feat: push checkout #14

Merged
reya merged 14 commits from feat/fork into master 2026-09-06 13:14:13 +00:00
2 changed files with 378 additions and 180 deletions
Showing only changes of commit 7b6b4b09a4 - Show all commits
+208 -1
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use anyhow::Error;
use bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
use nostr::event::IntoEventBuilder;
use nostr_sdk::prelude::*;
use signed_core::{
@@ -16,8 +16,10 @@ use signed_core::{
use crate::backend::{
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
};
use crate::checkouts::CheckoutsStore;
use crate::git_store::GitStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repo_list::RepoListStore;
/// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
@@ -57,6 +59,14 @@ pub struct RepoStore {
///
/// Example, a PR published without its commit reaching a grasp server.
pub last_warning: Option<String>,
/// A republish or a checkout push is in flight.
///
/// Views show a spinner and disable their push triggers while it is set.
pub pushing: bool,
/// A clone-into-a-folder operation is in flight.
///
/// Views show a spinner and disable the clone trigger while it is set.
pub cloning: bool,
/// Relays already asked to connect to, from this repository's NIP-34 `relays` tag.
///
/// Avoids re-subscribing and re-fetching on every refresh.
@@ -127,6 +137,8 @@ impl RepoStore {
version: 0,
last_error: None,
last_warning: None,
pushing: false,
cloning: false,
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
refresh: RefreshGate::default(),
@@ -1048,6 +1060,201 @@ impl RepoStore {
}));
}
/// The latest announcement of this repository,
/// for operations that need its clone URLs and relays.
fn action_announcement(&self, cx: &App) -> Option<Announcement> {
self.announcement.clone().or_else(|| {
RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == self.addr)
.cloned()
})
}
/// Re-push the repository's refs to its announced grasp servers, republish.
pub fn push_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
if self.pushing {
return Task::ready(Err(anyhow::anyhow!(
"A push to this repository is already in progress"
)));
}
let Some(announcement) = self.action_announcement(cx) else {
return self.action_error("Repository announcement is not loaded yet", cx);
};
self.pushing = true;
self.last_error = None;
cx.notify();
let backend = Backend::global(cx);
let push = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx));
cx.spawn(async move |this, cx| {
let result = push.await;
this.update(cx, |this, cx| {
this.pushing = false;
if let Err(e) = &result {
this.last_error = Some(format!("Push failed: {e}"));
}
cx.notify();
})?;
result
})
}
/// Push the unpushed commits of the local checkout at `path`.
pub fn push_checkout(
&mut self,
path: PathBuf,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
if self.pushing {
return Task::ready(Err(anyhow::anyhow!(
"A push to this repository is already in progress"
)));
}
let Some(announcement) = self.action_announcement(cx) else {
return self.action_error("Repository announcement is not loaded yet", cx);
};
// The state event's `HEAD` stays the announced default branch.
// The checkout may be on a side branch.
let head = self.head.clone();
let addr = self.addr.clone();
self.pushing = true;
self.last_error = None;
cx.notify();
let backend = Backend::global(cx);
let push = backend.update(cx, |backend, cx| {
backend.push_checkout(announcement, path.clone(), head, cx)
});
cx.spawn(async move |this, cx| {
let result = push.await;
this.update(cx, |this, cx| {
this.pushing = false;
match &result {
Ok(()) => {
// The remote moved, so recompute the ready-to-push statuses.
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| {
store.request_push_statuses(&addr, cx);
cx.notify();
});
}
Err(e) => {
this.last_error = Some(format!("Push failed: {e}"));
}
}
cx.notify();
})?;
result
})
}
/// Delete the repository from nostr, announcement, state and activity.
///
/// Only the repository owner may delete it. The lists update when the
/// deletion events arrive.
pub fn delete_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
let addr = self.addr.clone();
self.last_error = None;
let backend = Backend::global(cx);
let delete = backend.update(cx, |backend, cx| backend.delete_repository(addr, cx));
cx.spawn(async move |this, cx| {
let result = delete.await;
this.update(cx, |this, cx| {
if let Err(e) = &result {
this.last_error = Some(format!("Delete failed: {e}"));
}
cx.notify();
})?;
result
})
}
/// Clone the repository into `destination`, a user-chosen folder outside
/// the cache, and remember the clone as a checkout of this repository.
pub fn clone_to_folder(
&mut self,
destination: PathBuf,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
if self.cloning {
return Task::ready(Err(anyhow::anyhow!(
"A clone of this repository is already in progress"
)));
}
let Some(announcement) = self.action_announcement(cx) else {
return self.action_error("Repository announcement is not loaded yet", cx);
};
let clone_urls: Vec<String> = announcement.clone.iter().map(ToString::to_string).collect();
let addr = self.addr.clone();
self.cloning = true;
self.last_error = None;
cx.notify();
let clone = {
let destination = destination.clone();
cx.background_spawn(async move { signed_git::clone_repo(&clone_urls, &destination) })
};
cx.spawn(async move |this, cx| {
let result = clone.await;
this.update(cx, |this, cx| {
this.cloning = false;
match &result {
Ok(()) => {
// Remember the clone as a checkout of this repository.
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| {
store.record(destination.clone(), addr.clone(), cx);
});
}
Err(e) => {
this.last_error = Some(format!("Failed to clone: {e}"));
}
}
cx.notify();
})?;
result
})
}
/// Fail an operation whose announcement is not loaded yet.
fn action_error(
&mut self,
message: impl Into<String>,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
let message = message.into();
self.last_error = Some(message.clone());
cx.notify();
Task::ready(Err(anyhow::anyhow!("{message}")))
}
/// Publish a kind-1631 Applied status event for `root` after a merge.
fn publish_applied_status(
&mut self,
+170 -179
View File
@@ -163,10 +163,6 @@ pub struct RepoDetailView {
item_sizes: Rc<Vec<Size<Pixels>>>,
/// A clone/fetch is in flight.
loading: bool,
/// The header clone button is cloning into a user-chosen folder.
cloning: bool,
/// A push to the grasp servers is in flight.
pushing: bool,
error: Option<SharedString>,
/// Commit HEAD currently points to, shown in the header button.
head_commit: Option<FileCommit>,
@@ -337,8 +333,6 @@ impl RepoDetailView {
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()),
loading: true,
cloning: false,
pushing: false,
error: None,
head_commit: None,
branch_select,
@@ -358,10 +352,9 @@ impl RepoDetailView {
}
/// Load the repository and populate the file explorer.
///
/// A local, not yet published, repository opens straight from disk.
/// An announced repository's clone, if any, loads first without touching the network.
/// An unreachable server can't block the panel.
/// A background fetch then refreshes the refs and commit list.
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
@@ -386,6 +379,7 @@ impl RepoDetailView {
this.loading = false;
cx.notify();
})?;
Ok(())
});
@@ -452,6 +446,7 @@ impl RepoDetailView {
if !had_clone {
return Ok(());
}
let refresh = {
let cache = cache.clone();
let addr = addr.clone();
@@ -459,9 +454,11 @@ impl RepoDetailView {
let Some(repo) = cache.open(&addr)? else {
return Ok::<_, Error>(None);
};
// Best-effort, a fetch failure, e.g. offline, keeps the cached state.
// The state is already shown.
signed_git::fetch_all(&repo).ok();
let worktree = repo.workdir().map(Path::to_path_buf);
// A fetch never moves a mirror's local branches.
// A push landing on the grasp servers would never show up.
@@ -474,6 +471,7 @@ impl RepoDetailView {
}
None => false,
};
let (branches, tags) = match &worktree {
Some(_) => (
signed_git::repo_branches(&repo).unwrap_or_default(),
@@ -481,8 +479,10 @@ impl RepoDetailView {
),
None => (Vec::new(), Vec::new()),
};
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit)))
})
}
@@ -539,8 +539,6 @@ impl RepoDetailView {
}
/// Apply the loaded repository data.
/// Sets the explorer tree, README preview, ref selectors and HEAD commit.
/// Then starts the commit-list walk.
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
let RepoData {
tree,
@@ -592,19 +590,16 @@ impl RepoDetailView {
}
/// Clone the repository into a user-chosen folder outside the cache.
/// Then open the new clone in the system file manager.
fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.cloning {
let Some(store) = self.store.clone() else {
return;
}
};
let (clone_urls, name, addr) = {
let name = {
let Some(announcement) = self.announcement(cx) else {
return;
};
let addr = announcement.addr();
let clone_urls: Vec<String> =
announcement.clone.iter().map(ToString::to_string).collect();
// Directory name, the display name falling back to the repo id.
// Both are sanitized to a safe single path component.
let name = announcement
@@ -614,17 +609,13 @@ impl RepoDetailView {
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| addr.identifier.clone());
let name = signed_git::sanitize_path_component(&name);
let name = if name.is_empty() {
if name.is_empty() {
"repository".to_owned()
} else {
name
};
(clone_urls, name, addr)
}
};
self.cloning = true;
cx.notify();
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
@@ -640,39 +631,25 @@ impl RepoDetailView {
_ => None,
};
let Some(folder) = picked else {
this.update_in(cx, |this, _window, cx| {
this.cloning = false;
cx.notify();
})?;
return Ok(());
};
let destination = folder.join(&name);
let destination_for_open = destination.clone();
let clone_target = destination_for_open.clone();
let result = cx
.background_spawn(async move { signed_git::clone_repo(&clone_urls, &clone_target) })
.await;
this.update_in(cx, |this, _window, cx| {
this.cloning = false;
match result {
Ok(_) => {
cx.open_with_system(&destination_for_open);
// Remember the clone as a checkout of this repository.
// The New PR panel pre-fills it.
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| {
store.record(destination, addr, cx);
});
}
Err(error) => {
this.error = Some(format!("Failed to clone: {error}").into());
}
}
cx.notify();
// The store owns the clone, its busy flag and error reporting.
let clone = this.update_in(cx, |_this, _window, cx| {
store.update(cx, |store, cx| store.clone_to_folder(destination, cx))
})?;
// Reveal the new clone in the system file manager on success.
// Failures already surfaced in the store's error banner.
if let Ok(()) = clone.await {
this.update_in(cx, |_this, _window, cx| {
cx.open_with_system(&destination_for_open);
})?;
}
Ok(())
});
@@ -726,6 +703,7 @@ impl RepoDetailView {
self.loading_files.insert(path.to_string());
let path = path.to_string();
self.load_commit(&path, cx);
let generation = self.ref_generation;
@@ -906,8 +884,6 @@ impl RepoDetailView {
}
/// Open a new panel showing the diff of `commit_id`.
/// All files it changed, with the line diff of each.
/// Called from the Commits tab rows and the latest-commit button.
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(worktree) = self.worktree.clone() else {
return;
@@ -929,121 +905,63 @@ impl RepoDetailView {
}
/// Re-push the repository's refs to its announced grasp servers.
/// The menu trigger shows a spinner while the push is in flight.
/// Failures appear in the panel's error banner.
fn push_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.pushing {
return;
}
let Some(announcement) = self.announcement(cx).cloned() else {
fn push_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
self.pushing = true;
self.error = None;
cx.notify();
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx));
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, _window, cx| {
if let Err(error) = result {
this.error = Some(format!("Push failed: {error}").into());
}
this.pushing = false;
cx.notify();
})?;
Ok(())
}));
self.tasks
.push(store.update(cx, |store, cx| store.push_repository(cx)));
}
/// Push the unpushed commits of the local checkout at `path`.
/// The checkout is an owned repository's working copy.
/// Failures appear in the panel's error banner.
/// On success the push statuses are recomputed so the banner clears.
fn push_unpushed_checkout(
&mut self,
path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.pushing {
let Some(store) = self.store.clone() else {
return;
};
if store.read(cx).pushing {
return;
}
let Some(announcement) = self.announcement(cx).cloned() else {
return;
};
// The state event's `HEAD` stays the announced default branch.
// The checkout may be on a side branch.
let head = self
.store
.as_ref()
.and_then(|store| store.read(cx).head.clone());
let addr = announcement.addr();
self.pushing = true;
self.error = None;
cx.notify();
let backend = Backend::global(cx);
let checkout = CheckoutsStore::global(cx);
let task = backend.update(cx, |backend, cx| {
backend.push_checkout(announcement, path.clone(), head, cx)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(()) => {
// The remote moved, so recompute the push statuses.
// The banner disappears.
// Refresh the mirror, fetch, fast-forward and an explorer reload.
// The pushed commits then show in the panel.
checkout.update(cx, |store, cx| {
store.request_push_statuses(&addr, cx);
});
this.load_repo(window, cx);
}
Err(error) => {
this.error = Some(format!("Push failed: {error}").into());
}
}
this.pushing = false;
cx.notify();
let task = cx.spawn_in(window, async move |this, cx| {
// The store owns the push, its busy flag and error reporting.
let push = this.update_in(cx, |_this, _window, cx| {
store.update(cx, |store, cx| store.push_checkout(path.clone(), cx))
})?;
// The remote moved, refresh the mirror browsing.
// Failures already surfaced in the store's error banner.
if let Ok(()) = push.await {
this.update_in(cx, |this, window, cx| {
this.load_repo(window, cx);
})?;
}
Ok(())
}));
});
self.tasks.push(task);
}
/// Delete the repository from nostr, announcement, state and activity.
/// Only offered to the repository owner.
/// The sidebar list updates when the deletion events arrive.
fn delete_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(announcement) = self.announcement(cx).cloned() else {
fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
return;
};
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| {
backend.delete_repository(announcement.addr(), cx)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, _window, cx| {
if let Err(error) = result {
this.error = Some(format!("Delete failed: {error}").into());
}
cx.notify();
})?;
Ok(())
}));
self.tasks
.push(store.update(cx, |store, cx| store.delete_repository(cx)));
}
/// Open the issues list panel in the dock area.
@@ -1380,10 +1298,15 @@ impl RepoDetailView {
let Some(store_entity) = self.store.as_ref() else {
return div().into_any_element();
};
let store = store_entity.read(cx);
let issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_count().to_string());
// Busy flags are owned by the store; observers re-render on their changes.
let pushing = store.pushing;
let cloning = store.cloning;
let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else {
return div().into_any_element();
};
@@ -1397,8 +1320,10 @@ impl RepoDetailView {
.nip05
.clone()
.filter(|nip05| !nip05.trim().is_empty());
let announcement = Rc::new(source.clone());
let share = Rc::new(ShareTargets::from_announcement(&announcement));
let nostr_url = nostr_clone_url(&announcement, nip05.as_deref());
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
@@ -1608,8 +1533,8 @@ impl RepoDetailView {
.tooltip("Repository management")
.compact()
.secondary()
.loading(self.pushing)
.disabled(self.pushing)
.loading(pushing)
.disabled(pushing)
.dropdown_menu(move |menu, _, cx| {
let backend = Backend::global(cx);
let current_user = backend.read(cx).current_user();
@@ -1660,8 +1585,8 @@ impl RepoDetailView {
Button::new("clone")
.icon(CustomIconName::GitClone)
.tooltip("Clone")
.loading(self.cloning)
.disabled(self.cloning)
.loading(cloning)
.disabled(cloning)
.primary(),
)
.content(move |_, _window, cx| {
@@ -1983,6 +1908,11 @@ impl RepoDetailView {
let status = self.push_suggestion(cx)?;
let key = (status.path.clone(), status.branch.clone());
let path = status.path.clone();
// The push busy flag lives on the store; it disables the banner's triggers.
let pushing = self
.store
.as_ref()
.is_some_and(|store| store.read(cx).pushing);
let commits = if status.ahead == 1 {
SharedString::from("1 commit")
@@ -2038,9 +1968,9 @@ impl RepoDetailView {
.icon(IconName::ArrowUp)
.label("Push")
.small()
.primary()
.loading(self.pushing)
.disabled(self.pushing)
.info()
.loading(pushing)
.disabled(pushing)
.on_click(cx.listener(move |this, _event, window, cx| {
this.push_unpushed_checkout(path.clone(), window, cx);
})),
@@ -2051,7 +1981,7 @@ impl RepoDetailView {
.tooltip("Dismiss")
.small()
.ghost()
.disabled(self.pushing)
.disabled(pushing)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banner_dismissed.insert(key.clone());
cx.notify();
@@ -2063,52 +1993,102 @@ impl RepoDetailView {
}
/// The ready-to-contribute banner of the repository panel.
/// A message, a Create action opening the prefilled New PR panel.
/// Plus a dismiss control.
///
/// A checkout has commits ahead of its base branch, with a Create action
/// opening the prefilled New PR panel, and a dismiss control.
fn render_ready_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.ready_suggestion(cx)?;
let commits = if status.ahead == 1 {
"1 commit".to_owned()
} else {
format!("{} commits", status.ahead)
};
let message = SharedString::from(format!(
"{} is {} ahead of {} in {}",
status.branch,
commits,
status.base,
status.path.display()
));
let key = (status.path.clone(), status.branch.clone());
let view = cx.entity().clone();
let commits = if status.ahead == 1 {
SharedString::from("1 commit")
} else {
SharedString::from(format!("{} commits", status.ahead))
};
Some(
h_flex()
.p_4()
.gap_2()
.px_4()
.pt_1()
.w_full()
.items_center()
.justify_between()
.bg(cx.theme().muted)
.child(
Alert::info("repo-ready-to-contribute", message)
.banner()
.flex_1()
.on_close(move |_event, _window, cx| {
view.update(cx, |this, _| {
this.banner_dismissed.insert(key.clone());
});
}),
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().info)
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(status.branch),
)
.child("is")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(commits),
)
.child("ahead of")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(status.base),
),
)
.child(
Button::new("create-pr-from-banner")
.small()
.icon(IconName::Plus)
.label("Create pull request")
.on_click(cx.listener(|this, _event, window, cx| {
if let Some(store) = this.store.clone() {
open_new_pull_panel(this.dock_area.clone(), store, window, cx);
}
})),
h_flex()
.gap_1()
.child(
Button::new("create-pr-from-banner")
.icon(IconName::Plus)
.label("Create")
.small()
.info()
.on_click(cx.listener(|this, _event, window, cx| {
if let Some(store) = this.store.clone() {
open_new_pull_panel(
this.dock_area.clone(),
store,
window,
cx,
);
}
})),
)
.child(
Button::new("dismiss-ready-banner")
.icon(IconName::Close)
.tooltip("Dismiss")
.small()
.ghost()
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banner_dismissed.insert(key.clone());
cx.notify();
})),
),
)
.into_any_element(),
)
@@ -2331,18 +2311,29 @@ impl Render for RepoDetailView {
.render_ready_banner(cx)
.or_else(|| self.render_push_banner(cx));
// View-level load/switch errors, plus the errors of the store-owned
// operations, republish, checkout push, delete and clone-to-folder.
let error = self.error.clone().or_else(|| {
self.store
.as_ref()
.and_then(|store| store.read(cx).last_error.clone().map(SharedString::from))
});
v_flex()
.image_cache(gpui::retain_all("repo"))
.id("repo")
.size_full()
.when_some(banner, |this, banner| this.child(banner))
.child(self.render_header(cx))
.when_some(self.error.clone(), |this, error| {
.when_some(error, |this, error| {
this.child(
Alert::error("repo-error", error)
.banner()
.on_close(cx.listener(|this, _event, _window, cx| {
this.error = None;
if let Some(store) = this.store.clone() {
store.update(cx, |store, _| store.last_error = None);
}
cx.notify();
})),
)