diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 47c0106..bb8071f 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -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, + /// 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 { + 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) -> Task> { + 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, + ) -> Task> { + 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) -> Task> { + 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, + ) -> Task> { + 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 = 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, + cx: &mut Context, + ) -> Task> { + 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, diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index db95040..f955430 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -163,10 +163,6 @@ pub struct RepoDetailView { item_sizes: Rc>>, /// 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, /// Commit HEAD currently points to, shown in the header button. head_commit: Option, @@ -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.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) { 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) { - 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 = - 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) { 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) { - if self.pushing { - return; - } - let Some(announcement) = self.announcement(cx).cloned() else { + fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { + 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, ) { - 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) { - let Some(announcement) = self.announcement(cx).cloned() else { + fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { + 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) -> Option { 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(); })), )