From 651ffec22ddbbff3ab557867aebc0b9e47239f45 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 12 Sep 2026 13:35:10 +0700 Subject: [PATCH 01/12] update workspace --- Cargo.lock | 52 +----------------- Cargo.toml | 1 - crates/workspace/Cargo.toml | 1 - crates/workspace/src/workspace.rs | 87 +++++++++++++------------------ 4 files changed, 38 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d23ca2..7dc6af9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3561,7 +3561,6 @@ dependencies = [ "gpui_macros", "gpui_shared_string", "gpui_util", - "hdrhistogram", "heapless 0.9.3", "http_client", "image", @@ -3745,20 +3744,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "gpui-fps" -version = "0.1.0" -source = "git+https://github.com/longbridge/gpui-component?rev=39c2c86dbee7ad445591462f8675f74082e10828#39c2c86dbee7ad445591462f8675f74082e10828" -dependencies = [ - "gpui", - "instant", - "libc", - "objc2-core-foundation", - "objc2-io-kit", - "sysinfo 0.37.2", - "windows 0.58.0", -] - [[package]] name = "gpui_apple" version = "0.1.0" @@ -4096,16 +4081,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hdrhistogram" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" -dependencies = [ - "byteorder", - "num-traits", -] - [[package]] name = "heapless" version = "0.8.0" @@ -6035,16 +6010,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -8526,20 +8491,6 @@ dependencies = [ "windows 0.57.0", ] -[[package]] -name = "sysinfo" -version = "0.37.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.61.3", -] - [[package]] name = "system-configuration" version = "0.6.1" @@ -10907,7 +10858,6 @@ dependencies = [ "gpui", "gpui-base", "gpui-component", - "gpui-fps", "log", "nostr", "paths", @@ -11333,7 +11283,7 @@ dependencies = [ "rand 0.8.8", "screencapturekit", "screencapturekit-sys", - "sysinfo 0.31.4", + "sysinfo", "tao-core-video-sys", "windows 0.61.3", "windows-capture", diff --git a/Cargo.toml b/Cargo.toml index 2327cfc..359ca4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } # GPUI Kit gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], } gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" } -gpui-fps = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" } dock = { path = "crates/dock" } settings = { path = "crates/settings" } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 59d09f5..f093cde 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -18,7 +18,6 @@ utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true gpui-base.workspace = true -gpui-fps.workspace = true gix.workspace = true nostr.workspace = true diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 05df0c0..6a0e209 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,28 +1,22 @@ use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle}; use gpui::prelude::*; -use gpui::{Context, Entity, KeyBinding, Render, Subscription, Window, actions, div, px}; +use gpui::{Context, Entity, Render, Subscription, Window, div, px}; use gpui_component::{Root, StyledExt, Theme}; -use gpui_fps::{FpsMonitor, FpsOverlay}; +use settings::{AppearanceMode, SettingsStore}; use signed_state::{Backend, BackendEvent}; use crate::views::SidebarPanel; use crate::views::sidebar::passphrase_dialog; -actions!(workspace, [ToggleMonitor]); - pub struct Workspace { dock: Entity, - fps: Entity, - /// Debug HUD, toggled with `cmd-shift-f`. - show_fps: bool, _subscriptions: Vec, - _passphrase_subscription: Subscription, } impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let fps = cx.new(|cx| FpsMonitor::new(window, cx).continuous(false)); - cx.bind_keys([KeyBinding::new("cmd-shift-f", ToggleMonitor, None)]); + let backend = Backend::global(cx); + let settings = SettingsStore::global(cx); let dock = cx.new(|cx| { let skin = SignedDockSkin::new(cx); @@ -33,20 +27,16 @@ impl Workspace { let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); let weak_sidebar = sidebar.downgrade(); - dock.update(cx, |dock_area, cx| { - dock_area.set_dock( - DockPlacement::Left, - DockLayout::tabs().panel_view(panel_handle(sidebar), cx), - window, - cx, - ); - dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx); - }); - let mut subscriptions = vec![]; + // Sync the system appearance if the appearance mode is set to system. + if settings.read(cx).settings().appearance == AppearanceMode::System { + subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| { + Theme::sync_system_appearance(Some(window), cx); + })); + } + // A bottom or right dock whose last panel was dragged away is removed entirely. - let dock_for_pruning = dock.clone(); subscriptions.push(cx.subscribe_in( &dock, window, @@ -54,9 +44,9 @@ impl Workspace { if !matches!(event, DockEvent::LayoutChanged) { return; } - let dock = dock_for_pruning.clone(); + let weak = weak_dock.clone(); cx.spawn_in(window, async move |_, window| { - dock.update_in(window, |area, window, cx| { + weak.update_in(window, |area, window, cx| { for placement in [DockPlacement::Bottom, DockPlacement::Right] { if area.is_empty(placement, cx) { area.remove_dock(placement, window, cx); @@ -69,28 +59,36 @@ impl Workspace { }, )); - subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| { - Theme::sync_system_appearance(Some(window), cx); - })); - - let backend = Backend::global(cx); - // Ask for the passphrase when the stored identity is NIP-49 encrypted. - let passphrase_subscription = - window.subscribe(&backend, cx, |_backend, event, window, cx| { + subscriptions.push(cx.subscribe_in( + &backend, + window, + |_this, _state, event, window, cx| { if matches!(event, BackendEvent::PassphraseRequired) { passphrase_dialog::open(window, cx); } + }, + )); + + cx.defer_in(window, move |this, window, cx| { + // The event may have fired before this window existed. + // Fall back to the backend state in that case. + if backend.read(cx).passphrase_required() { + passphrase_dialog::open(window, cx); + } + + // Open the sidebar and explore panel. + this.dock.update(cx, |dock_area, cx| { + dock_area.set_dock( + DockPlacement::Left, + DockLayout::tabs().panel_view(panel_handle(sidebar), cx), + window, + cx, + ); + dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx); }); - // The event may have fired before this window existed. - // Fall back to the backend state in that case. - if backend.read(cx).passphrase_required() { - passphrase_dialog::open(window, cx); - } - - // Open the explore panel after the sidebar has been initialized. - cx.defer_in(window, move |_, window, cx| { + // Open the explore panel. weak_sidebar .update(cx, |this, cx| { this.open_explore(window, cx); @@ -100,10 +98,7 @@ impl Workspace { Self { dock, - show_fps: cfg!(debug_assertions), - fps, _subscriptions: subscriptions, - _passphrase_subscription: passphrase_subscription, } } } @@ -115,12 +110,6 @@ impl Render for Workspace { div() .id("workspace") - .on_action( - cx.listener(|this: &mut Self, _ev: &ToggleMonitor, _window, cx| { - this.show_fps = !this.show_fps; - cx.notify(); - }), - ) .v_flex() .size_full() .relative() @@ -129,7 +118,5 @@ impl Render for Workspace { .children(notification_layer) // Modals .children(dialog_layer) - // On top of everything, so it stays readable while debugging. - .when(self.show_fps, |this| this.child(FpsOverlay::new(&self.fps))) } } -- 2.54.0 From f9f0d33bb159b88bfa369cd111c01a62b9be90b5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 12 Sep 2026 14:37:45 +0700 Subject: [PATCH 02/12] update --- crates/workspace/src/views/repo_list.rs | 98 ++++++++++++++--------- crates/workspace/src/views/sidebar/mod.rs | 22 ++--- 2 files changed, 72 insertions(+), 48 deletions(-) diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 126669a..c3dec27 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -1,3 +1,4 @@ +use std::fmt::Display; use std::rc::Rc; use assets::CustomIconName; @@ -5,7 +6,7 @@ use dock::{BasePanel, DockArea, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, Subscription, WeakEntity, Window, div, px, size, + SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size, }; use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::scroll::Scrollbar; @@ -38,6 +39,22 @@ enum RepoFilter { Recent, } +impl AsRef for RepoFilter { + fn as_ref(&self) -> &str { + match self { + RepoFilter::All => "all", + RepoFilter::Popular => "popular", + RepoFilter::Recent => "recent", + } + } +} + +impl Display for RepoFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_ref()) + } +} + impl RepoFilter { /// Indices into the store's `announcements` this filter includes, in display order. /// @@ -49,6 +66,7 @@ impl RepoFilter { // Narrow by the search query first. // Recent then limits the matches and Popular ranks them. let query = query.trim().to_lowercase(); + if !query.is_empty() { indices.retain(|&ix| { let announcement = &announcements[ix]; @@ -125,7 +143,11 @@ impl RepoListView { this.rebuild_rows(cx); }); - let mut this = Self { + cx.defer_in(window, |this, _window, cx| { + this.rebuild_rows(cx); + }); + + Self { store, dock_area, focus_handle: cx.focus_handle(), @@ -137,14 +159,7 @@ impl RepoListView { search, _search_subscription: search_subscription, _subscription: subscription, - }; - - // Seed the rows right away. - // The store may already hold announcements from before the panel opened. - // The first render must not depend on a later store update. - this.rebuild_rows(cx); - - this + } } /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store. @@ -154,10 +169,12 @@ impl RepoListView { let filter = self.filter; let query = self.search.read(cx).value(); let store = self.store.read(cx); + self.visible = filter.visible(store, &query); // Each virtual list row holds `COLUMNS` repo cards. let rows = self.visible.len().div_ceil(COLUMNS); + if self.repo_len != rows { self.repo_len = rows; self.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]); @@ -172,7 +189,7 @@ impl RepoListView { window: &mut Window, cx: &mut Context, ) { - open_repo_panel(&self.dock_area, announcement, window, &mut *cx); + open_repo_panel(&self.dock_area, announcement, window, cx); } fn render_card( @@ -308,6 +325,22 @@ impl RepoListView { .into_any_element() } + fn render_filter(&self, filter: RepoFilter, label: T, cx: &mut Context) -> AnyElement + where + T: Into, + { + let active = self.filter == filter; + + SegmentButton::new(filter.to_string(), label) + .icon(Icon::new(filter.icon_name())) + .selected(active) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.filter = filter; + this.rebuild_rows(cx); + })) + .into_any_element() + } + fn render_header(&self, count: usize, cx: &mut Context) -> AnyElement { h_flex() .px_4() @@ -315,18 +348,24 @@ impl RepoListView { .w_full() .gap_3() .child( - h_flex() - .gap_1() - .text_xs() - .child(div().font_semibold().child("Repositories")) + v_flex() + .gap_0p5() .child( div() - .w_10() .min_w_0() .truncate() .text_ellipsis() + .font_semibold() + .text_xs() + .line_height(relative(1.2)) + .child("Repositories"), + ) + .child( + div() + .text_size(px(10.)) .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!("({count})"))), + .line_height(relative(1.2)) + .child(SharedString::from(format!("Total: {count}"))), ), ) .child( @@ -342,31 +381,12 @@ impl RepoListView { .child( h_flex() .gap_1() - .child(self.filter_button(RepoFilter::All, "All", cx)) - .child(self.filter_button(RepoFilter::Popular, "Popular", cx)) - .child(self.filter_button(RepoFilter::Recent, "Recent", cx)), + .child(self.render_filter(RepoFilter::All, "All", cx)) + .child(self.render_filter(RepoFilter::Popular, "Popular", cx)) + .child(self.render_filter(RepoFilter::Recent, "Recent", cx)), ) .into_any_element() } - - /// One segmented header filter button, like the issues list's status filter buttons. - fn filter_button( - &self, - filter: RepoFilter, - label: &'static str, - cx: &mut Context, - ) -> AnyElement { - let active = self.filter == filter; - - SegmentButton::new(label, label) - .icon(Icon::new(filter.icon_name())) - .selected(active) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.filter = filter; - this.rebuild_rows(cx); - })) - .into_any_element() - } } impl BasePanel for RepoListView { diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 303471f..950e790 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -164,12 +164,12 @@ impl SidebarPanel { /// Recompute the badge counts from the global checkouts store's ready-to-push statuses fn refresh_unpushed(&mut self, cx: &mut Context) -> bool { - let checkouts = CheckoutsStore::global(cx).read(cx); + let checkouts = CheckoutsStore::global(cx); let mut unpushed = HashMap::with_capacity(self.announcements.len()); for announcement in self.announcements.iter() { let addr = announcement.addr(); - let count = checkouts.unpushed(&addr); + let count = checkouts.read(cx).unpushed(&addr); if count > 0 { unpushed.insert(addr, count); } @@ -202,9 +202,11 @@ impl SidebarPanel { let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx)); self.inbox = Some(panel.downgrade()); - let _ = self.dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); + self.dock_area + .update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }) + .ok(); } /// Open the Explore repository list panel in the dock area's center. @@ -221,9 +223,11 @@ impl SidebarPanel { let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx)); self.explore = Some(panel.downgrade()); - let _ = self.dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); + self.dock_area + .update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }) + .ok(); } /// Show the Onboarding dialog. @@ -599,9 +603,9 @@ impl Render for SidebarPanel { } v_flex() + .image_cache(gpui::retain_all("sidebar")) .size_full() .justify_between() - .image_cache(gpui::retain_all("sidebar")) .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .child( -- 2.54.0 From d68c09881868594f9c194f2b9e3c9911268add16 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 12 Sep 2026 18:26:06 +0700 Subject: [PATCH 03/12] restructure --- crates/signed_state/src/repo.rs | 20 +- crates/signed_state/src/repos.rs | 13 +- .../diff.rs => commit_diff/mod.rs} | 2 +- .../issue_detail.rs => issues/detail.rs} | 2 +- .../{repo_detail/issues.rs => issues/mod.rs} | 4 +- crates/workspace/src/views/mod.rs | 10 +- .../detail.rs} | 4 +- .../pull_requests.rs => pull_requests/mod.rs} | 9 +- .../new.rs} | 7 +- .../src/views/{repo_detail => repo}/about.rs | 0 crates/workspace/src/views/repo/actions.rs | 265 ++ crates/workspace/src/views/repo/banners.rs | 327 ++ .../{repo_detail/browser.rs => repo/files.rs} | 223 +- crates/workspace/src/views/repo/header.rs | 728 +++++ .../views/{repo_detail => repo}/helpers.rs | 135 +- crates/workspace/src/views/repo/history.rs | 237 ++ .../{repo_detail => repo}/init_dialog.rs | 0 crates/workspace/src/views/repo/loading.rs | 429 +++ crates/workspace/src/views/repo/mod.rs | 433 +++ crates/workspace/src/views/repo/refs.rs | 285 ++ crates/workspace/src/views/repo/store.rs | 102 + .../src/views/repo_detail/commits.rs | 160 - crates/workspace/src/views/repo_detail/mod.rs | 2706 ----------------- .../src/views/{repo_detail => }/send_patch.rs | 0 24 files changed, 3177 insertions(+), 2924 deletions(-) rename crates/workspace/src/views/{repo_detail/diff.rs => commit_diff/mod.rs} (99%) rename crates/workspace/src/views/{repo_detail/issue_detail.rs => issues/detail.rs} (98%) rename crates/workspace/src/views/{repo_detail/issues.rs => issues/mod.rs} (99%) rename crates/workspace/src/views/{repo_detail/pull_request_detail.rs => pull_requests/detail.rs} (99%) rename crates/workspace/src/views/{repo_detail/pull_requests.rs => pull_requests/mod.rs} (99%) rename crates/workspace/src/views/{repo_detail/new_pull_request.rs => pull_requests/new.rs} (99%) rename crates/workspace/src/views/{repo_detail => repo}/about.rs (100%) create mode 100644 crates/workspace/src/views/repo/actions.rs create mode 100644 crates/workspace/src/views/repo/banners.rs rename crates/workspace/src/views/{repo_detail/browser.rs => repo/files.rs} (53%) create mode 100644 crates/workspace/src/views/repo/header.rs rename crates/workspace/src/views/{repo_detail => repo}/helpers.rs (85%) create mode 100644 crates/workspace/src/views/repo/history.rs rename crates/workspace/src/views/{repo_detail => repo}/init_dialog.rs (100%) create mode 100644 crates/workspace/src/views/repo/loading.rs create mode 100644 crates/workspace/src/views/repo/mod.rs create mode 100644 crates/workspace/src/views/repo/refs.rs create mode 100644 crates/workspace/src/views/repo/store.rs delete mode 100644 crates/workspace/src/views/repo_detail/commits.rs delete mode 100644 crates/workspace/src/views/repo_detail/mod.rs rename crates/workspace/src/views/{repo_detail => }/send_patch.rs (100%) diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 2bd907b..854a2a1 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -397,6 +397,22 @@ impl RepoStore { }; let again = this.update(cx, |this, cx| { + // Compare before moving the freshly queried data in, so a pass + // that found nothing new does not notify observers. The store + // is polled in bursts while a sync is in flight; notifying on + // every identical pass would re-render the repository panel + // several times for no visible change. + let head_changed = state + .as_ref() + .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); + let changed = this.announcement != announcement + || head_changed + || this.issues != issues + || this.patches != patches + || this.pull_requests != pull_requests + || this.comments != comments + || this.status_by_root != status_by_root; + this.announcement = announcement; // The announcement may list relays for this repository's activity. @@ -454,7 +470,9 @@ impl RepoStore { }); } - cx.notify(); + if changed { + cx.notify(); + } this.refresh.finish() })?; diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index b4a88f7..4189909 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -154,7 +154,6 @@ pub struct RepoListStore { /// Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, /// Latest known activity timestamp per repository. - /// Covers announcements, state updates, patches, PRs, issues and statuses. pub last_activity: Arc>, /// Issues, pull requests and commits per repository. /// @@ -178,6 +177,7 @@ impl RepoListStore { /// Create the store listing all announcements. pub fn new(cx: &mut Context) -> Self { let backend = Backend::global(cx); + let weak = cx.entity().downgrade(); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { @@ -217,17 +217,12 @@ impl RepoListStore { } }); - let weak = cx.entity().downgrade(); cx.defer(move |cx| { - let result = weak.update(cx, |this, cx| { + weak.update(cx, |this, cx| { this.subscribe_remote(cx); - // Query the local database right away. - // The list never waits for the relay syncs started above to finish. this.refresh_initial(cx); - }); - if let Err(error) = result { - log::warn!("repo list store dropped before bootstrap could run: {error}"); - } + }) + .ok(); }); Self { diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/commit_diff/mod.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/diff.rs rename to crates/workspace/src/views/commit_diff/mod.rs index bcd6474..e6a3904 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -21,7 +21,7 @@ use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff}; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; -use super::helpers::{ +use crate::views::repo::helpers::{ DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items, }; diff --git a/crates/workspace/src/views/repo_detail/issue_detail.rs b/crates/workspace/src/views/issues/detail.rs similarity index 98% rename from crates/workspace/src/views/repo_detail/issue_detail.rs rename to crates/workspace/src/views/issues/detail.rs index 08b23e2..d6911f4 100644 --- a/crates/workspace/src/views/repo_detail/issue_detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -13,7 +13,7 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; +use crate::views::repo::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; /// Detail panel of a single issue. pub struct IssueDetailView { diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/issues/mod.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/issues.rs rename to crates/workspace/src/views/issues/mod.rs index 6820606..6aceaec 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -21,7 +21,9 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::issue_detail::IssueDetailView; +pub(super) mod detail; + +use self::detail::IssueDetailView; /// Height of one issue row in the virtual list. const ISSUE_ROW_HEIGHT: f32 = 73.; diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index eb79c7e..a098cb4 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,11 +1,15 @@ +mod commit_diff; mod dialog_state; mod inbox; -mod repo_detail; +mod issues; +mod pull_requests; +mod repo; mod repo_list; +mod send_patch; pub(crate) mod sidebar; pub use inbox::InboxView; -pub use repo_detail::RepoDetailView; -pub(crate) use repo_detail::{RepoItem, open_repo_item, open_repo_panel}; +pub use repo::RepoDetailView; +pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel}; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/pull_requests/detail.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/pull_request_detail.rs rename to crates/workspace/src/views/pull_requests/detail.rs index 86ed7ba..8439a3b 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -30,8 +30,8 @@ use signed_state::{Backend, GitStore, ProfileStore, RepoStore}; use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; -use super::diff::{CommitDiffView, DiffPane}; -use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; +use crate::views::commit_diff::{CommitDiffView, DiffPane}; +use crate::views::repo::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; /// Height of one commit row in the commits tab's virtual list. const ROW_HEIGHT: f32 = 37.; diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/pull_requests/mod.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/pull_requests.rs rename to crates/workspace/src/views/pull_requests/mod.rs index f5cab0e..e32bdda 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -19,10 +19,13 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::RepoAction; -use super::new_pull_request::open_new_pull_panel; -use super::pull_request_detail::PullRequestDetailView; +pub(super) mod detail; +pub(super) mod new; + +use self::detail::PullRequestDetailView; +use self::new::open_new_pull_panel; use super::send_patch::open_send_patch_panel; +use crate::views::repo::RepoAction; /// Height of one pull request row in the virtual list. const ROW_HEIGHT: f32 = 73.; diff --git a/crates/workspace/src/views/repo_detail/new_pull_request.rs b/crates/workspace/src/views/pull_requests/new.rs similarity index 99% rename from crates/workspace/src/views/repo_detail/new_pull_request.rs rename to crates/workspace/src/views/pull_requests/new.rs index b2b4a07..f087c4a 100644 --- a/crates/workspace/src/views/repo_detail/new_pull_request.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -29,9 +29,8 @@ use signed_git::{ use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore}; use signed_ui::{CountBadge, placeholder}; -use super::commits::{COMMIT_ROW_HEIGHT, commit_row}; -use super::diff::{CommitDiffView, DiffPane}; -use super::helpers::ref_selector_trigger; +use crate::views::commit_diff::{CommitDiffView, DiffPane}; +use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row, ref_selector_trigger}; /// The new pull request panel of a repository. pub struct NewPullRequestView { @@ -1342,7 +1341,7 @@ impl NewPullRequestView { } /// Open the new pull request panel in the center dock. -pub(super) fn open_new_pull_panel( +pub(crate) fn open_new_pull_panel( dock_area: WeakEntity, store: Entity, window: &mut Window, diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo/about.rs similarity index 100% rename from crates/workspace/src/views/repo_detail/about.rs rename to crates/workspace/src/views/repo/about.rs diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs new file mode 100644 index 0000000..095fec5 --- /dev/null +++ b/crates/workspace/src/views/repo/actions.rs @@ -0,0 +1,265 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Error; +use dock::{DockArea, add_center_panel, panel_handle}; +use gpui::prelude::*; +use gpui::{App, Context, Entity, WeakEntity, Window}; +use gpui_base::dock::PanelView; +use nostr::prelude::EventId; +use signed_core::{Announcement, filters}; +use signed_state::{Backend, RepoListStore, RepoStore}; + +use super::RepoDetailView; +use crate::views::issues::IssuesView; +use crate::views::issues::detail::IssueDetailView; +use crate::views::pull_requests::PullRequestsView; +use crate::views::pull_requests::detail::PullRequestDetailView; +use crate::views::repo::init_dialog; + +impl RepoDetailView { + /// Re-push the repository's refs to its announced grasp servers. + pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + + self.error = None; + cx.notify(); + + store + .update(cx, |store, cx| store.push_repository(cx)) + .detach(); + } + + /// Push the unpushed commits of the local checkout at `path`. + pub(super) fn push_unpushed_checkout( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let Some(store) = self.store.clone() else { + return; + }; + + if store.read(cx).pushing { + return; + } + + self.error = None; + cx.notify(); + + let task: gpui::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(()) + }); + + task.detach(); + } + + /// Delete the repository from nostr, announcement, state and activity. + pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + store + .update(cx, |store, cx| store.delete_repository(cx)) + .detach(); + } + + /// Open the issues list panel in the dock area. + pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } + + /// Open the pull requests list panel in the dock area. + pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } + + /// Open the upstream repository, the `u` tag of this fork's announcement. + /// The upstream announcement may not be in the local database yet. + /// Subscribe for it and open the panel as soon as it lands. + pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { + if self.pending_upstream.is_some() { + return; + } + + let Some(announcement) = self.announcement(cx).cloned() else { + return; + }; + + let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else { + return; + }; + + if let Some(found) = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|a| a.addr() == addr) + .cloned() + { + open_repo_panel(&self.dock_area, &found, window, &mut *cx); + return; + } + + let backend = Backend::global(cx); + backend.update(cx, |backend, cx| { + backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx); + }); + self.pending_upstream = Some(addr); + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + for _ in 0..60 { + cx.background_executor() + .timer(Duration::from_millis(250)) + .await; + + let opened = this.update_in(cx, |this, window, cx| { + let Some(addr) = this.pending_upstream.clone() else { + return true; + }; + let found = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|a| a.addr() == addr) + .cloned(); + match found { + Some(found) => { + this.pending_upstream = None; + open_repo_panel(&this.dock_area, &found, window, &mut *cx); + true + } + None => false, + } + })?; + + if opened { + return Ok(()); + } + } + + this.update(cx, |this, _cx| this.pending_upstream = None)?; + Ok(()) + }); + + task.detach(); + } + + /// Open the dialog guiding the user through publishing the local repository to NIP-34. + pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(local_path) = self.local_path.clone() else { + return; + }; + let view = cx.entity().downgrade(); + init_dialog::open(local_path, view, window, cx); + } +} + +/// Open `announcement` as a repository panel in the dock's center. +pub(crate) fn open_repo_panel( + dock_area: &WeakEntity, + announcement: &Announcement, + window: &mut Window, + cx: &mut App, +) -> Entity { + let detail = + cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx)); + + if let Some(dock_area) = dock_area.upgrade() { + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(detail.clone()), window, cx); + }); + } + + detail +} + +/// The nostr store of `announcement`'s repository, without opening a repository panel. +fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity { + cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)) +} + +/// An item of a repository to open from outside its detail panel. +/// A patch has no detail view in Signed, so it opens nothing. +pub(crate) enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} + +/// Open the detail panel of `item` in `announcement`'s repository, in the dock's center. +/// +/// The repository store is built here, not taken from a `RepoDetailView`, so the +/// item panel is the only panel docked. +/// +/// A patch opens nothing: patches are only consumed inside a pull request's +/// detail panel, and have no panel of their own. +pub(crate) fn open_repo_item( + dock_area: &WeakEntity, + announcement: &Announcement, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { + let panel: Arc = + match item { + RepoItem::Issue(issue_id) => { + let store = repo_store(announcement, cx); + panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) + } + RepoItem::PullRequest(pr_id) => { + let store = repo_store(announcement, cx); + panel_handle(cx.new(|cx| { + PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx) + })) + } + RepoItem::Patch => return, + }; + + let Some(dock_area) = dock_area.upgrade() else { + return; + }; + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel, window, cx); + }); +} diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs new file mode 100644 index 0000000..c50a7c2 --- /dev/null +++ b/crates/workspace/src/views/repo/banners.rs @@ -0,0 +1,327 @@ +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{AnyElement, App, Context, SharedString, div, transparent_white}; +use gpui_base::Disableable; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, h_flex}; +use signed_core::RepoStatus; +use signed_state::{Backend, CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; + +use super::RepoDetailView; +use crate::views::pull_requests::new::open_new_pull_panel; + +impl RepoDetailView { + /// The first checkout ready for a pull request on this repository. + /// Not covered by an open PR of the signed-in user. + /// Not dismissed in this panel. + /// The repository's own checkouts are not suggested here. + /// Their work is pushed, see [`Self::push_suggestion`]. + fn ready_suggestion(&self, cx: &App) -> Option { + let store = self.store.as_ref()?; + let addr = store.read(cx).addr().clone(); + let user = Backend::global(cx).read(cx).current_user()?; + if store.read(cx).is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr); + + 'status: for status in statuses { + if self + .banner_dismissed + .contains(&(status.path.clone(), status.branch.clone())) + { + continue; + } + let store = store.read(cx); + for pr in &store.pull_requests { + if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) + { + continue 'status; + } + } + return Some(status); + } + + None + } + + /// The first checkout of this owned repository with unpushed commits. + /// + /// Not dismissed in this panel. + fn push_suggestion(&self, cx: &App) -> Option { + let entity = self.store.as_ref()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if !entity.read(cx).is_author(&user) { + return None; + } + + let addr = entity.read(cx).addr().clone(); + let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr); + + statuses.into_iter().find(|status| { + !self + .banner_dismissed + .contains(&(status.path.clone(), status.branch.clone())) + }) + } + + /// The ready-to-push banner of an owned repository. + /// + /// A local checkout has unpushed commits, with a Push action and a dismiss control. + pub(super) fn render_push_banner(&self, cx: &Context) -> Option { + 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") + } else { + SharedString::from(format!("{} commits", status.ahead)) + }; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( + 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("has") + .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("ready to push"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("push-checkout-banner") + .icon(IconName::ArrowUp) + .label("Push") + .small() + .info() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(move |this, _event, window, cx| { + this.push_unpushed_checkout(path.clone(), window, cx); + })), + ) + .child( + Button::new("close-repo") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .disabled(pushing) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.banner_dismissed.insert(key.clone()); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + /// Warning after a push that only some grasp servers accepted. + pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { + let store = self.store.as_ref()?; + let store = store.read(cx); + let warning = store.last_push_warning.clone()?; + let pushing = store.pushing; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_start() + .justify_between() + .bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08)) + .child( + h_flex() + .gap_2() + .min_w_0() + .flex_1() + .items_start() + .child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0()) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_color(cx.theme().warning) + .child(SharedString::from(warning)), + ), + ) + .child( + h_flex() + .gap_1() + .flex_shrink_0() + .child( + Button::new("republish-after-partial-push") + .icon(CustomIconName::Init) + .label("Republish") + .small() + .info() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(|this, _event, window, cx| { + this.push_repository(window, cx); + })), + ) + .child( + Button::new("dismiss-push-warning") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .disabled(pushing) + .on_click(cx.listener(|this, _ev, _window, cx| { + if let Some(store) = this.store.clone() { + store.update(cx, |store, _| { + store.last_push_warning = None; + }); + } + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + /// The ready-to-contribute banner of the repository panel. + pub(super) fn render_ready_banner(&self, cx: &Context) -> Option { + let status = self.ready_suggestion(cx)?; + let key = (status.path.clone(), status.branch.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() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( + 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( + 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(), + ) + } +} diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo/files.rs similarity index 53% rename from crates/workspace/src/views/repo_detail/browser.rs rename to crates/workspace/src/views/repo/files.rs index 389aeae..152c317 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -1,3 +1,6 @@ +use std::path::{Component, Path}; + +use anyhow::Error; use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; @@ -10,7 +13,7 @@ use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; use signed_ui::{placeholder, tree_row}; use super::RepoDetailView; -use super::helpers::{code_language, is_markdown_path}; +use crate::views::repo::helpers::{code_language, is_markdown_path}; /// Width of the file explorer column. const TREE_WIDTH: f32 = 240.; @@ -39,6 +42,8 @@ pub(super) struct MarkdownView { /// Source path, `None` means the repository README. pub(super) path: Option, pub(super) state: Entity, + /// Hash of the source, so the same document is not re-parsed on a refresh. + source_hash: u64, } /// A code file loaded into a persistent [`InputState`]. @@ -46,6 +51,21 @@ pub(super) struct CodeView { /// Source path, relative to the worktree root. pub(super) path: SharedString, pub(super) state: Entity, + /// Hash of the source, so the same document is not re-parsed on a refresh. + source_hash: u64, +} + +/// Hash of a preview's source text. +/// +/// Two loads of the same document produce the same hash, so the persistent +/// markdown/editor state can be kept instead of rebuilt, which would re-parse +/// and flash the pane. +fn source_hash(text: &str) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() } /// Spinner shown while a document is being loaded/parsed. @@ -217,9 +237,21 @@ impl RepoDetailView { text: &str, cx: &mut Context, ) { + let hash = source_hash(text); + if let Some(md) = &self.md + && md.path == path + && md.source_hash == hash + { + return; + } + let state = cx.new(|cx| TextViewState::markdown("", cx)); state.update(cx, |state, cx| state.push_str(text, cx)); - self.md = Some(MarkdownView { path, state }); + self.md = Some(MarkdownView { + path, + state, + source_hash: hash, + }); } /// The persistent markdown TextView for `path`, where `None` is the README. @@ -257,6 +289,14 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) { + let hash = source_hash(text); + if let Some(code) = &self.code + && code.path == path + && code.source_hash == hash + { + return; + } + let language = code_language(path.as_ref()).unwrap_or("text"); let state = cx.new(|cx| { EditorState::new(window, cx) @@ -265,7 +305,11 @@ impl RepoDetailView { .line_number(true) .folding(true) }); - self.code = Some(CodeView { path, state }); + self.code = Some(CodeView { + path, + state, + source_hash: hash, + }); } /// The persistent code editor for `path`, or a spinner while the file loads or parses. @@ -286,3 +330,176 @@ impl RepoDetailView { .into_any_element() } } + +impl RepoDetailView { + /// Preview the file at `path`, relative to the worktree root. + fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { + self.selected_file = Some(path.into()); + + if self.files.contains_key(path) { + // The file is cached, but the markdown or code state may hold a different file. + // Re-point it at this one, the parse runs on a background task either way. + // Without this, the pane would show a spinner forever. + if let Some(FileContent::Text(text)) = self.files.get(path) { + let text = text.clone(); + if is_markdown_path(path) { + if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) { + self.set_markdown(Some(path.into()), &text, cx); + } + } else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) { + self.set_code(path.into(), &text, window, cx); + } + } + cx.notify(); + return; + } + if self.loading_files.contains(path) { + cx.notify(); + return; + } + + // Paths come from our own tree walk, but never trust them. + // Refuse anything that could escape the worktree. + let rel = Path::new(path); + let unsafe_path = rel.is_absolute() + || rel.components().any(|c| { + matches!( + c, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }); + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + if unsafe_path { + return; + } + + self.loading_files.insert(path.to_string()); + let path = path.to_string(); + + self.load_commit(&path, cx); + let generation = self.ref_generation; + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let path_for_read = path.clone(); + let content = cx + .background_spawn(async move { + let full = worktree.join(&path_for_read); + // Refuse oversized files before reading them. + // Reading a multi-gigabyte file just to classify it is wasteful. + // It would burn disk and memory bandwidth. + let metadata = match std::fs::metadata(&full) { + Ok(metadata) => metadata, + Err(error) => return Err(anyhow::anyhow!("{}", error)), + }; + if metadata.len() > MAX_PREVIEW_BYTES as u64 { + return Ok(FileContent::TooLarge); + } + let bytes = match std::fs::read(&full) { + Ok(bytes) => bytes, + Err(error) => return Err(anyhow::anyhow!("{}", error)), + }; + match String::from_utf8(bytes) { + Ok(text) => Ok(FileContent::Text(text)), + Err(_) => Ok(FileContent::Binary), + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + // The worktree was switched while this file was reading. + // The result belongs to the previous branch. + // Clear the in-flight marker either way. + // Otherwise the path could never be loaded again. + if generation != this.ref_generation { + this.loading_files.remove(&path); + return; + } + this.loading_files.remove(&path); + match content { + Ok(kind) => { + if let FileContent::Text(text) = &kind { + if is_markdown_path(&path) { + let same = this.md.as_ref().map(|md| md.path.as_deref()) + == Some(Some(path.as_str())); + if !same { + this.set_markdown(Some(path.clone().into()), text, cx); + } + } else { + let same = this.code.as_ref().map(|code| code.path.as_str()) + == Some(path.as_str()); + if !same { + this.set_code(path.clone().into(), text, window, cx); + } + } + this.preview_bytes += text.len(); + } + this.files.insert(path.clone(), kind); + this.file_order.push_back(path); + this.evict_previews(); + } + Err(error) => { + this.files + .insert(path, FileContent::Failed(error.to_string())); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Drop the cached preview, editor and commit state of `path`. + pub(super) fn drop_preview_of(&mut self, path: &str) { + if let Some(FileContent::Text(text)) = self.files.remove(path) { + self.preview_bytes -= text.len(); + } + self.commits.remove(path); + if self.selected_file.as_deref() == Some(path) { + self.selected_file = None; + } + if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) { + self.md = None; + } + if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) { + self.code = None; + } + } + + /// Drop the oldest previews beyond the cache caps. + /// Keep the currently selected file. + /// An evicted file's parsed editor state drops with its entry. + /// Re-opening it re-parses on a background task. + fn evict_previews(&mut self) { + while (self.files.len() > MAX_PREVIEWED_FILES + || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) + && self.file_order.len() > 1 + { + let path = self.file_order.pop_front().expect("non-empty"); + if Some(path.as_str()) == self.selected_file.as_deref() { + self.file_order.push_back(path); + continue; + } + if let Some(FileContent::Text(text)) = self.files.remove(&path) { + self.preview_bytes -= text.len(); + } + if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) { + self.md = None; + } + if self + .code + .as_ref() + .is_some_and(|code| code.path.as_ref() == path.as_str()) + { + self.code = None; + } + self.commits.remove(&path); + } + } +} diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs new file mode 100644 index 0000000..b652dd5 --- /dev/null +++ b/crates/workspace/src/views/repo/header.rs @@ -0,0 +1,728 @@ +use std::collections::HashSet; +use std::rc::Rc; + +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{Anchor, AnyElement, ClipboardItem, Context, SharedString, div, px, relative}; +use gpui_base::{Button as BaseButton, Disableable, Popover}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::combobox::Combobox; +use gpui_component::menu::DropdownMenu; +use gpui_component::{ + ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, +}; +use nostr::prelude::{RelayUrl, ToBech32}; +use signed_core::Announcement; +use signed_state::{Backend, ProfileStore, RepoListStore}; +use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row}; + +use super::{RepoAction, RepoDetailView}; +use crate::views::issues::open_new_issue_dialog; +use crate::views::pull_requests::new::open_new_pull_panel; +use crate::views::repo::about::open_about_dialog; +use crate::views::repo::helpers::{ShareTargets, ref_selector_trigger}; +use crate::views::send_patch::open_send_patch_panel; + +impl RepoDetailView { + /// The NIP-34 header, actions and issues/PR counts. + /// Or the local header with an Init button for an unpublished repository. + pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { + if self.local_path.is_some() { + return self.render_local_header(cx); + } + + 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(); + }; + + // Derived NIP-34 header data, share targets and clone commands. + // Rebuilt per frame: two bech32 encodes and a couple of format strings. + let nip05 = ProfileStore::global(cx) + .read(cx) + .get(&source.owner) + .metadata() + .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}")); + let git_commands = Rc::new(announcement.clone_urls()); + + let name = self.display_name(cx); + let description = announcement.description(); + let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); + + v_flex() + .on_action( + cx.listener(|this, action: &RepoAction, window, cx| match action { + RepoAction::NewIssue => { + if let Some(store) = this.store.clone() { + open_new_issue_dialog(store, window, cx); + } + } + RepoAction::NewPR => { + if let Some(store) = this.store.clone() { + open_new_pull_panel(this.dock_area.clone(), store, window, cx); + } + } + RepoAction::SendPatch => { + if let Some(store) = this.store.clone() { + open_send_patch_panel(this.dock_area.clone(), store, window, cx); + } + } + RepoAction::About => { + if let Some(announcement) = this.announcement(cx) { + open_about_dialog(announcement.clone(), window, cx); + } + } + RepoAction::Push => this.push_repository(window, cx), + RepoAction::Delete => this.delete_repository(window, cx), + }), + ) + .p_4() + .w_full() + .gap_8() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .min_h_8() + .font_semibold() + .child(avatar.size_6()) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .line_clamp(2) + .line_height(relative(1.25)) + .text_ellipsis() + .child(description), + ) + .when_some(fork_row(&announcement, cx), |this, row| this.child(row)) + .child( + h_flex() + .mt_2() + .w_full() + .gap_0p5() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .font_semibold() + .child("Maintainers:"), + ) + .child(self.render_maintainers(cx)), + ), + ) + .child( + h_flex() + .flex_none() + .gap_2() + .justify_end() + .child( + DropdownButton::new("issues") + .action( + BaseButton::new("issues-open") + .child( + h_flex() + .h_8() + .px_2() + .gap_1() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary) + .hover(|this| { + this.bg(cx.theme().secondary_hover) + }) + .text_sm() + .text_color(cx.theme().secondary_foreground) + .child(Icon::new(CustomIconName::GitIssueDone)) + .child("Issues") + .child( + div() + .mx_1() + .h_5() + .w_px() + .bg(cx.theme().border.darken(0.1)), + ) + .child(issue_count), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_issue_detail(window, cx); + })), + ) + .dropdown_menu(|menu, _, _| { + menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New issue") + }) + }), + ) + .child( + DropdownButton::new("prs") + .action( + BaseButton::new("prs-open") + .child( + h_flex() + .h_8() + .px_2() + .gap_1() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary) + .hover(|this| { + this.bg(cx.theme().secondary_hover) + }) + .text_sm() + .text_color(cx.theme().secondary_foreground) + .child(Icon::new( + CustomIconName::GitPullRequest, + )) + .child("Pull Requests") + .child( + div() + .mx_1() + .h_5() + .w_px() + .bg(cx.theme().border.darken(0.1)), + ) + .child(pr_count), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_pull_request_detail(window, cx); + })), + ) + .dropdown_menu(|menu, _, _| { + menu.menu_element(Box::new(RepoAction::NewPR), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New Pull Request") + }) + .menu_element( + Box::new(RepoAction::SendPatch), + |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::File)) + .child("Send Patch") + }, + ) + }), + ) + .child( + DropdownButton::new("share") + .action( + Button::new("link") + .icon(IconName::Copy) + .tooltip("Copy ID") + .secondary() + .on_click({ + let naddr = share.naddr.clone(); + move |_, _, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string(naddr.clone()), + ); + } + }), + ) + .dropdown_menu(move |menu, _, _| share.menu(menu)), + ) + .child( + Button::new("repo-menu-open") + .icon(IconName::EllipsisVertical) + .tooltip("Repository management") + .compact() + .secondary() + .loading(pushing) + .disabled(pushing) + .dropdown_menu(move |menu, _, cx| { + let backend = Backend::global(cx); + let current_user = backend.read(cx).current_user(); + let owner = current_user == Some(announcement.owner); + + let menu = menu.menu_element( + Box::new(RepoAction::About), + |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Info)) + .child("About") + }, + ); + + if owner { + menu.menu_element(Box::new(RepoAction::Push), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(CustomIconName::Init)) + .child("Republish") + }) + .separator() + .menu_element(Box::new(RepoAction::Delete), |_, cx| { + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().danger) + .child(Icon::new(IconName::Delete)) + .child("Delete") + }) + } else { + menu + } + }), + ) + .child({ + let view = cx.entity(); + let ngit_command = ngit_command.clone(); + let nak_command = nak_command.clone(); + let git_commands = git_commands.clone(); + + Popover::new("clone") + .anchor(Anchor::TopRight) + .trigger( + Button::new("clone") + .icon(CustomIconName::GitClone) + .tooltip("Clone") + .loading(cloning) + .disabled(cloning) + .primary(), + ) + .content(move |_, _window, cx| { + let state = cx.entity(); + let ngit_row = copy_row("copy-ngit", &ngit_command, cx); + let nak_row = copy_row("copy-nak", &nak_command, cx); + + v_flex() + .w(px(440.)) + .mt_1() + .p_3() + .gap_4() + .popover_style(cx) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Clone with ngit"), + ) + .child(ngit_row), + ) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Clone with nak"), + ) + .child(nak_row), + ) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Grasp Servers"), + ) + .when(!git_commands.is_empty(), |this| { + this.children( + git_commands.iter().enumerate().map( + |(ix, cmd)| { + copy_row( + format!("copy-git-{ix}"), + cmd, + cx, + ) + }, + ), + ) + }) + .when(git_commands.is_empty(), |this| { + this.child( + div() + .text_xs() + .child("No git clone urls."), + ) + }), + ) + .child(div().h_px().w_full().bg(cx.theme().border)) + .child( + h_flex().gap_1().justify_end().child( + Button::new("download") + .icon(CustomIconName::GitClone) + .label("Download") + .primary() + .on_click(move |_event, window, cx| { + state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); + view.update(cx, |this, cx| { + this.clone_to_folder(window, cx); + }); + }), + ), + ) + }) + }), + ), + ) + .child(self.render_header_tabs(cx)) + .into_any_element() + } + + /// Header for a local, not yet published, repository. + /// The directory name and path with an Init button instead of the NIP-34 actions. + fn render_local_header(&self, cx: &mut Context) -> AnyElement { + let name = self.display_name(cx); + let path = self + .local_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + let avatar = PixelAvatar::new(path.clone()); + + v_flex() + .px_4() + .pb_4() + .w_full() + .gap_8() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .min_h_8() + .font_semibold() + .child(avatar.size_6()) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .line_clamp(2) + .line_height(relative(1.25)) + .text_ellipsis() + .child(path), + ), + ) + .child( + Button::new("init") + .icon(CustomIconName::Init) + .label("Initialize on Nostr") + .primary() + .tooltip("Publish this repository to Nostr") + .on_click(cx.listener(|this, _event, window, cx| { + this.open_init_dialog(window, cx); + })), + ), + ) + .child(self.render_header_tabs(cx)) + .into_any_element() + } + + /// The tab row shared by both header variants. + /// Files and Commits tabs, the HEAD commit button and the branch/tag selectors. + fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { + let commits_count = self.all_commits.as_ref().map(|list| list.total); + let worktree_empty = self.switching_ref || self.worktree.is_none(); + + h_flex() + .items_center() + .gap_2() + .child( + BaseButton::new("files-tab") + .flex() + .items_center() + .h_8() + .px_2() + .gap_2() + .child( + h_flex() + .gap_1() + .text_sm() + .child(Icon::new(CustomIconName::GitFile).small()) + .child("Files"), + ) + .text_color(cx.theme().button_foreground) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().button_hover)) + .active(|this| this.bg(cx.theme().button_active)) + .selected(self.active_tab == 0) + .when(self.active_tab == 0, |this| { + this.bg(cx.theme().button_active) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.active_tab = 0; + cx.notify(); + })), + ) + .child( + BaseButton::new("commits-tab") + .flex() + .items_center() + .h_8() + .px_2() + .gap_2() + .child( + h_flex() + .gap_1() + .text_sm() + .child(Icon::new(CustomIconName::GitCommit).small()) + .child("Commits"), + ) + .when_some(commits_count, |this, count| { + this.child(CountBadge::new(count)) + }) + .text_color(cx.theme().button_foreground) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().button_hover)) + .active(|this| this.bg(cx.theme().button_active)) + .selected(self.active_tab == 1) + .when(self.active_tab == 1, |this| { + this.bg(cx.theme().button_active) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.active_tab = 1; + cx.notify(); + })), + ) + .child( + h_flex() + .flex_1() + .gap_2() + .justify_end() + .child( + Button::new("enc") + .ghost() + .when_some(self.head_commit.as_ref(), |this, commit| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&commit.id)), + ) + .child( + div() + .max_w(px(200.)) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_xs() + .child(SharedString::from(&commit.summary)), + ) + }) + .tooltip( + self.head_commit + .as_ref() + .map_or_else(SharedString::default, |commit| { + commit.summary.clone().into() + }), + ) + .on_click(cx.listener(|this, _event, window, cx| { + if let Some(commit) = &this.head_commit { + let id = commit.id.clone(); + this.open_commit_diff(&id, window, cx); + } + })), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.branch_select) + .placeholder("Branch") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + ref_selector_trigger(ctx, CustomIconName::GitBranch, cx) + }), + ), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.tag_select) + .placeholder("Tag") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + ref_selector_trigger(ctx, CustomIconName::Tag, cx) + }), + ), + ), + ) + .into_any_element() + } + + fn render_maintainers(&self, cx: &mut Context) -> AnyElement { + let Some(announcement) = self.announcement(cx) else { + return div().into_any_element(); + }; + let profile_store = ProfileStore::global(cx); + + let mut seen = HashSet::new(); + let rest: Vec<_> = announcement + .maintainers + .iter() + .copied() + .filter(|key| key != &announcement.owner && seen.insert(*key)) + .collect(); + + let owner = profile_store.read(cx).get(&announcement.owner); + let owner_name = owner.name(); + let owner_picture = owner.picture(); + + h_flex() + .w_full() + .gap_3() + .child( + Button::new("maintainers").compact().ghost().child( + h_flex() + .gap_2() + .child( + h_flex() + .gap_1() + .child(UserAvatar::new(owner_name.clone()).picture(owner_picture)) + .child(div().text_xs().whitespace_nowrap().child(owner_name)), + ) + .when(!rest.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!("+{}", rest.len()))), + ) + }), + ), + ) + .into_any_element() + } +} + +/// The `nostr://...` clone URL of an announcement, NIP-34. +fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { + let owner = announcement.owner; + let user = nip05 + .map(str::to_owned) + .unwrap_or_else(|| owner.to_bech32().unwrap()); + + let mut url = format!("nostr://{user}"); + if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { + url.push('/'); + url.push_str(hint); + } + url.push('/'); + url.push_str(&announcement.id); + + SharedString::from(url) +} + +/// The forked-from row of the detail header. +/// +/// Clickable link to the upstream repository when the `u` tag references a NIP-34 repo. +/// Plain text when it only carries a git URL. +fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { + let upstream = announcement.upstream.as_ref()?; + + let (label, clickable) = match &upstream.addr { + Some(addr) => { + // Prefer the upstream's display name when its announcement is known locally. + // Fall back to its repository id otherwise. + let name = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|a| a.addr() == *addr) + .map(|a| { + a.name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(a.id.clone())) + }) + .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); + (SharedString::from(format!("Forked from {name}")), true) + } + None => (SharedString::from(upstream.display().as_str()), false), + }; + + let row = h_flex() + .gap_1() + .items_center() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(CustomIconName::GitBranch).small()) + .child(div().whitespace_nowrap().text_ellipsis().child(label)); + + Some(if clickable { + row.id("fork-upstream") + .cursor_pointer() + .hover(|this| this.text_color(cx.theme().foreground)) + .on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx))) + .into_any_element() + } else { + row.into_any_element() + }) +} diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo/helpers.rs similarity index 85% rename from crates/workspace/src/views/repo_detail/helpers.rs rename to crates/workspace/src/views/repo/helpers.rs index fd7be3c..bf95cd3 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo/helpers.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use assets::CustomIconName; use gpui::prelude::*; -use gpui::{AnyElement, App, Entity, SharedString, div, px}; +use gpui::{AnyElement, App, Entity, SharedString, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::{Caret, ComboboxTriggerContext}; use gpui_component::input::{Textarea, TextareaState}; @@ -15,12 +15,12 @@ use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; use nostr::nips::nip19::{Nip19Coordinate, ToBech32}; use nostr::prelude::{Event, EventId, PublicKey}; use signed_core::Announcement; -use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff}; +use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileCommit, FileDiff}; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{UserAvatar, menu_copy_row, middle_truncate}; -use utils::relative_time; +use utils::{relative_time, relative_time_secs}; -pub(super) struct TreeItemSeed { +pub(crate) struct TreeItemSeed { /// Path of the node, relative to the worktree root. id: String, /// File or directory name. @@ -28,7 +28,7 @@ pub(super) struct TreeItemSeed { children: Vec, } -pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { +pub(crate) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { let mut item = TreeItem::new(seed.id, seed.label); if expand_folders && !seed.children.is_empty() { @@ -49,7 +49,7 @@ pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec< } /// Build nested tree items from a flat entry list sorted dirs-first. -pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { +pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec { // Node indices by full path, so parents resolve in constant time while inserting. let mut index: HashMap = HashMap::new(); let mut nodes: Vec<(String, String, Vec)> = Vec::new(); @@ -93,8 +93,21 @@ pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { roots.iter().map(|root| assemble(*root, &nodes)).collect() } +/// Sorted relative paths of a worktree snapshot. +/// +/// Compared against the `worktree_paths` of a repository panel to skip +/// rebuilding the explorer when a refresh left the worktree unchanged. +pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec { + let mut paths: Vec = entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + paths.sort(); + paths +} + /// The markdown fence language for a file path, or `None` for plain text. -pub(super) fn code_language(path: &str) -> Option<&'static str> { +pub(crate) fn code_language(path: &str) -> Option<&'static str> { let name = Path::new(path) .file_name() .and_then(|name| name.to_str()) @@ -145,7 +158,7 @@ pub(super) fn code_language(path: &str) -> Option<&'static str> { } /// Whether a file path has a markdown extension. -pub(super) fn is_markdown_path(path: &str) -> bool { +pub(crate) fn is_markdown_path(path: &str) -> bool { Path::new(path) .extension() .and_then(|ext| ext.to_str()) @@ -157,21 +170,21 @@ pub(super) fn is_markdown_path(path: &str) -> bool { }) } -pub(super) struct ShareTargets { +pub(crate) struct ShareTargets { /// NIP-19 `naddr1...` of the announcement, with its announced relays. - pub(super) naddr: String, + pub(crate) naddr: String, /// Hex ID of the announcement event itself. - pub(super) event_id: String, + pub(crate) event_id: String, /// NIP-34 coordinate `30617::`. - pub(super) coordinate: String, + pub(crate) coordinate: String, /// `https://gitworkshop.dev/` - pub(super) gitworkshop: String, + pub(crate) gitworkshop: String, /// `https://ditto.pub/` - pub(super) ditto: String, + pub(crate) ditto: String, } impl ShareTargets { - pub(super) fn from_announcement(announcement: &Announcement) -> Self { + pub(crate) fn from_announcement(announcement: &Announcement) -> Self { let addr = announcement.addr(); let coordinate = addr.to_string(); let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) @@ -190,7 +203,7 @@ impl ShareTargets { /// The share dropdown menu, one row per target. /// /// Each shows a compact label, the copy button and row click copy the full value. - pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu { + pub(crate) fn menu(&self, menu: PopupMenu) -> PopupMenu { menu.min_w(px(340.)) .item(menu_copy_row( "copy-gitworkshop", @@ -231,15 +244,15 @@ fn truncate_naddr_link(url: &str, tail: usize) -> String { } /// Width of one line-number gutter in a diff row. -pub(super) const GUTTER_WIDTH: f32 = 44.; +pub(crate) const GUTTER_WIDTH: f32 = 44.; /// Height of one row in a virtual diff list. -pub(super) const DIFF_ROW_HEIGHT: f32 = 20.; +pub(crate) const DIFF_ROW_HEIGHT: f32 = 20.; /// One row of a virtual diff list, a hunk header or a line of a hunk. /// /// Shared by the commit diff and pull request diff viewers. #[derive(Clone, Copy)] -pub(super) enum DiffRow { +pub(crate) enum DiffRow { Hunk { old_start: u32, old_lines: u32, @@ -251,7 +264,7 @@ pub(super) enum DiffRow { } /// The rows of `file`'s diff, one header row per hunk then its lines. -pub(super) fn diff_rows(file: &FileDiff) -> Vec { +pub(crate) fn diff_rows(file: &FileDiff) -> Vec { let mut rows = Vec::new(); for (hunk_ix, hunk) in file.hunks.iter().enumerate() { rows.push(DiffRow::Hunk { @@ -269,7 +282,7 @@ pub(super) fn diff_rows(file: &FileDiff) -> Vec { } /// One row of the virtual diff list, a hunk header or a single line. -pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { +pub(crate) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { match row { DiffRow::Hunk { old_start, @@ -298,7 +311,7 @@ pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any /// One diff line, old and new line numbers in the gutters. /// /// The content is tinted by kind, addition, deletion or context. -pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { +pub(crate) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { let bg = match line.kind { DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)), @@ -346,7 +359,7 @@ pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { } /// Find a tree item by id, searching into nested children. -pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { +pub(crate) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { let id = id?; items.iter().find_map(|item| { if item.id.as_ref() == id { @@ -358,12 +371,12 @@ pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<& } /// The root issue events of a repo store, for the shared detail sections. -pub(super) fn issue_roots(store: &RepoStore) -> &[Event] { +pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] { &store.issues } /// The root pull request events of a repo store, for the shared detail sections. -pub(super) fn pr_roots(store: &RepoStore) -> &[Event] { +pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] { &store.pull_requests } @@ -372,7 +385,7 @@ pub(super) fn pr_roots(store: &RepoStore) -> &[Event] { /// The kind icon, the selection or placeholder, and the caret. /// `Combobox` replaces its default trigger entirely, /// the only way to show an icon inside it. -pub(super) fn ref_selector_trigger( +pub(crate) fn ref_selector_trigger( ctx: &ComboboxTriggerContext>, icon: CustomIconName, cx: &App, @@ -406,7 +419,7 @@ pub(super) fn ref_selector_trigger( } /// Section heading of a detail sidebar, shared by the issue and PR panels. -pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement { +pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement { div() .text_xs() .font_semibold() @@ -416,7 +429,7 @@ pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement { } /// Right sidebar with participants and labels of a root event, issue or PR. -pub(super) fn sidebar_section( +pub(crate) fn sidebar_section( store: &Entity, id: EventId, roots: fn(&RepoStore) -> &[Event], @@ -499,7 +512,7 @@ pub(super) fn sidebar_section( } /// The comments on a root event, issue or PR, one card per comment. -pub(super) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { +pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { let store = store.read(cx); let comments: Vec<&Event> = store.comments_of(&root).collect(); let title = SharedString::from(format!("Discussions {}", comments.len())); @@ -549,7 +562,7 @@ pub(super) fn comments_section(store: &Entity, root: EventId, cx: &Ap /// The comment form posting to an issue or PR root event. /// /// `roots` selects the root's list within the store, issues or pull requests. -pub(super) fn comment_form( +pub(crate) fn comment_form( store: &Entity, root: EventId, roots: fn(&RepoStore) -> &[Event], @@ -608,6 +621,68 @@ pub(super) fn comment_form( .into_any_element() } +/// Height of one commit row in a commit virtual list. +pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.; + +/// One commit row of a virtual list, shared by the commits tab and the +/// new-pull-request commit picker. +pub(crate) fn commit_row( + ix: usize, + commit: &FileCommit, + on_click: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + h_flex() + .id(ix) + .px_4() + .h(px(COMMIT_ROW_HEIGHT)) + .w_full() + .gap_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .hover(|this| this.bg(cx.theme().list_hover)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .justify_center() + .child( + h_flex() + .gap_2() + .items_center() + .overflow_hidden() + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.id.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.author.clone()) + .child(relative_time_secs(commit.time)), + ), + ) + .on_click(move |_event, window, cx| on_click(window, cx)) + .into_any_element() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs new file mode 100644 index 0000000..9438c0f --- /dev/null +++ b/crates/workspace/src/views/repo/history.rs @@ -0,0 +1,237 @@ +use std::path::PathBuf; +use std::rc::Rc; + +use anyhow::Error; +use dock::{add_center_panel, panel_handle}; +use gpui::prelude::*; +use gpui::{AnyElement, Context, Window, div, px, size}; +use gpui_component::scroll::Scrollbar; +use gpui_component::spinner::Spinner; +use gpui_component::{ActiveTheme, Sizable, v_flex, v_virtual_list}; +use signed_ui::placeholder; + +use super::RepoDetailView; +use crate::views::commit_diff::CommitDiffView; +use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row}; + +impl RepoDetailView { + pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { + let Some(list) = self.all_commits.as_ref() else { + return if self.loading_all_commits { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() + } else { + placeholder("Failed to load commits", cx) + }; + }; + + if list.commits.is_empty() { + return placeholder("No commits found", cx); + } + + // Copy only the values the element tree needs. + // The list is borrowed by the renderer below instead of cloned per frame. + // A full history can be tens of thousands of commits. + let view = cx.entity().clone(); + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); + let shown = list.commits.len(); + let total = list.total; + + v_flex() + .relative() + .flex_1() + .w_full() + .min_h_0() + .child( + v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| { + let view = cx.entity().downgrade(); + let commits = this + .all_commits + .as_ref() + .map(|list| list.commits.as_slice()) + .unwrap_or(&[]); + + range + .map(|ix| { + let id = commits[ix].id.clone(); + let view = view.clone(); + + commit_row( + ix, + &commits[ix], + move |window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| { + this.open_commit_diff(&id, window, cx) + }); + } + }, + cx, + ) + }) + .collect() + }) + .track_scroll(&scroll_handle) + .size_full(), + ) + .when(shown < total, |this| { + // The history is capped. + // Tell the user the list is truncated. + this.child( + div() + .py_2() + .w_full() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("Showing {shown} of {total} commits")), + ) + }) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&self.scroll_handle)), + ) + .into_any_element() + } +} + +impl RepoDetailView { + /// Queue `path` for the per-file commit query. + /// Requests are batched into one history walk, see [`Self::load_commits`]. + pub(super) fn load_commit(&mut self, path: &str, cx: &mut Context) { + if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { + return; + } + self.pending_commits.push(path.to_string()); + if !self.loading_commits { + self.load_commits(cx); + } + } + + /// Walk history once for every queued path on a background task. + /// Cache the latest commit touching each path in [`Self::commits`]. + /// That feeds the file header in the content column. + /// Batching shares one walk across paths queued while the previous walk ran. + fn load_commits(&mut self, cx: &mut Context) { + if self.pending_commits.is_empty() || self.loading_commits { + return; + } + let Some(worktree) = self.worktree.clone() else { + self.pending_commits.clear(); + return; + }; + + self.loading_commits = true; + let paths = std::mem::take(&mut self.pending_commits); + let generation = self.ref_generation; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let rels: Vec = paths.iter().map(PathBuf::from).collect(); + let result = cx + .background_spawn( + async move { signed_git::worktree_last_commits(&worktree, &rels) }, + ) + .await; + + this.update(cx, |this, cx| { + this.loading_commits = false; + if generation == this.ref_generation + && let Ok(found) = result + { + for (path, commit) in found { + this.commits + .insert(path.to_string_lossy().into_owned(), commit); + } + } + // Paths queued while the walk was in flight start the next batch. + // A stale walk, branch switched mid-flight, must not strand them. + // This runs under the current generation regardless of the result. + if !this.pending_commits.is_empty() { + this.load_commits(cx); + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Walk all commits reachable from HEAD on a background task. + /// For the Commits tab and its total-count badge. + /// [`CommitList`] caps the list, only the newest commits are materialized. + pub(super) fn load_all_commits(&mut self, cx: &mut Context) { + if self.loading_all_commits || self.all_commits.is_some() { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + self.loading_all_commits = true; + let generation = self.ref_generation; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) + .await; + + this.update(cx, |this, cx| { + // A stale walk, branch switched mid-flight, must not leave the flag set. + // Otherwise the Commits tab would spin forever. + if generation != this.ref_generation { + this.loading_all_commits = false; + return; + } + if let Ok(list) = result { + let count = list.commits.len(); + this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); + this.all_commits = Some(list); + } + this.loading_all_commits = false; + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Open a new panel showing the diff of `commit_id`. + pub(super) fn open_commit_diff( + &mut self, + commit_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + // Same display name as the repo detail panel's title. + let repo_name = self.display_name(cx); + + let panel = + cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } +} diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo/init_dialog.rs similarity index 100% rename from crates/workspace/src/views/repo_detail/init_dialog.rs rename to crates/workspace/src/views/repo/init_dialog.rs diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs new file mode 100644 index 0000000..bf6a761 --- /dev/null +++ b/crates/workspace/src/views/repo/loading.rs @@ -0,0 +1,429 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Error; +use gix::Repository; +use gpui::prelude::*; +use gpui::{Context, Entity, PathPromptOptions, SharedString, Window}; +use gpui_component::combobox::ComboboxState; +use gpui_component::searchable_list::SearchableVec; +use nostr::prelude::Url; +use signed_git::FileCommit; +use signed_state::GitStore; + +use super::RepoDetailView; +use crate::views::repo::helpers::{ + TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items, +}; + +/// Everything loaded from the local clone for the explorer. +struct RepoData { + tree: Vec, + /// Relative paths of the worktree entries, for [`RepoDetailView::worktree_paths`]. + entries: Vec, + readme_path: Option, + readme: Option>, + worktree: Option, + branches: Vec, + tags: Vec, + current_branch: Option, + head_commit: Option, +} + +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. + pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { + self.loading = true; + self.error = None; + cx.notify(); + + // Local repositories live on disk at their scan path. + // No clone step or network refresh applies here. + if let Some(local_path) = self.local_path.clone() { + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let data = cx + .background_spawn(async move { + let repo = gix::open(&local_path)?; + load_repo_data(&repo) + }) + .await; + + this.update_in(cx, |this, window, cx| { + match data { + Ok(data) => this.apply_repo_data(data, window, cx), + Err(error) => this.error = Some(error.to_string().into()), + } + this.loading = false; + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + + return; + } + + let Some(initial) = self.initial.as_ref() else { + return; + }; + + let cache = GitStore::global(cx).cache().clone(); + let addr = initial.addr(); + let clone_urls: Vec = initial.clone.clone(); + + // Captured before the loads start. + // A branch/tag switch bumps the generation, discarding the refresh below. + let refresh_generation = self.ref_generation; + + let disk = { + let cache = cache.clone(); + let addr = addr.clone(); + cx.background_spawn(async move { + match cache.open(&addr)? { + Some(repo) => Ok(Some(load_repo_data(&repo)?)), + None => Ok(None), + } + }) + }; + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let disk = disk.await; + let had_clone = matches!(&disk, Ok(Some(_))); + + // No local clone yet, so clone from the network then load. + let data = match disk { + Ok(Some(data)) => Ok(data), + Ok(None) => { + let cache = cache.clone(); + let addr = addr.clone(); + let clone_urls = clone_urls.clone(); + cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; + load_repo_data(&repo) + }) + .await + } + Err(error) => Err(error), + }; + + this.update_in(cx, |this, window, cx| { + match data { + Ok(data) => this.apply_repo_data(data, window, cx), + Err(error) => this.error = Some(error.to_string().into()), + } + this.loading = false; + cx.notify(); + })?; + + // Refresh the clone from the network in the background. + // When it completes, update the refs and commit list. + // Loads started before a branch/tag switch are discarded via the generation. + if !had_clone { + return Ok(()); + } + + let refresh = { + let cache = cache.clone(); + let addr = addr.clone(); + cx.background_spawn(async move { + 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. + // That covers own repo pushes from a checkout and updates fetched here. + // Fast-forward branches from the remote, like `git pull --ff-only`. + // Only the checked-out branch's worktree can change on disk. + let moved = match &worktree { + Some(worktree) => { + signed_git::fast_forward_branches(worktree).unwrap_or(false) + } + None => false, + }; + + let (branches, tags) = match &worktree { + Some(_) => ( + signed_git::repo_branches(&repo).unwrap_or_default(), + signed_git::repo_tags(&repo).unwrap_or_default(), + ), + 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))) + }) + } + .await; + + this.update_in(cx, |this, window, cx| { + if refresh_generation != this.ref_generation { + return; + } + + if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { + let branches: Vec = branches.iter().map(Into::into).collect(); + let tags: Vec = tags.iter().map(Into::into).collect(); + + let branches_changed = Self::sync_ref_selector( + &this.branch_select, + &mut this.ref_branches, + branches, + current_branch.map(Into::into), + window, + cx, + ); + let tags_changed = Self::sync_ref_selector( + &this.tag_select, + &mut this.ref_tags, + tags, + None, + window, + cx, + ); + + let new_head_commit = head_commit.as_ref().map(|c| &c.id); + let current_head_commit = this.head_commit.as_ref().map(|c| &c.id); + let head_changed = new_head_commit != current_head_commit; + this.head_commit = head_commit; + + log::debug!( + "repo detail refresh reconcile: branches_changed={branches_changed} tags_changed={tags_changed} head_changed={head_changed} moved={moved}" + ); + + // Only a moved HEAD invalidates the commit list. + // Leaving an in-flight walk alone when HEAD did not move + // keeps a refresh that learned nothing new from flashing + // the commits tab. + if head_changed { + this.all_commits = None; + this.loading_all_commits = false; + this.load_all_commits(cx); + } + + // A fast-forward may touch a branch that is not checked out. + // `catch_up_worktree` no-ops when the tree is unchanged and + // re-renders only when it actually rebuilt something. + if moved { + this.catch_up_worktree(cx); + } + + if branches_changed || tags_changed || head_changed { + cx.notify(); + } + } + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Apply the loaded repository data. + fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { + log::debug!("repo detail: apply_repo_data"); + let RepoData { + tree, + entries, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + } = data; + let Some(worktree) = worktree else { + self.error = Some("Repository has no worktree".into()); + return; + }; + + self.worktree = Some(worktree); + self.head_commit = head_commit; + self.worktree_paths = sorted_worktree_paths(&entries); + self.tree_state.update(cx, |state, cx| { + state.set_items(tree_items(tree, false), cx); + }); + + // Populate the branch/tag selectors with the local refs. + // Select the branch HEAD points to. + let branches: Vec = branches.into_iter().map(Into::into).collect(); + let tags: Vec = tags.into_iter().map(Into::into).collect(); + + Self::sync_ref_selector( + &self.branch_select, + &mut self.ref_branches, + branches, + current_branch.map(Into::into), + window, + cx, + ); + Self::sync_ref_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx); + + self.load_all_commits(cx); + + if let Some((path, bytes)) = readme_path.zip(readme) { + self.readme_name = Some(path.to_string_lossy().into()); + self.load_commit(&path.to_string_lossy(), cx); + if let Ok(text) = String::from_utf8(bytes) { + self.set_markdown(None, &text, cx); + } + } + } + + /// Point a ref selector at `items`, selecting `selected` when given. + /// + /// Updates the items and selection only when they differ from `cached` and + /// the current selection. `set_items`/`set_selected_values` notify the + /// combobox, which re-renders the header, so skipping the no-op keeps a + /// background refresh that learned nothing new from flashing the selectors. + /// Returns whether anything was set. + fn sync_ref_selector( + select: &Entity>>, + cached: &mut Vec, + items: Vec, + selected: Option, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let items_changed = *cached != items; + let selection_changed = selected + .as_ref() + .is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value)); + + if !items_changed && !selection_changed { + return false; + } + + select.update(cx, |state, cx| { + if items_changed { + state.set_items(SearchableVec::from(items.clone()), window, cx); + } + if let Some(value) = selected + && (items_changed || selection_changed) + { + state.set_selected_values(std::slice::from_ref(&value), window, cx); + } + }); + *cached = items; + + true + } + + /// Clone the repository into a user-chosen folder outside the cache. + pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { + let Some(store) = self.store.clone() else { + return; + }; + + let name = { + let Some(announcement) = self.announcement(cx) else { + return; + }; + let addr = announcement.addr(); + // Directory name, the display name falling back to the repo id. + // Both are sanitized to a safe single path component. + let name = announcement + .name + .as_ref() + .map(|name| name.to_string()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| addr.identifier.clone()); + let name = signed_git::sanitize_path_component(&name); + if name.is_empty() { + "repository".to_owned() + } else { + name + } + }; + + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Clone".into()), + }); + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + // `Ok(Ok(Some(paths)))` means the user picked a folder. + // A cancel or picker failure resolves to anything else. + let picked = match prompt.await { + Ok(Ok(Some(mut paths))) => paths.pop(), + _ => None, + }; + let Some(folder) = picked else { + return Ok(()); + }; + + let destination = folder.join(&name); + let destination_for_open = destination.clone(); + + // 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(()) + }); + + task.detach(); + } +} + +/// Read the worktree state of `repo`, no network. +/// +/// Entries, README, refs and HEAD commit. +fn load_repo_data(repo: &Repository) -> Result { + let entries = signed_git::worktree_entries(repo)?; + let tree = build_tree_items(&entries); + let readme_path = signed_git::find_readme(repo)?; + let readme = match &readme_path { + Some(path) => signed_git::worktree_read(repo, path)?, + None => None, + }; + let worktree = repo.workdir().map(Path::to_path_buf); + // Ref listing is auxiliary UI. + // A broken ref must not prevent the explorer from loading. + // Failures degrade to empty selectors. + let (branches, tags, current_branch) = match &worktree { + Some(_) => ( + signed_git::repo_branches(repo).unwrap_or_default(), + signed_git::repo_tags(repo).unwrap_or_default(), + signed_git::current_branch(repo).unwrap_or(None), + ), + None => (Vec::new(), Vec::new(), None), + }; + let head_commit = signed_git::head_commit(repo).unwrap_or(None); + + Ok(RepoData { + tree, + entries, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + }) +} diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs new file mode 100644 index 0000000..5607fce --- /dev/null +++ b/crates/workspace/src/views/repo/mod.rs @@ -0,0 +1,433 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; +use std::rc::Rc; + +use dock::{BasePanel, DockArea, Panel, PanelEvent}; +use gpui::prelude::*; +use gpui::{ + Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Subscription, WeakEntity, Window, +}; +use gpui_component::alert::Alert; +use gpui_component::combobox::{ComboboxEvent, ComboboxState}; +use gpui_component::searchable_list::SearchableVec; +use gpui_component::tree::TreeState; +use gpui_component::{VirtualListScrollHandle, h_flex, v_flex}; +use signed_core::{Announcement, RepoAddr}; +use signed_git::{CommitList, FileCommit}; +use signed_state::{CheckoutStatus, CheckoutsStore, RepoStore}; + +mod about; +mod actions; +mod banners; +mod files; +mod header; +pub(super) mod helpers; +mod history; +mod init_dialog; +mod loading; +mod refs; +mod store; + +pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel}; + +use self::files::{CodeView, FileContent, MarkdownView}; + +/// What kind of ref the header selectors switch to. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RefKind { + /// A local branch `refs/heads/*`, HEAD stays attached. + Branch, + /// A tag `refs/tags/*`, HEAD becomes detached. + Tag, +} + +/// Header actions dispatched by the dropdown menus of the header buttons. +/// `pub(crate)` because the pull-request list panel shares this action set. +/// It offers the New-PR and Send-patch actions in its own dropdown. +#[derive(Clone, Action, PartialEq, Eq)] +#[action(namespace = repo, no_json)] +pub(crate) enum RepoAction { + /// Open the new issue dialog. + NewIssue, + /// Open the new pull request dialog. + NewPR, + /// Open the send patch panel. + SendPatch, + /// Open the about dialog. + About, + /// Re-push the repository to its grasp servers. + Push, + /// Delete the repository from nostr, owner only. + Delete, +} + +/// Detail view of a repository, header, stats and metadata. +/// +/// A file explorer with README preview, cloned from the announcement's `clone` URLs. +pub struct RepoDetailView { + focus_handle: FocusHandle, + /// Dock area the detail view lives in. + /// + /// New panels, commit diffs, are added there. + dock_area: WeakEntity, + /// Snapshot taken at open time. + /// + /// `None` for local repositories that haven't been published yet. + initial: Option, + /// Per-repository nostr store, holding announcement, issues, PRs and statuses. + /// + /// `None` until a local repository is initialized to NIP-34. + store: Option>, + /// Path of the local repository when opened from the scan. + /// + /// `None` once it is initialized to NIP-34, or for announced repositories. + local_path: Option, + /// File explorer state, the worktree of the local clone. + tree_state: Entity, + /// Root of the local clone, for reading files on demand. + worktree: Option, + /// Sorted relative paths of the tree currently shown. + /// + /// A background refresh that did not change the tree skips rebuilding it, + /// see [`Self::catch_up_worktree`], so a fetch that learned nothing new + /// does not flash the explorer. + worktree_paths: Vec, + /// Markdown document currently in the preview pane, README or a file. + md: Option, + /// Code file currently in the preview pane. + code: Option, + readme_name: Option, + /// Currently previewed file, a relative path, and its contents. + selected_file: Option, + files: HashMap, + /// Paths of cached previews, oldest first. + /// Feeds the eviction caps in [`Self::evict_previews`]. + file_order: VecDeque, + /// Total text bytes held by [`Self::files`]. + preview_bytes: usize, + /// Reads in flight, to avoid duplicate loads. + loading_files: HashSet, + /// Latest commit touching a previewed file or the README, keyed by path. + commits: HashMap, + /// Paths queued for the next batched commit query, see [`Self::load_commits`]. + pending_commits: Vec, + /// A batched commit query is in flight. + loading_commits: bool, + /// Active header tab, 0 = Files tree, 1 = Commits. + active_tab: usize, + /// Commits reachable from HEAD, newest first. + /// `None` until the walk finishes or fails. + /// [`CommitList`] caps the list, `total` feeds the tab badge. + all_commits: Option, + /// Commit walk in flight. + loading_all_commits: bool, + /// Virtual list state of the Commits tab. + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, + /// A clone/fetch is in flight. + loading: bool, + error: Option, + /// Commit HEAD currently points to, shown in the header button. + head_commit: Option, + /// Branch selector in the header, local branches, searchable. + branch_select: Entity>>, + /// Tag selector in the header, tags, searchable. + tag_select: Entity>>, + /// Branch names currently in `branch_select`, for cheap no-op detection. + ref_branches: Vec, + /// Tag names currently in `tag_select`, for cheap no-op detection. + ref_tags: Vec, + /// A branch/tag switch is in flight, checkout plus explorer reload. + switching_ref: bool, + /// Bumped on every branch/tag switch. + /// In-flight loads with an older generation are discarded when they complete. + ref_generation: u64, + /// Subscriptions keeping the selectors' confirm events alive. + _subscriptions: Vec, + /// `(path, branch)` ready-suggestions dismissed by the user, per panel. + banner_dismissed: HashSet<(PathBuf, String)>, + /// The announced HEAD the ready statuses were last requested with. + /// Whether they were requested at all. + /// Re-requested only when the HEAD, the base default, changes. + /// e.g. when the store's first refresh lands. + ready_requested: bool, + ready_head: Option, + /// The global checkouts store's ready-to-contribute statuses of this + /// repository, last seen when they drove a render. + /// + /// The store notifies on any recompute pass; the observer re-renders this + /// panel only when these slices changed. + ready_statuses: Vec, + /// The global checkouts store's ready-to-push statuses of this repository, + /// last seen when they drove a render. + push_statuses: Vec, + /// Upstream repository, from this fork's `u` tag, the user asked to open. + /// Its announcement is still being fetched. + pending_upstream: Option, +} + +impl RepoDetailView { + /// Open a repository announced. + /// + /// The store connects to the announcement's relays and loads issues, PRs and statuses. + pub fn new( + dock_area: WeakEntity, + initial: Announcement, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // The announcement we opened from already carries the NIP-34 `relays` tag. + // + // The store connects to those relays immediately, no bootstrap fetch wait. + let addr = initial.addr(); + let relays = initial.relays.clone(); + let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); + + let mut view = Self::new_common( + dock_area, + Some(initial), + Some(store.clone()), + None, + window, + cx, + ); + view.attach_store(&store, cx); + view + } + + /// Open a local repository discovered by the scan. + pub fn new_local( + dock_area: WeakEntity, + local_path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) -> Self { + Self::new_common(dock_area, None, None, Some(local_path), window, cx) + } + + /// Shared construction. + /// + /// File explorer state, ref selectors and the deferred repository load. + fn new_common( + dock_area: WeakEntity, + initial: Option, + store: Option>, + local_path: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let tree_state = cx.new(|cx| TreeState::new(cx)); + + // Empty until the clone completes, then filled with the local refs. + let branch_select: Entity>> = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + let tag_select: Entity>> = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + + let mut subscriptions = vec![ + cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { + // `Change` fires only when the selection actually changed. + // Picking the already-selected branch emits nothing. + // A confirmed value always means a switch. + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Branch, name.clone(), window, cx); + } + }), + cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| { + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Tag, name.clone(), window, cx); + } + }), + ]; + + // The ready-to-contribute and ready-to-push banners are driven by the + // global checkouts store. It notifies on every recompute; compare the + // statuses of this repository so unrelated updates (the sidebar badges, + // other open panels) do not re-render this panel. + let checkouts = CheckoutsStore::global(cx); + subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { + if this.refresh_statuses(cx) { + cx.notify(); + } + })); + + // Defer loading the repository until the window is ready. + cx.defer_in(window, |this, window, cx| { + this.load_repo(window, cx); + }); + + Self { + initial, + dock_area, + store, + local_path, + tree_state, + worktree: None, + worktree_paths: Vec::new(), + md: None, + code: None, + readme_name: None, + selected_file: None, + files: HashMap::new(), + file_order: VecDeque::new(), + preview_bytes: 0, + loading_files: HashSet::new(), + commits: HashMap::new(), + pending_commits: Vec::new(), + loading_commits: false, + active_tab: 0, + all_commits: None, + loading_all_commits: false, + scroll_handle: VirtualListScrollHandle::new(), + item_sizes: Rc::new(Vec::new()), + loading: true, + error: None, + head_commit: None, + branch_select, + tag_select, + ref_branches: Vec::new(), + ref_tags: Vec::new(), + switching_ref: false, + ref_generation: 0, + banner_dismissed: HashSet::new(), + ready_requested: false, + ready_head: None, + ready_statuses: Vec::new(), + push_statuses: Vec::new(), + pending_upstream: None, + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + } + } + + /// The latest announcement from the store or the open-time snapshot. + /// `None` for local repositories that haven't been published yet. + fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { + let store = self.store.as_ref()?; + store + .read(cx) + .announcement + .as_ref() + .or(self.initial.as_ref()) + } + + /// Display name, the announcement's name or ID for announced repositories. + /// The directory name for local ones. + fn display_name(&self, cx: &App) -> SharedString { + if let Some(path) = &self.local_path { + return SharedString::from( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ); + } + self.announcement(cx) + .map(|announcement| { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + }) + .unwrap_or_default() + } +} + +impl BasePanel for RepoDetailView { + fn panel_name(&self) -> &'static str { + "repo" + } +} + +impl Panel for RepoDetailView { + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.display_name(cx) + } +} + +impl EventEmitter for RepoDetailView {} + +impl Focusable for RepoDetailView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RepoDetailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + + let pane_title = self + .selected_file + .clone() + .or_else(|| self.readme_name.clone()) + .unwrap_or_else(|| "Overview".into()); + + let banner = self + .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)) + .when_some(self.render_push_warning_banner(cx), |this, banner| { + this.child(banner) + }) + .child(self.render_header(cx)) + .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(); + })), + ) + }) + .map(|this| match self.active_tab { + 0 => this.child( + h_flex() + .flex_1() + .w_full() + .overflow_hidden() + .child(Self::render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)) + .into_any_element(), + ), + _ => this.child(self.render_commits_tab(cx)), + }) + } +} diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs new file mode 100644 index 0000000..8983484 --- /dev/null +++ b/crates/workspace/src/views/repo/refs.rs @@ -0,0 +1,285 @@ +use std::collections::HashSet; + +use anyhow::Error; +use gpui::prelude::*; +use gpui::{Context, Entity, SharedString, Window}; +use gpui_component::combobox::ComboboxState; +use gpui_component::searchable_list::SearchableVec; + +use super::{RefKind, RepoDetailView}; +use crate::views::repo::helpers::{build_tree_items, sorted_worktree_paths, tree_items}; + +impl RepoDetailView { + /// Check out `name`, a branch or tag picked in the header. + /// Refresh the explorer once the switch completes. + pub(super) fn switch_ref( + &mut self, + kind: RefKind, + name: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + if self.switching_ref { + return; + } + let Some(worktree) = self.worktree.clone() else { + return; + }; + + // Branches and tags are mutually exclusive states of HEAD. + // Selecting one clears the other selector. + // Remember the previous selections to restore them if the checkout fails. + let previous_branch = self.branch_select.read(cx).selected_value(); + let previous_tag = self.tag_select.read(cx).selected_value(); + + match kind { + RefKind::Branch => { + self.tag_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + RefKind::Tag => { + self.branch_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + } + self.switching_ref = true; + // In-flight loads of the previous branch are discarded when they complete. + self.ref_generation += 1; + cx.notify(); + + let checkout_name = name.clone(); + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn(async move { + match kind { + RefKind::Branch => { + signed_git::worktree_checkout_branch(&worktree, &checkout_name) + } + RefKind::Tag => { + signed_git::worktree_checkout_tag(&worktree, &checkout_name) + } + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + match result { + Ok(()) => this.reload_worktree(cx), + Err(error) => { + this.error = Some(format!("Failed to check out {name}: {error}").into()); + this.switching_ref = false; + this.restore_selection(&this.branch_select, &previous_branch, window, cx); + this.restore_selection(&this.tag_select, &previous_tag, window, cx); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Restore a selector to `previous`, or clear it after a failed switch. + fn restore_selection( + &self, + select: &Entity>>, + previous: &Option, + window: &mut Window, + cx: &mut Context, + ) { + select.update(cx, |state, cx| match previous { + Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), + None => state.clear_selection(cx), + }); + } + + /// Refresh the file explorer, preview pane and commit list after a successful switch. + /// The selectors were already updated by [`Self::switch_ref`]. + /// [`Self::switching_ref`] stays set until this reload finishes. + /// A second switch cannot interleave. + fn reload_worktree(&mut self, cx: &mut Context) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let snapshot = signed_git::worktree_snapshot(&worktree)?; + // Build the tree off the main thread, like [`Self::load_repo`]. + let tree = build_tree_items(&snapshot.entries); + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) + }) + .await; + + this.update(cx, |this, cx| { + this.switching_ref = false; + match result { + Ok((snapshot, tree, paths)) => { + this.head_commit = snapshot.head_commit; + this.worktree_paths = paths; + // Rebuild the tree from scratch. + // Entries of the previous branch are gone. + // The expansion state goes with them. + this.tree_state.update(cx, |state, cx| { + state.set_items(tree_items(tree, false), cx); + }); + + // Drop cached previews and commits of the old branch. + this.selected_file = None; + this.files.clear(); + this.file_order.clear(); + this.preview_bytes = 0; + this.loading_files.clear(); + this.commits.clear(); + this.pending_commits.clear(); + this.loading_commits = false; + this.md = None; + this.code = None; + this.readme_name = None; + this.all_commits = None; + this.loading_all_commits = false; + + if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) { + this.readme_name = Some(path.to_string_lossy().into()); + this.load_commit(&path.to_string_lossy(), cx); + if let Ok(text) = String::from_utf8(bytes) { + this.set_markdown(None, &text, cx); + } + } + this.load_all_commits(cx); + } + Err(error) => { + this.error = Some(error.to_string().into()); + this.head_commit = None; + this.worktree_paths.clear(); + // The tree may show files that no longer exist. + this.tree_state.update(cx, |state, cx| { + state.set_items(Vec::new(), cx); + }); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } + + /// Refresh the file explorer, previews and commit list after the mirror + /// caught up with the remote. + /// + /// The checked-out branch fast-forwarded in place, so unlike + /// [`Self::reload_worktree`] this keeps the panel's selection and previews: + /// it rebuilds the tree, drops previews of files the refresh removed and + /// re-renders the README when it is on screen. + pub(super) fn catch_up_worktree(&mut self, cx: &mut Context) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let snapshot = signed_git::worktree_snapshot(&worktree)?; + let tree = build_tree_items(&snapshot.entries); + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) + }) + .await; + + this.update(cx, |this, cx| { + match result { + Ok((snapshot, tree, paths)) => { + let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id) + != this.head_commit.as_ref().map(|c| &c.id); + + // A fast-forward of a branch other than the checked-out + // one leaves the worktree untouched. Rebuilding the tree + // and re-parsing the README would flash the panel for + // nothing, so it is a no-op. + if !head_changed && paths == this.worktree_paths { + log::debug!("repo detail catch_up_worktree: no-op"); + return; + } + + log::debug!( + "repo detail catch_up_worktree: head_changed={head_changed} entries={}", + paths.len() + ); + + this.head_commit = snapshot.head_commit; + this.worktree_paths = paths; + this.tree_state.update(cx, |state, cx| { + state.set_items(tree_items(tree, false), cx); + }); + + // Drop previews of files the refresh removed from the worktree, + // everything else stays put. + let present: HashSet = snapshot + .entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + + let mut previewed: Vec = Vec::new(); + previewed.extend(this.files.keys().cloned()); + previewed.extend(this.selected_file.clone().map(|p| p.to_string())); + + if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) { + previewed.push(path.to_string()); + } + + if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) { + previewed.push(path.to_string()); + } + + previewed.sort(); + previewed.dedup(); + + for path in previewed { + if !present.contains(&path) { + this.drop_preview_of(&path); + } + } + + // Re-render the README when it is on screen, i.e. when no file preview is open. + if this.selected_file.is_none() { + match snapshot.readme_path.zip(snapshot.readme) { + Some((path, bytes)) => { + this.readme_name = Some(path.to_string_lossy().into()); + if let Ok(text) = String::from_utf8(bytes) { + this.set_markdown(None, &text, cx); + } + } + None => { + this.md = None; + this.readme_name = None; + } + } + } + + if head_changed { + this.all_commits = None; + this.loading_all_commits = false; + this.load_all_commits(cx); + } + } + Err(error) => { + this.error = Some(error.to_string().into()); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + task.detach(); + } +} diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs new file mode 100644 index 0000000..2bc4e38 --- /dev/null +++ b/crates/workspace/src/views/repo/store.rs @@ -0,0 +1,102 @@ +use gpui::prelude::*; +use gpui::{Context, Entity}; +use signed_core::Announcement; +use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; + +use super::RepoDetailView; + +impl RepoDetailView { + /// Switch the repository into its NIP-34 mode after a successful init. + /// Creates the nostr store for the announced repository. + /// Drops the local scan identity. + /// The worktree is unchanged, so the explorer keeps its loaded content. + pub(crate) fn apply_announcement( + &mut self, + announcement: Announcement, + cx: &mut Context, + ) { + // The repository is no longer a bare local repo. + // Drop it from the scan results so it leaves the sidebar's local section. + if let Some(path) = self.local_path.take() { + LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); + } + let store = + cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); + // Re-render on store refreshes, issues, PRs and statuses. + // Keep the ready-to-contribute statuses of this repository requested. + self.attach_store(&store, cx); + self.store = Some(store); + self.initial = Some(announcement); + cx.notify(); + } + + /// Observe the repository's store, re-render on refreshes. + /// Request the ready-to-contribute statuses for it. + pub(super) fn attach_store(&mut self, store: &Entity, cx: &mut Context) { + self._subscriptions + .push(cx.observe(store, |this, _store, cx| { + log::debug!("repo detail: store notify"); + this.refresh_ready_statuses(cx); + cx.notify(); + })); + self.refresh_ready_statuses(cx); + } + + /// Request the statuses of this repository again when the announced HEAD changes. + /// The HEAD is the base the checkouts are compared against. + /// Owned repositories are watched for unpushed commits. + /// Other repositories for ready-to-contribute checkouts. + fn refresh_ready_statuses(&mut self, cx: &mut Context) { + let Some(entity) = self.store.clone() else { + return; + }; + + let head = entity.read(cx).head.clone(); + + if self.ready_requested && self.ready_head == head { + return; + } + + self.ready_requested = true; + self.ready_head = head.clone(); + + let addr = entity.read(cx).addr().clone(); + let backend = Backend::global(cx); + let checkout = CheckoutsStore::global(cx); + + let owned = backend + .read(cx) + .current_user() + .is_some_and(|user| entity.read(cx).is_author(&user)); + + checkout.update(cx, |store, cx| { + // The ready statuses keep the fast poll running while the panel is open. + // The sidebar's push watch alone polls slower. + store.request_statuses(&addr, head, cx); + + if owned { + store.request_push_statuses(&addr, cx); + } + }); + } + + /// The ready-to-push statuses of this repository in the global checkouts + /// store changed since they last drove a render. + /// + /// Updates the cached slices. `None` store (a local, not yet published, + /// repository) has no statuses. + pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { + let Some(entity) = self.store.clone() else { + return false; + }; + let addr = entity.read(cx).addr().clone(); + let checkouts = CheckoutsStore::global(cx).read(cx); + let ready_statuses = checkouts.ready_statuses_of(&addr); + let push_statuses = checkouts.push_statuses_of(&addr); + + let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses; + self.ready_statuses = ready_statuses; + self.push_statuses = push_statuses; + changed + } +} diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs deleted file mode 100644 index 49fb6ce..0000000 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ /dev/null @@ -1,160 +0,0 @@ -use gpui::prelude::*; -use gpui::{AnyElement, App, Context, Window, div, px}; -use gpui_component::scroll::Scrollbar; -use gpui_component::spinner::Spinner; -use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list}; -use signed_git::FileCommit; -use signed_ui::placeholder; -use utils::relative_time_secs; - -use super::RepoDetailView; - -/// Height of one commit row in the virtual list. -pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.; - -pub(super) fn commit_row( - ix: usize, - commit: &FileCommit, - on_click: impl Fn(&mut Window, &mut App) + 'static, - cx: &App, -) -> AnyElement { - h_flex() - .id(ix) - .px_4() - .h(px(COMMIT_ROW_HEIGHT)) - .w_full() - .gap_3() - .items_center() - .border_b(px(1.)) - .border_color(cx.theme().border) - .hover(|this| this.bg(cx.theme().list_hover)) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .justify_center() - .child( - h_flex() - .gap_2() - .items_center() - .overflow_hidden() - .child( - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.id.clone()), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_ellipsis() - .whitespace_nowrap() - .child(commit.summary.clone()), - ), - ) - .child( - h_flex() - .gap_2() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.author.clone()) - .child(relative_time_secs(commit.time)), - ), - ) - .on_click(move |_event, window, cx| on_click(window, cx)) - .into_any_element() -} - -impl RepoDetailView { - pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { - let Some(list) = self.all_commits.as_ref() else { - return if self.loading_all_commits { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element() - } else { - placeholder("Failed to load commits", cx) - }; - }; - - if list.commits.is_empty() { - return placeholder("No commits found", cx); - } - - // Copy only the values the element tree needs. - // The list is borrowed by the renderer below instead of cloned per frame. - // A full history can be tens of thousands of commits. - let view = cx.entity().clone(); - let sizes = self.item_sizes.clone(); - let scroll_handle = self.scroll_handle.clone(); - let shown = list.commits.len(); - let total = list.total; - - v_flex() - .relative() - .flex_1() - .w_full() - .min_h_0() - .child( - v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| { - let view = cx.entity().downgrade(); - let commits = this - .all_commits - .as_ref() - .map(|list| list.commits.as_slice()) - .unwrap_or(&[]); - - range - .map(|ix| { - let id = commits[ix].id.clone(); - let view = view.clone(); - - commit_row( - ix, - &commits[ix], - move |window, cx| { - if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| { - this.open_commit_diff(&id, window, cx) - }); - } - }, - cx, - ) - }) - .collect() - }) - .track_scroll(&scroll_handle) - .size_full(), - ) - .when(shown < total, |this| { - // The history is capped. - // Tell the user the list is truncated. - this.child( - div() - .py_2() - .w_full() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(format!("Showing {shown} of {total} commits")), - ) - }) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::vertical(&self.scroll_handle)), - ) - .into_any_element() - } -} diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs deleted file mode 100644 index 1ff0b63..0000000 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ /dev/null @@ -1,2706 +0,0 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::{Component, Path, PathBuf}; -use std::rc::Rc; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Error; -use assets::CustomIconName; -use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; -use gix::Repository; -use gpui::prelude::*; -use gpui::{ - Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, - Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity, - Window, div, px, relative, size, transparent_white, -}; -use gpui_base::dock::PanelView; -use gpui_base::{Button as BaseButton, Disableable, Popover}; -use gpui_component::alert::Alert; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::{Combobox, ComboboxEvent, ComboboxState}; -use gpui_component::menu::DropdownMenu; -use gpui_component::searchable_list::SearchableVec; -use gpui_component::tree::TreeState; -use gpui_component::{ - ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, - VirtualListScrollHandle, h_flex, v_flex, -}; -use nostr::prelude::{EventId, RelayUrl, ToBech32, Url}; -use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; -use signed_git::{CommitList, FileCommit}; -use signed_state::{ - Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore, - RepoListStore, RepoStore, pr_proposes_checkout, -}; -use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row}; - -mod about; -mod browser; -mod commits; -mod diff; -mod helpers; -mod init_dialog; -mod issue_detail; -mod issues; -mod new_pull_request; -mod pull_request_detail; -mod pull_requests; -mod send_patch; - -use about::open_about_dialog; -use browser::{ - CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, - MarkdownView, -}; -use commits::COMMIT_ROW_HEIGHT; -use diff::CommitDiffView; -use helpers::{ - ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger, - tree_items, -}; -use issue_detail::IssueDetailView; -use issues::{IssuesView, open_new_issue_dialog}; -use pull_request_detail::PullRequestDetailView; -use pull_requests::PullRequestsView; -use send_patch::open_send_patch_panel; - -use crate::views::repo_detail::new_pull_request::open_new_pull_panel; - -/// What kind of ref the header selectors switch to. -#[derive(Clone, Copy, PartialEq, Eq)] -enum RefKind { - /// A local branch `refs/heads/*`, HEAD stays attached. - Branch, - /// A tag `refs/tags/*`, HEAD becomes detached. - Tag, -} - -/// Header actions dispatched by the dropdown menus of the header buttons. -/// `pub(super)` because the pull-request list panel shares this action set. -/// It offers the New-PR and Send-patch actions in its own dropdown. -#[derive(Clone, Action, PartialEq, Eq)] -#[action(namespace = repo_detail, no_json)] -pub(super) enum RepoAction { - /// Open the new issue dialog. - NewIssue, - /// Open the new pull request dialog. - NewPR, - /// Open the send patch panel. - SendPatch, - /// Open the about dialog. - About, - /// Re-push the repository to its grasp servers. - Push, - /// Delete the repository from nostr, owner only. - Delete, -} - -/// Everything loaded from the local clone for the explorer. -/// The tree seeds, README, refs and HEAD commit. -/// Computed on a background thread, see [`load_repo_data`]. -/// Applied on the main thread. -struct RepoData { - tree: Vec, - readme_path: Option, - readme: Option>, - worktree: Option, - branches: Vec, - tags: Vec, - current_branch: Option, - head_commit: Option, -} - -/// Detail view of a repository, header, stats and metadata. -/// A file explorer with README preview, cloned from the announcement's `clone` URLs. -pub struct RepoDetailView { - focus_handle: FocusHandle, - /// Dock area the detail view lives in. - /// New panels, commit diffs, are added there. - dock_area: WeakEntity, - /// Snapshot taken at open time. - /// Shown until the store's first refresh completes. - /// Also a fallback while the store has no announcement. - /// `None` for local repositories that haven't been published yet. - initial: Option, - /// Per-repository nostr store, holding announcement, issues, PRs and statuses. - /// `None` until a local repository is initialized to NIP-34. - store: Option>, - /// Path of the local repository when opened from the scan. - /// `None` once it is initialized to NIP-34, or for announced repositories. - local_path: Option, - /// File explorer state, the worktree of the local clone. - tree_state: Entity, - /// Root of the local clone, for reading files on demand. - worktree: Option, - /// Markdown document currently in the preview pane, README or a file. - md: Option, - /// Code file currently in the preview pane. - code: Option, - readme_name: Option, - /// Currently previewed file, a relative path, and its contents. - selected_file: Option, - files: HashMap, - /// Paths of cached previews, oldest first. - /// Feeds the eviction caps in [`Self::evict_previews`]. - file_order: VecDeque, - /// Total text bytes held by [`Self::files`]. - preview_bytes: usize, - /// Reads in flight, to avoid duplicate loads. - loading_files: HashSet, - /// Latest commit touching a previewed file or the README, keyed by path. - commits: HashMap, - /// Paths queued for the next batched commit query, see [`Self::load_commits`]. - pending_commits: Vec, - /// A batched commit query is in flight. - loading_commits: bool, - /// Active header tab, 0 = Files tree, 1 = Commits. - active_tab: usize, - /// Commits reachable from HEAD, newest first. - /// `None` until the walk finishes or fails. - /// [`CommitList`] caps the list, `total` feeds the tab badge. - all_commits: Option, - /// Commit walk in flight. - loading_all_commits: bool, - /// Virtual list state of the Commits tab. - scroll_handle: VirtualListScrollHandle, - item_sizes: Rc>>, - /// A clone/fetch is in flight. - loading: bool, - error: Option, - /// Commit HEAD currently points to, shown in the header button. - head_commit: Option, - /// Branch selector in the header, local branches, searchable. - branch_select: Entity>>, - /// Tag selector in the header, tags, searchable. - tag_select: Entity>>, - /// A branch/tag switch is in flight, checkout plus explorer reload. - switching_ref: bool, - /// Bumped on every branch/tag switch. - /// In-flight loads with an older generation are discarded when they complete. - ref_generation: u64, - /// Subscriptions keeping the selectors' confirm events alive. - _subscriptions: Vec, - /// `(path, branch)` ready-suggestions dismissed by the user, per panel. - banner_dismissed: HashSet<(PathBuf, String)>, - /// The announced HEAD the ready statuses were last requested with. - /// Whether they were requested at all. - /// Re-requested only when the HEAD, the base default, changes. - /// e.g. when the store's first refresh lands. - ready_requested: bool, - ready_head: Option, - /// The global checkouts store's ready-to-contribute statuses of this - /// repository, last seen when they drove a render. - /// - /// The store notifies on any recompute pass; the observer re-renders this - /// panel only when these slices changed. - ready_statuses: Vec, - /// The global checkouts store's ready-to-push statuses of this repository, - /// last seen when they drove a render. - push_statuses: Vec, - /// Upstream repository, from this fork's `u` tag, the user asked to open. - /// Its announcement is still being fetched. - pending_upstream: Option, -} - -impl RepoDetailView { - /// Open a repository announced. - /// - /// The store connects to the announcement's relays and loads issues, PRs and statuses. - pub fn new( - dock_area: WeakEntity, - initial: Announcement, - window: &mut Window, - cx: &mut Context, - ) -> Self { - // The announcement we opened from already carries the NIP-34 `relays` tag. - // The store connects to those relays immediately, no bootstrap fetch wait. - let addr = initial.addr(); - let relays = initial.relays.clone(); - let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); - - let mut view = Self::new_common( - dock_area, - Some(initial), - Some(store.clone()), - None, - window, - cx, - ); - view.attach_store(&store, cx); - view - } - - /// Open a local repository discovered by the scan. - pub fn new_local( - dock_area: WeakEntity, - local_path: PathBuf, - window: &mut Window, - cx: &mut Context, - ) -> Self { - Self::new_common(dock_area, None, None, Some(local_path), window, cx) - } - - /// Shared construction. - /// - /// File explorer state, ref selectors and the deferred repository load. - fn new_common( - dock_area: WeakEntity, - initial: Option, - store: Option>, - local_path: Option, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let tree_state = cx.new(|cx| TreeState::new(cx)); - - // Empty until the clone completes, then filled with the local refs. - let branch_select: Entity>> = cx.new(|cx| { - ComboboxState::new( - SearchableVec::new(Vec::::new()), - Vec::new(), - window, - cx, - ) - .searchable(true) - }); - let tag_select: Entity>> = cx.new(|cx| { - ComboboxState::new( - SearchableVec::new(Vec::::new()), - Vec::new(), - window, - cx, - ) - .searchable(true) - }); - - let mut subscriptions = vec![ - cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { - // `Change` fires only when the selection actually changed. - // Picking the already-selected branch emits nothing. - // A confirmed value always means a switch. - if let ComboboxEvent::Change(values) = event - && let Some(name) = values.first() - { - this.switch_ref(RefKind::Branch, name.clone(), window, cx); - } - }), - cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| { - if let ComboboxEvent::Change(values) = event - && let Some(name) = values.first() - { - this.switch_ref(RefKind::Tag, name.clone(), window, cx); - } - }), - ]; - - // The ready-to-contribute and ready-to-push banners are driven by the - // global checkouts store. It notifies on every recompute; compare the - // statuses of this repository so unrelated updates (the sidebar badges, - // other open panels) do not re-render this panel. - let checkouts = CheckoutsStore::global(cx); - subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { - if this.refresh_statuses(cx) { - cx.notify(); - } - })); - - // Defer loading the repository until the window is ready. - cx.defer_in(window, |this, window, cx| { - this.load_repo(window, cx); - }); - - Self { - initial, - dock_area, - store, - local_path, - tree_state, - worktree: None, - md: None, - code: None, - readme_name: None, - selected_file: None, - files: HashMap::new(), - file_order: VecDeque::new(), - preview_bytes: 0, - loading_files: HashSet::new(), - commits: HashMap::new(), - pending_commits: Vec::new(), - loading_commits: false, - active_tab: 0, - all_commits: None, - loading_all_commits: false, - scroll_handle: VirtualListScrollHandle::new(), - item_sizes: Rc::new(Vec::new()), - loading: true, - error: None, - head_commit: None, - branch_select, - tag_select, - switching_ref: false, - ref_generation: 0, - banner_dismissed: HashSet::new(), - ready_requested: false, - ready_head: None, - ready_statuses: Vec::new(), - push_statuses: Vec::new(), - pending_upstream: None, - focus_handle: cx.focus_handle(), - _subscriptions: subscriptions, - } - } - - /// 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. - fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { - self.loading = true; - self.error = None; - cx.notify(); - - // Local repositories live on disk at their scan path. - // No clone step or network refresh applies here. - if let Some(local_path) = self.local_path.clone() { - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let data = cx - .background_spawn(async move { - let repo = gix::open(&local_path)?; - load_repo_data(&repo) - }) - .await; - - this.update_in(cx, |this, window, cx| { - match data { - Ok(data) => this.apply_repo_data(data, window, cx), - Err(error) => this.error = Some(error.to_string().into()), - } - this.loading = false; - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - - return; - } - - let Some(initial) = self.initial.as_ref() else { - return; - }; - - let cache = GitStore::global(cx).cache().clone(); - let addr = initial.addr(); - let clone_urls: Vec = initial.clone.clone(); - - // Captured before the loads start. - // A branch/tag switch bumps the generation, discarding the refresh below. - let refresh_generation = self.ref_generation; - - let disk = { - let cache = cache.clone(); - let addr = addr.clone(); - cx.background_spawn(async move { - match cache.open(&addr)? { - Some(repo) => Ok(Some(load_repo_data(&repo)?)), - None => Ok(None), - } - }) - }; - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let disk = disk.await; - let had_clone = matches!(&disk, Ok(Some(_))); - - // No local clone yet, so clone from the network then load. - let data = match disk { - Ok(Some(data)) => Ok(data), - Ok(None) => { - let cache = cache.clone(); - let addr = addr.clone(); - let clone_urls = clone_urls.clone(); - cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - load_repo_data(&repo) - }) - .await - } - Err(error) => Err(error), - }; - - this.update_in(cx, |this, window, cx| { - match data { - Ok(data) => this.apply_repo_data(data, window, cx), - Err(error) => this.error = Some(error.to_string().into()), - } - this.loading = false; - cx.notify(); - })?; - - // Refresh the clone from the network in the background. - // When it completes, update the refs and commit list. - // Loads started before a branch/tag switch are discarded via the generation. - if !had_clone { - return Ok(()); - } - - let refresh = { - let cache = cache.clone(); - let addr = addr.clone(); - cx.background_spawn(async move { - 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. - // That covers own repo pushes from a checkout and updates fetched here. - // Fast-forward branches from the remote, like `git pull --ff-only`. - // Only the checked-out branch's worktree can change on disk. - let moved = match &worktree { - Some(worktree) => { - signed_git::fast_forward_branches(worktree).unwrap_or(false) - } - None => false, - }; - - let (branches, tags) = match &worktree { - Some(_) => ( - signed_git::repo_branches(&repo).unwrap_or_default(), - signed_git::repo_tags(&repo).unwrap_or_default(), - ), - 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))) - }) - } - .await; - - this.update_in(cx, |this, window, cx| { - if refresh_generation != this.ref_generation { - return; - } - - if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { - let branches: Vec = branches.iter().map(Into::into).collect(); - let tags: Vec = tags.iter().map(Into::into).collect(); - - this.branch_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(branches), window, cx); - if let Some(branch) = current_branch { - let branch: SharedString = branch.into(); - state.set_selected_values(&[branch], window, cx); - } - }); - - this.tag_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(tags), window, cx); - }); - - let new_head_commit = head_commit.as_ref().map(|c| &c.id); - let current_head_commit = this.head_commit.as_ref().map(|c| &c.id); - let head_changed = new_head_commit != current_head_commit; - this.head_commit = head_commit; - - if head_changed || this.all_commits.is_none() { - this.all_commits = None; - this.loading_all_commits = false; - this.load_all_commits(cx); - } - - if moved { - this.catch_up_worktree(cx); - } - - cx.notify(); - } - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Apply the loaded repository data. - fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { - let RepoData { - tree, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - } = data; - let Some(worktree) = worktree else { - self.error = Some("Repository has no worktree".into()); - return; - }; - - self.worktree = Some(worktree); - self.head_commit = head_commit; - self.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); - }); - - // Populate the branch/tag selectors with the local refs. - // Select the branch HEAD points to. - let branches: Vec = branches.into_iter().map(Into::into).collect(); - let tags: Vec = tags.into_iter().map(Into::into).collect(); - - self.branch_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(branches), window, cx); - if let Some(branch) = current_branch { - let branch: SharedString = branch.into(); - state.set_selected_values(&[branch], window, cx); - } - }); - - self.tag_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(tags), window, cx); - }); - - self.load_all_commits(cx); - - if let Some((path, bytes)) = readme_path.zip(readme) { - self.readme_name = Some(path.to_string_lossy().into()); - self.load_commit(&path.to_string_lossy(), cx); - if let Ok(text) = String::from_utf8(bytes) { - self.set_markdown(None, &text, cx); - } - } - } - - /// Clone the repository into a user-chosen folder outside the cache. - fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - - let name = { - let Some(announcement) = self.announcement(cx) else { - return; - }; - let addr = announcement.addr(); - // Directory name, the display name falling back to the repo id. - // Both are sanitized to a safe single path component. - let name = announcement - .name - .as_ref() - .map(|name| name.to_string()) - .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| addr.identifier.clone()); - let name = signed_git::sanitize_path_component(&name); - if name.is_empty() { - "repository".to_owned() - } else { - name - } - }; - - let prompt = cx.prompt_for_paths(PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: Some("Clone".into()), - }); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - // `Ok(Ok(Some(paths)))` means the user picked a folder. - // A cancel or picker failure resolves to anything else. - let picked = match prompt.await { - Ok(Ok(Some(mut paths))) => paths.pop(), - _ => None, - }; - let Some(folder) = picked else { - return Ok(()); - }; - - let destination = folder.join(&name); - let destination_for_open = destination.clone(); - - // 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(()) - }); - - task.detach(); - } - - /// Preview the file at `path`, relative to the worktree root. - fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { - self.selected_file = Some(path.into()); - - if self.files.contains_key(path) { - // The file is cached, but the markdown or code state may hold a different file. - // Re-point it at this one, the parse runs on a background task either way. - // Without this, the pane would show a spinner forever. - if let Some(FileContent::Text(text)) = self.files.get(path) { - let text = text.clone(); - if is_markdown_path(path) { - if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) { - self.set_markdown(Some(path.into()), &text, cx); - } - } else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) { - self.set_code(path.into(), &text, window, cx); - } - } - cx.notify(); - return; - } - if self.loading_files.contains(path) { - cx.notify(); - return; - } - - // Paths come from our own tree walk, but never trust them. - // Refuse anything that could escape the worktree. - let rel = Path::new(path); - let unsafe_path = rel.is_absolute() - || rel.components().any(|c| { - matches!( - c, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }); - - let Some(worktree) = self.worktree.clone() else { - return; - }; - - if unsafe_path { - return; - } - - self.loading_files.insert(path.to_string()); - let path = path.to_string(); - - self.load_commit(&path, cx); - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let path_for_read = path.clone(); - let content = cx - .background_spawn(async move { - let full = worktree.join(&path_for_read); - // Refuse oversized files before reading them. - // Reading a multi-gigabyte file just to classify it is wasteful. - // It would burn disk and memory bandwidth. - let metadata = match std::fs::metadata(&full) { - Ok(metadata) => metadata, - Err(error) => return Err(anyhow::anyhow!("{}", error)), - }; - if metadata.len() > MAX_PREVIEW_BYTES as u64 { - return Ok(FileContent::TooLarge); - } - let bytes = match std::fs::read(&full) { - Ok(bytes) => bytes, - Err(error) => return Err(anyhow::anyhow!("{}", error)), - }; - match String::from_utf8(bytes) { - Ok(text) => Ok(FileContent::Text(text)), - Err(_) => Ok(FileContent::Binary), - } - }) - .await; - - this.update_in(cx, |this, window, cx| { - // The worktree was switched while this file was reading. - // The result belongs to the previous branch. - // Clear the in-flight marker either way. - // Otherwise the path could never be loaded again. - if generation != this.ref_generation { - this.loading_files.remove(&path); - return; - } - this.loading_files.remove(&path); - match content { - Ok(kind) => { - if let FileContent::Text(text) = &kind { - if is_markdown_path(&path) { - let same = this.md.as_ref().map(|md| md.path.as_deref()) - == Some(Some(path.as_str())); - if !same { - this.set_markdown(Some(path.clone().into()), text, cx); - } - } else { - let same = this.code.as_ref().map(|code| code.path.as_str()) - == Some(path.as_str()); - if !same { - this.set_code(path.clone().into(), text, window, cx); - } - } - this.preview_bytes += text.len(); - } - this.files.insert(path.clone(), kind); - this.file_order.push_back(path); - this.evict_previews(); - } - Err(error) => { - this.files - .insert(path, FileContent::Failed(error.to_string())); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Queue `path` for the per-file commit query. - /// Requests are batched into one history walk, see [`Self::load_commits`]. - fn load_commit(&mut self, path: &str, cx: &mut Context) { - if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { - return; - } - self.pending_commits.push(path.to_string()); - if !self.loading_commits { - self.load_commits(cx); - } - } - - /// Walk history once for every queued path on a background task. - /// Cache the latest commit touching each path in [`Self::commits`]. - /// That feeds the file header in the content column. - /// Batching shares one walk across paths queued while the previous walk ran. - fn load_commits(&mut self, cx: &mut Context) { - if self.pending_commits.is_empty() || self.loading_commits { - return; - } - let Some(worktree) = self.worktree.clone() else { - self.pending_commits.clear(); - return; - }; - - self.loading_commits = true; - let paths = std::mem::take(&mut self.pending_commits); - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let rels: Vec = paths.iter().map(PathBuf::from).collect(); - let result = cx - .background_spawn( - async move { signed_git::worktree_last_commits(&worktree, &rels) }, - ) - .await; - - this.update(cx, |this, cx| { - this.loading_commits = false; - if generation == this.ref_generation - && let Ok(found) = result - { - for (path, commit) in found { - this.commits - .insert(path.to_string_lossy().into_owned(), commit); - } - } - // Paths queued while the walk was in flight start the next batch. - // A stale walk, branch switched mid-flight, must not strand them. - // This runs under the current generation regardless of the result. - if !this.pending_commits.is_empty() { - this.load_commits(cx); - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Walk all commits reachable from HEAD on a background task. - /// For the Commits tab and its total-count badge. - /// [`CommitList`] caps the list, only the newest commits are materialized. - fn load_all_commits(&mut self, cx: &mut Context) { - if self.loading_all_commits || self.all_commits.is_some() { - return; - } - - let Some(worktree) = self.worktree.clone() else { - return; - }; - - self.loading_all_commits = true; - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) - .await; - - this.update(cx, |this, cx| { - // A stale walk, branch switched mid-flight, must not leave the flag set. - // Otherwise the Commits tab would spin forever. - if generation != this.ref_generation { - this.loading_all_commits = false; - return; - } - if let Ok(list) = result { - let count = list.commits.len(); - this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); - this.all_commits = Some(list); - } - this.loading_all_commits = false; - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Open a new panel showing the diff of `commit_id`. - fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - // Same display name as the repo detail panel's title. - let repo_name = self.display_name(cx); - - let panel = - cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - /// Re-push the repository's refs to its announced grasp servers. - fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - - self.error = None; - cx.notify(); - - store - .update(cx, |store, cx| store.push_repository(cx)) - .detach(); - } - - /// Push the unpushed commits of the local checkout at `path`. - fn push_unpushed_checkout( - &mut self, - path: PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - let Some(store) = self.store.clone() else { - return; - }; - - if store.read(cx).pushing { - return; - } - - self.error = None; - cx.notify(); - - let task: gpui::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(()) - }); - - task.detach(); - } - - /// Delete the repository from nostr, announcement, state and activity. - fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - store - .update(cx, |store, cx| store.delete_repository(cx)) - .detach(); - } - - /// Open the issues list panel in the dock area. - fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - /// Open the pull requests list panel in the dock area. - fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - /// Open the upstream repository, the `u` tag of this fork's announcement. - /// The upstream announcement may not be in the local database yet. - /// Subscribe for it and open the panel as soon as it lands. - fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { - if self.pending_upstream.is_some() { - return; - } - - let Some(announcement) = self.announcement(cx).cloned() else { - return; - }; - - let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else { - return; - }; - - if let Some(found) = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == addr) - .cloned() - { - open_repo_panel(&self.dock_area, &found, window, &mut *cx); - return; - } - - let backend = Backend::global(cx); - backend.update(cx, |backend, cx| { - backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx); - }); - self.pending_upstream = Some(addr); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - for _ in 0..60 { - cx.background_executor() - .timer(Duration::from_millis(250)) - .await; - - let opened = this.update_in(cx, |this, window, cx| { - let Some(addr) = this.pending_upstream.clone() else { - return true; - }; - let found = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == addr) - .cloned(); - match found { - Some(found) => { - this.pending_upstream = None; - open_repo_panel(&this.dock_area, &found, window, &mut *cx); - true - } - None => false, - } - })?; - - if opened { - return Ok(()); - } - } - - this.update(cx, |this, _cx| this.pending_upstream = None)?; - Ok(()) - }); - - task.detach(); - } - - /// Check out `name`, a branch or tag picked in the header. - /// Refresh the explorer once the switch completes. - fn switch_ref( - &mut self, - kind: RefKind, - name: SharedString, - window: &mut Window, - cx: &mut Context, - ) { - if self.switching_ref { - return; - } - let Some(worktree) = self.worktree.clone() else { - return; - }; - - // Branches and tags are mutually exclusive states of HEAD. - // Selecting one clears the other selector. - // Remember the previous selections to restore them if the checkout fails. - let previous_branch = self.branch_select.read(cx).selected_value(); - let previous_tag = self.tag_select.read(cx).selected_value(); - - match kind { - RefKind::Branch => { - self.tag_select - .update(cx, |state, cx| state.clear_selection(cx)); - } - RefKind::Tag => { - self.branch_select - .update(cx, |state, cx| state.clear_selection(cx)); - } - } - self.switching_ref = true; - // In-flight loads of the previous branch are discarded when they complete. - self.ref_generation += 1; - cx.notify(); - - let checkout_name = name.clone(); - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let result = cx - .background_spawn(async move { - match kind { - RefKind::Branch => { - signed_git::worktree_checkout_branch(&worktree, &checkout_name) - } - RefKind::Tag => { - signed_git::worktree_checkout_tag(&worktree, &checkout_name) - } - } - }) - .await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(()) => this.reload_worktree(cx), - Err(error) => { - this.error = Some(format!("Failed to check out {name}: {error}").into()); - this.switching_ref = false; - this.restore_selection(&this.branch_select, &previous_branch, window, cx); - this.restore_selection(&this.tag_select, &previous_tag, window, cx); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Restore a selector to `previous`, or clear it after a failed switch. - fn restore_selection( - &self, - select: &Entity>>, - previous: &Option, - window: &mut Window, - cx: &mut Context, - ) { - select.update(cx, |state, cx| match previous { - Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), - None => state.clear_selection(cx), - }); - } - - /// Refresh the file explorer, preview pane and commit list after a successful switch. - /// The selectors were already updated by [`Self::switch_ref`]. - /// [`Self::switching_ref`] stays set until this reload finishes. - /// A second switch cannot interleave. - fn reload_worktree(&mut self, cx: &mut Context) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; - // Build the tree off the main thread, like [`Self::load_repo`]. - let tree = build_tree_items(&snapshot.entries); - Ok::<_, Error>((snapshot, tree)) - }) - .await; - - this.update(cx, |this, cx| { - this.switching_ref = false; - match result { - Ok((snapshot, tree)) => { - this.head_commit = snapshot.head_commit; - // Rebuild the tree from scratch. - // Entries of the previous branch are gone. - // The expansion state goes with them. - this.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); - }); - - // Drop cached previews and commits of the old branch. - this.selected_file = None; - this.files.clear(); - this.file_order.clear(); - this.preview_bytes = 0; - this.loading_files.clear(); - this.commits.clear(); - this.pending_commits.clear(); - this.loading_commits = false; - this.md = None; - this.code = None; - this.readme_name = None; - this.all_commits = None; - this.loading_all_commits = false; - - if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) { - this.readme_name = Some(path.to_string_lossy().into()); - this.load_commit(&path.to_string_lossy(), cx); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - this.load_all_commits(cx); - } - Err(error) => { - this.error = Some(error.to_string().into()); - this.head_commit = None; - // The tree may show files that no longer exist. - this.tree_state.update(cx, |state, cx| { - state.set_items(Vec::new(), cx); - }); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Refresh the file explorer, previews and commit list after the mirror - /// caught up with the remote. - /// - /// The checked-out branch fast-forwarded in place, so unlike - /// [`Self::reload_worktree`] this keeps the panel's selection and previews: - /// it rebuilds the tree, drops previews of files the refresh removed and - /// re-renders the README when it is on screen. - fn catch_up_worktree(&mut self, cx: &mut Context) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; - let tree = build_tree_items(&snapshot.entries); - Ok::<_, Error>((snapshot, tree)) - }) - .await; - - this.update(cx, |this, cx| { - match result { - Ok((snapshot, tree)) => { - let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id) - != this.head_commit.as_ref().map(|c| &c.id); - - this.head_commit = snapshot.head_commit; - this.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); - }); - - // Drop previews of files the refresh removed from the worktree, - // everything else stays put. - let present: HashSet = snapshot - .entries - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(); - - let mut previewed: Vec = Vec::new(); - previewed.extend(this.files.keys().cloned()); - previewed.extend(this.selected_file.clone().map(|p| p.to_string())); - - if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) { - previewed.push(path.to_string()); - } - - if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) { - previewed.push(path.to_string()); - } - - previewed.sort(); - previewed.dedup(); - - for path in previewed { - if !present.contains(&path) { - this.drop_preview_of(&path); - } - } - - // Re-render the README when it is on screen, i.e. when no file preview is open. - if this.selected_file.is_none() { - match snapshot.readme_path.zip(snapshot.readme) { - Some((path, bytes)) => { - this.readme_name = Some(path.to_string_lossy().into()); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - None => { - this.md = None; - this.readme_name = None; - } - } - } - - if head_changed { - this.all_commits = None; - this.loading_all_commits = false; - this.load_all_commits(cx); - } - } - Err(error) => { - this.error = Some(error.to_string().into()); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Drop the cached preview, editor and commit state of `path`. - fn drop_preview_of(&mut self, path: &str) { - if let Some(FileContent::Text(text)) = self.files.remove(path) { - self.preview_bytes -= text.len(); - } - self.commits.remove(path); - if self.selected_file.as_deref() == Some(path) { - self.selected_file = None; - } - if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) { - self.md = None; - } - if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) { - self.code = None; - } - } - - /// Drop the oldest previews beyond the cache caps. - /// Keep the currently selected file. - /// An evicted file's parsed editor state drops with its entry. - /// Re-opening it re-parses on a background task. - fn evict_previews(&mut self) { - while (self.files.len() > MAX_PREVIEWED_FILES - || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) - && self.file_order.len() > 1 - { - let path = self.file_order.pop_front().expect("non-empty"); - if Some(path.as_str()) == self.selected_file.as_deref() { - self.file_order.push_back(path); - continue; - } - if let Some(FileContent::Text(text)) = self.files.remove(&path) { - self.preview_bytes -= text.len(); - } - if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) { - self.md = None; - } - if self - .code - .as_ref() - .is_some_and(|code| code.path.as_ref() == path.as_str()) - { - self.code = None; - } - self.commits.remove(&path); - } - } - - /// The latest announcement from the store or the open-time snapshot. - /// `None` for local repositories that haven't been published yet. - fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { - let store = self.store.as_ref()?; - store - .read(cx) - .announcement - .as_ref() - .or(self.initial.as_ref()) - } - - /// Display name, the announcement's name or ID for announced repositories. - /// The directory name for local ones. - fn display_name(&self, cx: &App) -> SharedString { - if let Some(path) = &self.local_path { - return SharedString::from( - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()), - ); - } - self.announcement(cx) - .map(|announcement| { - announcement - .name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) - }) - .unwrap_or_default() - } - - /// The NIP-34 header, actions and issues/PR counts. - /// Or the local header with an Init button for an unpublished repository. - fn render_header(&mut self, cx: &mut Context) -> AnyElement { - if self.local_path.is_some() { - return self.render_local_header(cx); - } - - 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(); - }; - - // Derived NIP-34 header data, share targets and clone commands. - // Rebuilt per frame: two bech32 encodes and a couple of format strings. - let nip05 = ProfileStore::global(cx) - .read(cx) - .get(&source.owner) - .metadata() - .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}")); - let git_commands = Rc::new(announcement.clone_urls()); - - let name = self.display_name(cx); - let description = announcement.description(); - let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); - - v_flex() - .on_action( - cx.listener(|this, action: &RepoAction, window, cx| match action { - RepoAction::NewIssue => { - if let Some(store) = this.store.clone() { - open_new_issue_dialog(store, window, cx); - } - } - RepoAction::NewPR => { - if let Some(store) = this.store.clone() { - open_new_pull_panel(this.dock_area.clone(), store, window, cx); - } - } - RepoAction::SendPatch => { - if let Some(store) = this.store.clone() { - open_send_patch_panel(this.dock_area.clone(), store, window, cx); - } - } - RepoAction::About => { - if let Some(announcement) = this.announcement(cx) { - open_about_dialog(announcement.clone(), window, cx); - } - } - RepoAction::Push => this.push_repository(window, cx), - RepoAction::Delete => this.delete_repository(window, cx), - }), - ) - .p_4() - .w_full() - .gap_8() - .border_b_1() - .border_color(cx.theme().border) - .child( - h_flex() - .w_full() - .gap_4() - .items_start() - .justify_between() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - h_flex() - .gap_2() - .min_h_8() - .font_semibold() - .child(avatar.size_6()) - .child(name), - ) - .child( - div() - .min_w_0() - .text_sm() - .text_color(cx.theme().muted_foreground) - .line_clamp(2) - .line_height(relative(1.25)) - .text_ellipsis() - .child(description), - ) - .when_some(fork_row(&announcement, cx), |this, row| this.child(row)) - .child( - h_flex() - .mt_2() - .w_full() - .gap_0p5() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .font_semibold() - .child("Maintainers:"), - ) - .child(self.render_maintainers(cx)), - ), - ) - .child( - h_flex() - .flex_none() - .gap_2() - .justify_end() - .child( - DropdownButton::new("issues") - .action( - BaseButton::new("issues-open") - .child( - h_flex() - .h_8() - .px_2() - .gap_1() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .hover(|this| { - this.bg(cx.theme().secondary_hover) - }) - .text_sm() - .text_color(cx.theme().secondary_foreground) - .child(Icon::new(CustomIconName::GitIssueDone)) - .child("Issues") - .child( - div() - .mx_1() - .h_5() - .w_px() - .bg(cx.theme().border.darken(0.1)), - ) - .child(issue_count), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_issue_detail(window, cx); - })), - ) - .dropdown_menu(|menu, _, _| { - menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::Plus)) - .child("New issue") - }) - }), - ) - .child( - DropdownButton::new("prs") - .action( - BaseButton::new("prs-open") - .child( - h_flex() - .h_8() - .px_2() - .gap_1() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .hover(|this| { - this.bg(cx.theme().secondary_hover) - }) - .text_sm() - .text_color(cx.theme().secondary_foreground) - .child(Icon::new( - CustomIconName::GitPullRequest, - )) - .child("Pull Requests") - .child( - div() - .mx_1() - .h_5() - .w_px() - .bg(cx.theme().border.darken(0.1)), - ) - .child(pr_count), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_pull_request_detail(window, cx); - })), - ) - .dropdown_menu(|menu, _, _| { - menu.menu_element(Box::new(RepoAction::NewPR), |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::Plus)) - .child("New Pull Request") - }) - .menu_element( - Box::new(RepoAction::SendPatch), - |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::File)) - .child("Send Patch") - }, - ) - }), - ) - .child( - DropdownButton::new("share") - .action( - Button::new("link") - .icon(IconName::Copy) - .tooltip("Copy ID") - .secondary() - .on_click({ - let naddr = share.naddr.clone(); - move |_, _, cx| { - cx.write_to_clipboard( - ClipboardItem::new_string(naddr.clone()), - ); - } - }), - ) - .dropdown_menu(move |menu, _, _| share.menu(menu)), - ) - .child( - Button::new("repo-menu-open") - .icon(IconName::EllipsisVertical) - .tooltip("Repository management") - .compact() - .secondary() - .loading(pushing) - .disabled(pushing) - .dropdown_menu(move |menu, _, cx| { - let backend = Backend::global(cx); - let current_user = backend.read(cx).current_user(); - let owner = current_user == Some(announcement.owner); - - let menu = menu.menu_element( - Box::new(RepoAction::About), - |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::Info)) - .child("About") - }, - ); - - if owner { - menu.menu_element(Box::new(RepoAction::Push), |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(CustomIconName::Init)) - .child("Republish") - }) - .separator() - .menu_element(Box::new(RepoAction::Delete), |_, cx| { - h_flex() - .gap_2() - .text_sm() - .text_color(cx.theme().danger) - .child(Icon::new(IconName::Delete)) - .child("Delete") - }) - } else { - menu - } - }), - ) - .child({ - let view = cx.entity(); - let ngit_command = ngit_command.clone(); - let nak_command = nak_command.clone(); - let git_commands = git_commands.clone(); - - Popover::new("clone") - .anchor(Anchor::TopRight) - .trigger( - Button::new("clone") - .icon(CustomIconName::GitClone) - .tooltip("Clone") - .loading(cloning) - .disabled(cloning) - .primary(), - ) - .content(move |_, _window, cx| { - let state = cx.entity(); - let ngit_row = copy_row("copy-ngit", &ngit_command, cx); - let nak_row = copy_row("copy-nak", &nak_command, cx); - - v_flex() - .w(px(440.)) - .mt_1() - .p_3() - .gap_4() - .popover_style(cx) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Clone with ngit"), - ) - .child(ngit_row), - ) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Clone with nak"), - ) - .child(nak_row), - ) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Grasp Servers"), - ) - .when(!git_commands.is_empty(), |this| { - this.children( - git_commands.iter().enumerate().map( - |(ix, cmd)| { - copy_row( - format!("copy-git-{ix}"), - cmd, - cx, - ) - }, - ), - ) - }) - .when(git_commands.is_empty(), |this| { - this.child( - div() - .text_xs() - .child("No git clone urls."), - ) - }), - ) - .child(div().h_px().w_full().bg(cx.theme().border)) - .child( - h_flex().gap_1().justify_end().child( - Button::new("download") - .icon(CustomIconName::GitClone) - .label("Download") - .primary() - .on_click(move |_event, window, cx| { - state.update(cx, |state, cx| { - state.dismiss(window, cx); - }); - view.update(cx, |this, cx| { - this.clone_to_folder(window, cx); - }); - }), - ), - ) - }) - }), - ), - ) - .child(self.render_header_tabs(cx)) - .into_any_element() - } - - /// Header for a local, not yet published, repository. - /// The directory name and path with an Init button instead of the NIP-34 actions. - fn render_local_header(&self, cx: &mut Context) -> AnyElement { - let name = self.display_name(cx); - let path = self - .local_path - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(); - let avatar = PixelAvatar::new(path.clone()); - - v_flex() - .px_4() - .pb_4() - .w_full() - .gap_8() - .border_b_1() - .border_color(cx.theme().border) - .child( - h_flex() - .w_full() - .gap_4() - .items_start() - .justify_between() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - h_flex() - .gap_2() - .min_h_8() - .font_semibold() - .child(avatar.size_6()) - .child(name), - ) - .child( - div() - .min_w_0() - .text_sm() - .text_color(cx.theme().muted_foreground) - .line_clamp(2) - .line_height(relative(1.25)) - .text_ellipsis() - .child(path), - ), - ) - .child( - Button::new("init") - .icon(CustomIconName::Init) - .label("Initialize on Nostr") - .primary() - .tooltip("Publish this repository to Nostr") - .on_click(cx.listener(|this, _event, window, cx| { - this.open_init_dialog(window, cx); - })), - ), - ) - .child(self.render_header_tabs(cx)) - .into_any_element() - } - - /// Open the dialog guiding the user through publishing the local repository to NIP-34. - fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(local_path) = self.local_path.clone() else { - return; - }; - let view = cx.entity().downgrade(); - init_dialog::open(local_path, view, window, cx); - } - - /// Switch the repository into its NIP-34 mode after a successful init. - /// Creates the nostr store for the announced repository. - /// Drops the local scan identity. - /// The worktree is unchanged, so the explorer keeps its loaded content. - pub(crate) fn apply_announcement( - &mut self, - announcement: Announcement, - cx: &mut Context, - ) { - // The repository is no longer a bare local repo. - // Drop it from the scan results so it leaves the sidebar's local section. - if let Some(path) = self.local_path.take() { - LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); - } - let store = - cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); - // Re-render on store refreshes, issues, PRs and statuses. - // Keep the ready-to-contribute statuses of this repository requested. - self.attach_store(&store, cx); - self.store = Some(store); - self.initial = Some(announcement); - cx.notify(); - } - - /// Observe the repository's store, re-render on refreshes. - /// Request the ready-to-contribute statuses for it. - fn attach_store(&mut self, store: &Entity, cx: &mut Context) { - self._subscriptions - .push(cx.observe(store, |this, _store, cx| { - this.refresh_ready_statuses(cx); - cx.notify(); - })); - self.refresh_ready_statuses(cx); - } - - /// Request the statuses of this repository again when the announced HEAD changes. - /// The HEAD is the base the checkouts are compared against. - /// Owned repositories are watched for unpushed commits. - /// Other repositories for ready-to-contribute checkouts. - fn refresh_ready_statuses(&mut self, cx: &mut Context) { - let Some(entity) = self.store.clone() else { - return; - }; - - let head = entity.read(cx).head.clone(); - - if self.ready_requested && self.ready_head == head { - return; - } - - self.ready_requested = true; - self.ready_head = head.clone(); - - let addr = entity.read(cx).addr().clone(); - let backend = Backend::global(cx); - let checkout = CheckoutsStore::global(cx); - - let owned = backend - .read(cx) - .current_user() - .is_some_and(|user| entity.read(cx).is_author(&user)); - - checkout.update(cx, |store, cx| { - // The ready statuses keep the fast poll running while the panel is open. - // The sidebar's push watch alone polls slower. - store.request_statuses(&addr, head, cx); - - if owned { - store.request_push_statuses(&addr, cx); - } - }); - } - - /// The ready-to-push statuses of this repository in the global checkouts - /// store changed since they last drove a render. - /// - /// Updates the cached slices. `None` store (a local, not yet published, - /// repository) has no statuses. - fn refresh_statuses(&mut self, cx: &mut Context) -> bool { - let Some(entity) = self.store.clone() else { - return false; - }; - let addr = entity.read(cx).addr().clone(); - let checkouts = CheckoutsStore::global(cx).read(cx); - let ready_statuses = checkouts.ready_statuses_of(&addr); - let push_statuses = checkouts.push_statuses_of(&addr); - - let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses; - self.ready_statuses = ready_statuses; - self.push_statuses = push_statuses; - changed - } - - /// The first checkout ready for a pull request on this repository. - /// Not covered by an open PR of the signed-in user. - /// Not dismissed in this panel. - /// The repository's own checkouts are not suggested here. - /// Their work is pushed, see [`Self::push_suggestion`]. - fn ready_suggestion(&self, cx: &App) -> Option { - let store = self.store.as_ref()?; - let addr = store.read(cx).addr().clone(); - let user = Backend::global(cx).read(cx).current_user()?; - if store.read(cx).is_author(&user) { - return None; - } - - let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr); - - 'status: for status in statuses { - if self - .banner_dismissed - .contains(&(status.path.clone(), status.branch.clone())) - { - continue; - } - let store = store.read(cx); - for pr in &store.pull_requests { - if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) - { - continue 'status; - } - } - return Some(status); - } - - None - } - - /// The first checkout of this owned repository with unpushed commits. - /// - /// Not dismissed in this panel. - fn push_suggestion(&self, cx: &App) -> Option { - let entity = self.store.as_ref()?; - let user = Backend::global(cx).read(cx).current_user()?; - - if !entity.read(cx).is_author(&user) { - return None; - } - - let addr = entity.read(cx).addr().clone(); - let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr); - - statuses.into_iter().find(|status| { - !self - .banner_dismissed - .contains(&(status.path.clone(), status.branch.clone())) - }) - } - - /// The ready-to-push banner of an owned repository. - /// - /// A local checkout has unpushed commits, with a Push action and a dismiss control. - fn render_push_banner(&self, cx: &Context) -> Option { - 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") - } else { - SharedString::from(format!("{} commits", status.ahead)) - }; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_center() - .justify_between() - .bg(cx.theme().muted) - .child( - 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("has") - .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("ready to push"), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("push-checkout-banner") - .icon(IconName::ArrowUp) - .label("Push") - .small() - .info() - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(move |this, _event, window, cx| { - this.push_unpushed_checkout(path.clone(), window, cx); - })), - ) - .child( - Button::new("close-repo") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .disabled(pushing) - .on_click(cx.listener(move |this, _ev, _window, cx| { - this.banner_dismissed.insert(key.clone()); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - /// Warning after a push that only some grasp servers accepted. - fn render_push_warning_banner(&self, cx: &Context) -> Option { - let store = self.store.as_ref()?; - let store = store.read(cx); - let warning = store.last_push_warning.clone()?; - let pushing = store.pushing; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_start() - .justify_between() - .bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08)) - .child( - h_flex() - .gap_2() - .min_w_0() - .flex_1() - .items_start() - .child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0()) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_color(cx.theme().warning) - .child(SharedString::from(warning)), - ), - ) - .child( - h_flex() - .gap_1() - .flex_shrink_0() - .child( - Button::new("republish-after-partial-push") - .icon(CustomIconName::Init) - .label("Republish") - .small() - .info() - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(|this, _event, window, cx| { - this.push_repository(window, cx); - })), - ) - .child( - Button::new("dismiss-push-warning") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .disabled(pushing) - .on_click(cx.listener(|this, _ev, _window, cx| { - if let Some(store) = this.store.clone() { - store.update(cx, |store, _| { - store.last_push_warning = None; - }); - } - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - /// The ready-to-contribute banner of the repository panel. - fn render_ready_banner(&self, cx: &Context) -> Option { - let status = self.ready_suggestion(cx)?; - let key = (status.path.clone(), status.branch.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() - .w_full() - .items_center() - .justify_between() - .bg(cx.theme().muted) - .child( - 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( - 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(), - ) - } - - /// The tab row shared by both header variants. - /// Files and Commits tabs, the HEAD commit button and the branch/tag selectors. - fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { - let commits_count = self.all_commits.as_ref().map(|list| list.total); - let worktree_empty = self.switching_ref || self.worktree.is_none(); - - h_flex() - .items_center() - .gap_2() - .child( - BaseButton::new("files-tab") - .flex() - .items_center() - .h_8() - .px_2() - .gap_2() - .child( - h_flex() - .gap_1() - .text_sm() - .child(Icon::new(CustomIconName::GitFile).small()) - .child("Files"), - ) - .text_color(cx.theme().button_foreground) - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().button_hover)) - .active(|this| this.bg(cx.theme().button_active)) - .selected(self.active_tab == 0) - .when(self.active_tab == 0, |this| { - this.bg(cx.theme().button_active) - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.active_tab = 0; - cx.notify(); - })), - ) - .child( - BaseButton::new("commits-tab") - .flex() - .items_center() - .h_8() - .px_2() - .gap_2() - .child( - h_flex() - .gap_1() - .text_sm() - .child(Icon::new(CustomIconName::GitCommit).small()) - .child("Commits"), - ) - .when_some(commits_count, |this, count| { - this.child(CountBadge::new(count)) - }) - .text_color(cx.theme().button_foreground) - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().button_hover)) - .active(|this| this.bg(cx.theme().button_active)) - .selected(self.active_tab == 1) - .when(self.active_tab == 1, |this| { - this.bg(cx.theme().button_active) - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.active_tab = 1; - cx.notify(); - })), - ) - .child( - h_flex() - .flex_1() - .gap_2() - .justify_end() - .child( - Button::new("enc") - .ghost() - .when_some(self.head_commit.as_ref(), |this, commit| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(&commit.id)), - ) - .child( - div() - .max_w(px(200.)) - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .text_xs() - .child(SharedString::from(&commit.summary)), - ) - }) - .tooltip( - self.head_commit - .as_ref() - .map_or_else(SharedString::default, |commit| { - commit.summary.clone().into() - }), - ) - .on_click(cx.listener(|this, _event, window, cx| { - if let Some(commit) = &this.head_commit { - let id = commit.id.clone(); - this.open_commit_diff(&id, window, cx); - } - })), - ) - .child( - div().w(px(120.)).child( - Combobox::new(&self.branch_select) - .placeholder("Branch") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - ref_selector_trigger(ctx, CustomIconName::GitBranch, cx) - }), - ), - ) - .child( - div().w(px(120.)).child( - Combobox::new(&self.tag_select) - .placeholder("Tag") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - ref_selector_trigger(ctx, CustomIconName::Tag, cx) - }), - ), - ), - ) - .into_any_element() - } - - fn render_maintainers(&self, cx: &mut Context) -> AnyElement { - let Some(announcement) = self.announcement(cx) else { - return div().into_any_element(); - }; - let profile_store = ProfileStore::global(cx); - - let mut seen = HashSet::new(); - let rest: Vec<_> = announcement - .maintainers - .iter() - .copied() - .filter(|key| key != &announcement.owner && seen.insert(*key)) - .collect(); - - let owner = profile_store.read(cx).get(&announcement.owner); - let owner_name = owner.name(); - let owner_picture = owner.picture(); - - h_flex() - .w_full() - .gap_3() - .child( - Button::new("maintainers").compact().ghost().child( - h_flex() - .gap_2() - .child( - h_flex() - .gap_1() - .child(UserAvatar::new(owner_name.clone()).picture(owner_picture)) - .child(div().text_xs().whitespace_nowrap().child(owner_name)), - ) - .when(!rest.is_empty(), |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!("+{}", rest.len()))), - ) - }), - ), - ) - .into_any_element() - } -} - -impl BasePanel for RepoDetailView { - fn panel_name(&self) -> &'static str { - "repo_detail" - } -} - -impl Panel for RepoDetailView { - fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.display_name(cx) - } -} - -impl EventEmitter for RepoDetailView {} - -impl Focusable for RepoDetailView { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for RepoDetailView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let tree_state = self.tree_state.clone(); - let view = cx.entity().downgrade(); - - let pane_title = self - .selected_file - .clone() - .or_else(|| self.readme_name.clone()) - .unwrap_or_else(|| "Overview".into()); - - let banner = self - .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)) - .when_some(self.render_push_warning_banner(cx), |this, banner| { - this.child(banner) - }) - .child(self.render_header(cx)) - .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(); - })), - ) - }) - .map(|this| match self.active_tab { - 0 => this.child( - h_flex() - .flex_1() - .w_full() - .overflow_hidden() - .child(Self::render_tree_column(tree_state, view, cx)) - .child(self.render_content_column(pane_title, cx)) - .into_any_element(), - ), - _ => this.child(self.render_commits_tab(cx)), - }) - } -} - -/// Read the worktree state of `repo`, no network. -/// -/// Entries, README, refs and HEAD commit. -fn load_repo_data(repo: &Repository) -> Result { - let entries = signed_git::worktree_entries(repo)?; - let tree = build_tree_items(&entries); - let readme_path = signed_git::find_readme(repo)?; - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(repo, path)?, - None => None, - }; - let worktree = repo.workdir().map(Path::to_path_buf); - // Ref listing is auxiliary UI. - // A broken ref must not prevent the explorer from loading. - // Failures degrade to empty selectors. - let (branches, tags, current_branch) = match &worktree { - Some(_) => ( - signed_git::repo_branches(repo).unwrap_or_default(), - signed_git::repo_tags(repo).unwrap_or_default(), - signed_git::current_branch(repo).unwrap_or(None), - ), - None => (Vec::new(), Vec::new(), None), - }; - let head_commit = signed_git::head_commit(repo).unwrap_or(None); - - Ok(RepoData { - tree, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - }) -} - -/// The `nostr://...` clone URL of an announcement, NIP-34. -fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { - let owner = announcement.owner; - let user = nip05 - .map(str::to_owned) - .unwrap_or_else(|| owner.to_bech32().unwrap()); - - let mut url = format!("nostr://{user}"); - if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { - url.push('/'); - url.push_str(hint); - } - url.push('/'); - url.push_str(&announcement.id); - - SharedString::from(url) -} - -/// The forked-from row of the detail header. -/// -/// Clickable link to the upstream repository when the `u` tag references a NIP-34 repo. -/// Plain text when it only carries a git URL. -fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { - let upstream = announcement.upstream.as_ref()?; - - let (label, clickable) = match &upstream.addr { - Some(addr) => { - // Prefer the upstream's display name when its announcement is known locally. - // Fall back to its repository id otherwise. - let name = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == *addr) - .map(|a| { - a.name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(a.id.clone())) - }) - .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); - (SharedString::from(format!("Forked from {name}")), true) - } - None => (SharedString::from(upstream.display().as_str()), false), - }; - - let row = h_flex() - .gap_1() - .items_center() - .min_w_0() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(CustomIconName::GitBranch).small()) - .child(div().whitespace_nowrap().text_ellipsis().child(label)); - - Some(if clickable { - row.id("fork-upstream") - .cursor_pointer() - .hover(|this| this.text_color(cx.theme().foreground)) - .on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx))) - .into_any_element() - } else { - row.into_any_element() - }) -} - -/// Open `announcement` as a repository panel in the dock's center. -pub(crate) fn open_repo_panel( - dock_area: &WeakEntity, - announcement: &Announcement, - window: &mut Window, - cx: &mut App, -) -> Entity { - let detail = - cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx)); - - if let Some(dock_area) = dock_area.upgrade() { - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(detail.clone()), window, cx); - }); - } - - detail -} - -/// The nostr store of `announcement`'s repository, without opening a repository panel. -fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity { - cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)) -} - -/// An item of a repository to open from outside its detail panel. -/// A patch has no detail view in Signed, so it opens nothing. -pub(crate) enum RepoItem { - Issue(EventId), - PullRequest(EventId), - Patch, -} - -/// Open the detail panel of `item` in `announcement`'s repository, in the dock's center. -/// -/// The repository store is built here, not taken from a `RepoDetailView`, so the -/// item panel is the only panel docked. -/// -/// A patch opens nothing: patches are only consumed inside a pull request's -/// detail panel, and have no panel of their own. -pub(crate) fn open_repo_item( - dock_area: &WeakEntity, - announcement: &Announcement, - item: RepoItem, - window: &mut Window, - cx: &mut App, -) { - let panel: Arc = - match item { - RepoItem::Issue(issue_id) => { - let store = repo_store(announcement, cx); - panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) - } - RepoItem::PullRequest(pr_id) => { - let store = repo_store(announcement, cx); - panel_handle(cx.new(|cx| { - PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx) - })) - } - RepoItem::Patch => return, - }; - - let Some(dock_area) = dock_area.upgrade() else { - return; - }; - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel, window, cx); - }); -} diff --git a/crates/workspace/src/views/repo_detail/send_patch.rs b/crates/workspace/src/views/send_patch.rs similarity index 100% rename from crates/workspace/src/views/repo_detail/send_patch.rs rename to crates/workspace/src/views/send_patch.rs -- 2.54.0 From a4dcbbc560f5822a92301a550dc5019ddc9dd814 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 11:54:37 +0700 Subject: [PATCH 04/12] refactor --- crates/signed_state/src/repo.rs | 28 +- crates/signed_state/src/repos.rs | 31 +- crates/workspace/src/views/issues/detail.rs | 17 +- crates/workspace/src/views/issues/mod.rs | 115 +++--- .../src/views/pull_requests/detail.rs | 336 +++++++++++------- .../workspace/src/views/pull_requests/mod.rs | 129 ++++--- docs/repo-state-plan.md | 330 +++++++++++++++++ 7 files changed, 719 insertions(+), 267 deletions(-) create mode 100644 docs/repo-state-plan.md diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 854a2a1..206189b 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1,7 +1,6 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::time::Duration; use anyhow::{Error, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; @@ -22,9 +21,6 @@ use crate::git_store::GitStore; use crate::refresh::{RefreshGate, RefreshRequest}; use crate::repos::RepoListStore; -/// Delay between a refresh request and the actual re-query. -const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); - /// Maximum size of one patch event. /// /// NIP-34 suggests patches when each event is under 60kb. @@ -37,6 +33,10 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; pub struct RepoStore { addr: RepoAddr, pub announcement: Option, + /// The first local pass has been applied. + /// + /// Views distinguish "no data yet" from a genuinely empty repository with it. + pub loaded: bool, /// Branch pointed to by `HEAD` in the latest state announcement. pub head: Option, pub issues: Vec, @@ -143,6 +143,7 @@ impl RepoStore { Self { addr, announcement: None, + loaded: false, head: None, issues: Vec::new(), patches: Vec::new(), @@ -228,17 +229,15 @@ impl RepoStore { } /// Re-query the local database and update all fields. + /// + /// Runs immediately. The backend pump already batches the relay events that + /// trigger a refresh, so no per-store debounce is needed. pub fn refresh(&mut self, cx: &mut Context) { if self.refresh.request() != RefreshRequest::Schedule { return; } - cx.spawn(async move |this, cx| { - cx.background_executor().timer(REFRESH_DEBOUNCE).await; - - this.update(cx, |this, cx| this.run_refresh(cx)) - }) - .detach(); + self.run_refresh(cx); } fn run_refresh(&mut self, cx: &mut Context) { @@ -402,10 +401,16 @@ impl RepoStore { // is polled in bursts while a sync is in flight; notifying on // every identical pass would re-render the repository panel // several times for no visible change. + // + // The first pass is the exception: it must notify even when it + // found nothing, so views can leave their loading state and show + // the empty result. + let first_pass = !this.loaded; let head_changed = state .as_ref() .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); - let changed = this.announcement != announcement + let changed = first_pass + || this.announcement != announcement || head_changed || this.issues != issues || this.patches != patches @@ -435,6 +440,7 @@ impl RepoStore { this.status_by_root = status_by_root; this.open_issue_count = open_issue_count; this.open_pr_count = open_pr_count; + this.loaded = true; this.version = this.version.wrapping_add(1); // Comments and statuses without an `a` tag. diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index 4189909..dbebf58 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -117,11 +117,6 @@ impl LocalReposStore { } } -/// Delay between a refresh request and the actual re-query. -/// -/// Bursts of events, e.g. sync progress ticks, collapse into one query. -const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); - /// How far back activity events count toward a repository's last activity. const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400); @@ -220,7 +215,7 @@ impl RepoListStore { cx.defer(move |cx| { weak.update(cx, |this, cx| { this.subscribe_remote(cx); - this.refresh_initial(cx); + this.refresh(cx); }) .ok(); }); @@ -254,33 +249,19 @@ impl RepoListStore { }); } - /// One-shot initial load. - /// - /// Query the local database immediately, no debounce. - /// Stored announcements appear as soon as the app opens. - fn refresh_initial(&mut self, cx: &mut Context) { - debug_assert!(!self.refresh.debouncing()); - if self.refresh.running() { - self.refresh.request(); - return; - } - self.run_refresh(cx); - } - /// Re-query the local database. + /// + /// Runs immediately. The backend pump already batches the relay events that + /// trigger a refresh, so no per-store debounce is needed. pub fn refresh(&mut self, cx: &mut Context) { if self.refresh.request() != RefreshRequest::Schedule { return; } - cx.spawn(async move |this, cx| { - cx.background_executor().timer(REFRESH_DEBOUNCE).await; - this.update(cx, |this, cx| this.run_refresh(cx)) - }) - .detach(); + self.run_refresh(cx); } - /// One query and apply cycle, the debounced entry point. + /// One query and apply cycle, the refresh entry point. fn run_refresh(&mut self, cx: &mut Context) { self.refresh.begin(); diff --git a/crates/workspace/src/views/issues/detail.rs b/crates/workspace/src/views/issues/detail.rs index d6911f4..c80a69a 100644 --- a/crates/workspace/src/views/issues/detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -1,8 +1,8 @@ use dock::{BasePanel, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ - App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div, - relative, + App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription, + Window, div, relative, }; use gpui_component::input::TextareaState; use gpui_component::scroll::ScrollableElement; @@ -17,12 +17,13 @@ use crate::views::repo::helpers::{comment_form, comments_section, issue_roots, s /// Detail panel of a single issue. pub struct IssueDetailView { + focus_handle: FocusHandle, /// Repo store holding the issues and their statuses. store: Entity, issue_id: EventId, /// Input state of the comment textarea. comment_input: Entity, - focus_handle: FocusHandle, + _subscription: Subscription, } impl IssueDetailView { @@ -35,11 +36,14 @@ impl IssueDetailView { let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); + let subscription = cx.observe(&store, |_this, _store, cx| cx.notify()); + Self { focus_handle: cx.focus_handle(), store, issue_id, comment_input, + _subscription: subscription, } } } @@ -81,7 +85,12 @@ impl Render for IssueDetailView { let store = self.store.read(cx); let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else { - return placeholder("Issue not found", cx); + // The store has not applied its first pass yet, the issue may still arrive. + return if store.loaded { + placeholder("Issue not found", cx) + } else { + placeholder("Loading issue...", cx) + }; }; let (title, author, picture, status, age, issue_id, content) = { diff --git a/crates/workspace/src/views/issues/mod.rs b/crates/workspace/src/views/issues/mod.rs index 6aceaec..311487e 100644 --- a/crates/workspace/src/views/issues/mod.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -4,8 +4,8 @@ use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, WeakEntity, Window, div, px, size, + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, + Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; @@ -62,27 +62,38 @@ pub struct IssuesView { filter: IssueFilter, /// Per-row heights of the virtual list. item_sizes: Rc>>, - /// The filtered issue count [`Self::item_sizes`] was built for. - issue_len: usize, /// Indices into the store's `issues` matching [`Self::filter`]. visible_issues: Vec, /// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`]. counts: (usize, usize, usize), - /// Store version and filter the cached rows/counts were built from. - cache_key: Option<(u64, IssueFilter)>, + /// Filter [`Self::visible_issues`] was last rebuilt for. + /// + /// A filter change notifies even when the visible rows are unchanged, + /// e.g. switching between two empty filters. + synced_filter: IssueFilter, /// Virtual list state of the issues list. scroll_handle: VirtualListScrollHandle, + /// Rebuild the rows and re-render when the store's data changes. + _subscription: Subscription, } impl IssuesView { pub fn new( dock_area: WeakEntity, store: Entity, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Self { let repo_name = store.read(cx).name(); + let subscription = cx.observe(&store, |this, _store, cx| { + this.rebuild(cx); + }); + + cx.defer_in(window, |this, _window, cx| { + this.rebuild(cx); + }); + Self { focus_handle: cx.focus_handle(), dock_area, @@ -90,14 +101,60 @@ impl IssuesView { repo_name, filter: IssueFilter::Open, item_sizes: Rc::new(Vec::new()), - issue_len: 0, visible_issues: Vec::new(), counts: (0, 0, 0), - cache_key: None, + synced_filter: IssueFilter::Open, scroll_handle: VirtualListScrollHandle::new(), + _subscription: subscription, } } + fn rebuild(&mut self, cx: &mut Context) { + let filter = self.filter; + + let (visible_issues, counts) = { + let store = self.store.read(cx); + let mut counts = (0usize, 0usize, 0usize); + + let visible_issues: Vec = store + .issues + .iter() + .enumerate() + .filter_map(|(ix, issue)| { + let status = store.status_of(issue); + counts.0 += 1; + match status { + RepoStatus::Open => counts.1 += 1, + RepoStatus::Closed => counts.2 += 1, + RepoStatus::Draft | RepoStatus::Applied => {} + } + filter.matches(status).then_some(ix) + }) + .collect(); + + (visible_issues, counts) + }; + + let filter_changed = self.synced_filter != filter; + let visible_issues_changed = self.visible_issues != visible_issues; + let counts_changed = self.counts != counts; + + if !filter_changed && !visible_issues_changed && !counts_changed { + return; + } + + self.item_sizes = Rc::new(vec![ + size(px(0.), px(ISSUE_ROW_HEIGHT)); + visible_issues.len() + ]); + + self.synced_filter = filter; + self.visible_issues = visible_issues; + self.counts = counts; + + cx.notify(); + } + /// Open the detail panel of `issue_id` in the dock area. fn open_issue_detail( &mut self, @@ -199,7 +256,7 @@ impl IssuesView { .selected(self.filter == IssueFilter::All) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = IssueFilter::All; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -209,7 +266,7 @@ impl IssuesView { .selected(self.filter == IssueFilter::Open) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = IssueFilter::Open; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -219,7 +276,7 @@ impl IssuesView { .selected(self.filter == IssueFilter::Closed) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = IssueFilter::Closed; - cx.notify(); + this.rebuild(cx); })), ), ) @@ -324,41 +381,7 @@ impl Focusable for IssuesView { impl Render for IssuesView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let filter = self.filter; - - // Rows and counts are rebuilt only when the store refreshed or filter changed. - let version = self.store.read(cx).version(); - - if self.cache_key != Some((version, filter)) { - let store = self.store.read(cx); - let mut counts = (0usize, 0usize, 0usize); - self.visible_issues = store - .issues - .iter() - .enumerate() - .filter_map(|(ix, issue)| { - let status = store.status_of(issue); - counts.0 += 1; - match status { - RepoStatus::Open => counts.1 += 1, - RepoStatus::Closed => counts.2 += 1, - RepoStatus::Draft | RepoStatus::Applied => {} - } - filter.matches(status).then_some(ix) - }) - .collect(); - self.counts = counts; - self.cache_key = Some((version, filter)); - } - let count = self.visible_issues.len(); - - // The virtual list's item count comes from `item_sizes`. - // Rebuild it whenever the filtered issue count changes. - if count != self.issue_len { - self.issue_len = count; - self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]); - } - let sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index 8439a3b..f4d7c4d 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -6,7 +6,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, WeakEntity, Window, div, px, relative, size, + SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; @@ -20,9 +20,9 @@ use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; -use nostr::prelude::{Event, EventId, Kind}; +use nostr::prelude::{Event, EventId, Kind, Url}; use signed_core::{ - activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update, + RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update, merge_base_of, pull_request_patch, }; use signed_git::{FileCommit, patch_commits, patch_diffs}; @@ -36,6 +36,23 @@ use crate::views::repo::helpers::{comment_form, comments_section, pr_roots, side /// Height of one commit row in the commits tab's virtual list. const ROW_HEIGHT: f32 = 37.; +/// Shown once the store's first pass is applied and the root PR is still absent. +const NOT_FOUND: &str = "Pull request not found"; + +/// Root PR inputs one diff load is keyed to. +/// +/// A store refresh re-binds the panel, and reloads only when these change. +#[derive(Clone, PartialEq, Eq)] +struct PrBinding { + description: String, + patch: String, + tip: Option, + base: Option, + clone_urls: Vec, + addr: RepoAddr, + has_patch_link: bool, +} + /// Detail panel of a single pull request. pub struct PullRequestDetailView { focus_handle: FocusHandle, @@ -61,6 +78,10 @@ pub struct PullRequestDetailView { /// The patch is being parsed on a background task. loading: bool, error: Option, + /// Root PR inputs the in-flight diff load was started for. + bound: Option, + /// Generation of the in-flight diff load. Stale results are discarded. + load_generation: u64, /// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits. active_tab: usize, /// Changed-files explorer and per-file diff, like the commit and compare views. @@ -69,6 +90,10 @@ pub struct PullRequestDetailView { commit_item_sizes: Rc>>, /// Virtual list state of the commits tab. commit_scroll_handle: VirtualListScrollHandle, + /// Re-render when the store's first pass or a later refresh lands. + /// Item panels are cached by the dock, so without this observer a panel + /// opened before the store loaded would stay on its placeholder. + _subscription: Subscription, } impl PullRequestDetailView { @@ -85,9 +110,11 @@ impl PullRequestDetailView { let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); + let subscription = cx.observe(&store, |this, _store, cx| this.sync(cx)); + // Defer loading until the window is ready, like the commit diff view. - cx.defer_in(window, |this, window, cx| { - this.load(window, cx); + cx.defer_in(window, |this, _window, cx| { + this.sync(cx); }); Self { @@ -103,156 +130,215 @@ impl PullRequestDetailView { commits: Vec::new(), loading: true, error: None, + bound: None, + load_generation: 0, active_tab: 0, pane, commit_item_sizes: Rc::new(Vec::new()), commit_scroll_handle: VirtualListScrollHandle::new(), + _subscription: subscription, } } - /// Snapshot the PR events from the store. - fn load(&mut self, window: &mut Window, cx: &mut Context) { + /// Snapshot the root PR from the store and reload the diff when it changed. + /// + /// Re-runs on construction and on every store refresh. Item panels are + /// cached by the dock, so this is the only way a panel opened before the + /// store's first pass learns about its PR. + fn sync(&mut self, cx: &mut Context) { + let loaded = self.store.read(cx).loaded; + + let binding = { + let store = self.store.read(cx); + + store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + .map(|root| { + let update = latest_update(store.pull_requests.iter(), root); + + let tip = update + .and_then(current_commit_of) + .or_else(|| current_commit_of(root)); + + let base = update + .and_then(merge_base_of) + .or_else(|| merge_base_of(root)); + + let clone_urls = clone_urls_of(root) + .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) + .unwrap_or_default(); + + PrBinding { + description: root.content.clone(), + patch: pull_request_patch(root, store.patches.iter()), + tip, + base, + clone_urls, + addr: store.addr().clone(), + has_patch_link: root.tags.event_ids().next().is_some(), + } + }) + }; + + let Some(binding) = binding else { + self.sync_missing(loaded, cx); + return; + }; + + if self.bound.as_ref() == Some(&binding) { + return; + } + + self.bound = Some(binding.clone()); + self.load_diff(binding, cx); + } + + /// The store does not hold the root PR yet, or at all. + /// + /// Loading until the first pass is applied, not found afterwards. + fn sync_missing(&mut self, loaded: bool, cx: &mut Context) { + self.bound = None; + + if !loaded { + if !self.loading || self.error.is_some() { + self.loading = true; + self.error = None; + cx.notify(); + } + return; + } + + if self.error.as_deref() != Some(NOT_FOUND) { + self.loading = false; + self.error = Some(NOT_FOUND.into()); + cx.notify(); + } + } + + /// Load the bound PR's changed files and commits. + /// + /// Nostr-backed pull requests parse the patch series, git-backed ones fetch + /// the clone and diff the `merge-base..tip` range. + fn load_diff(&mut self, binding: PrBinding, cx: &mut Context) { self.loading = true; self.error = None; + self.description = binding.description.clone().into(); + self.current_commit = binding.tip.clone().map(SharedString::from); cx.notify(); let cache = GitStore::global(cx).cache().clone(); - let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = { - let store = self.store.read(cx); + self.load_generation = self.load_generation.wrapping_add(1); + let generation = self.load_generation; - let Some(root) = store - .pull_requests - .iter() - .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) - else { - self.loading = false; - self.error = Some("Pull request not found".into()); - cx.notify(); - return; + let PrBinding { + patch, + tip, + base, + clone_urls, + addr, + has_patch_link, + .. + } = binding; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let nostr_diff = cx + .background_spawn({ + let patch = patch.clone(); + async move { patch_diffs(&patch) } + }) + .await; + + let nostr_commits = cx + .background_spawn({ + let patch = patch.clone(); + async move { patch_commits(&patch) } + }) + .await; + + // PRs without patch events, e.g. published by ngit, carry their changes in git. + // Fetch the clone and diff the `merge-base..tip` range. + let use_nostr = match &nostr_diff { + Ok(diff) => has_patch_link || !diff.files.is_empty(), + Err(_) => true, }; - let update = latest_update(store.pull_requests.iter(), root); + let git = if use_nostr { + None + } else { + let cache = cache.clone(); + let addr = addr.clone(); + let clone_urls = clone_urls.clone(); + let base = base.clone(); + let tip = tip.clone(); - let tip = update - .and_then(current_commit_of) - .or_else(|| current_commit_of(root)); + Some( + cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; - let base = update - .and_then(merge_base_of) - .or_else(|| merge_base_of(root)); + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? + .to_path_buf(); - let clone_urls = clone_urls_of(root) - .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())); + let tip = + tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; - ( - root.content.clone(), - pull_request_patch(root, store.patches.iter()), - tip, - base, - clone_urls.unwrap_or_default(), - store.addr().clone(), - root.tags.event_ids().next().is_some(), - ) - }; + let base = match base { + Some(base) => base, + // No `merge-base` tag. Use the merge base of the tip and the default branch. + None => { + let head = repo + .head_id() + .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; + let tip_id = repo.rev_parse_single(tip.as_bytes())?; + repo.merge_base(tip_id, head)?.to_string() + } + }; - self.description = description.into(); + let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; + let commits = + signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; - let task: gpui::Task> = - cx.spawn_in(window, async move |this, cx| { - let nostr_diff = cx - .background_spawn({ - let patch = patch.clone(); - async move { patch_diffs(&patch) } + Ok::<_, anyhow::Error>((diff, commits, workdir)) }) - .await; + .await, + ) + }; - let nostr_commits = cx - .background_spawn({ - let patch = patch.clone(); - async move { patch_commits(&patch) } - }) - .await; + let (diff, commits, worktree) = match git { + Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)), + Some(Err(error)) => (Err(error), Vec::new(), None), + None => (nostr_diff, nostr_commits, None), + }; - // PRs without patch events, e.g. published by ngit, carry their changes in git. - // Fetch the clone and diff the `merge-base..tip` range. - let use_nostr = match &nostr_diff { - Ok(diff) => has_patch_link || !diff.files.is_empty(), - Err(_) => true, - }; + this.update(cx, |this, cx| { + // A newer binding superseded this load. + if this.load_generation != generation { + return; + } - let git = if use_nostr { - None - } else { - let cache = cache.clone(); - let addr = addr.clone(); - let clone_urls = clone_urls.clone(); - let base = merge_base.clone(); - let tip = current_commit.clone(); + this.loading = false; + this.worktree = worktree; + this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]); + this.commits = commits; - Some( - cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? - .to_path_buf(); - - let tip = tip - .ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; - - let base = match base { - Some(base) => base, - // No `merge-base` tag. Use the merge base of the tip and the default branch. - None => { - let head = repo - .head_id() - .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - repo.merge_base(tip_id, head)?.to_string() - } - }; - - let diff = - signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; - let commits = - signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; - - Ok::<_, anyhow::Error>((diff, commits, workdir)) - }) - .await, - ) - }; - - let (diff, commits, worktree) = match git { - Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)), - Some(Err(error)) => (Err(error), Vec::new(), None), - None => (nostr_diff, nostr_commits, None), - }; - - this.update_in(cx, |this, _window, cx| { - this.loading = false; - this.worktree = worktree; - this.current_commit = current_commit.map(SharedString::from); - this.commit_item_sizes = - Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]); - this.commits = commits; - - match diff { - Ok(diff) => { - this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); - } - Err(error) => { - this.error = Some(error.to_string().into()); - } + match diff { + Ok(diff) => { + this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); } + Err(error) => { + this.error = Some(error.to_string().into()); + } + } - cx.notify(); - })?; + cx.notify(); + })?; - Ok(()) - }); + Ok(()) + }); task.detach(); } diff --git a/crates/workspace/src/views/pull_requests/mod.rs b/crates/workspace/src/views/pull_requests/mod.rs index e32bdda..4af7ce9 100644 --- a/crates/workspace/src/views/pull_requests/mod.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -4,8 +4,8 @@ use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, WeakEntity, Window, div, px, size, + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, + Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; use gpui_base::Button as BaseButton; use gpui_component::alert::Alert; @@ -70,27 +70,38 @@ pub struct PullRequestsView { filter: PullRequestFilter, /// Per-row heights of the virtual list. item_sizes: Rc>>, - /// The filtered pull request count [`Self::item_sizes`] was built for. - pr_len: usize, /// Indices into the store's `pull_requests` matching [`Self::filter`]. visible_prs: Vec, /// Header counts `(total, open, closed, draft, merged)`. counts: (usize, usize, usize, usize, usize), - /// Store version and filter the cached rows/counts were built from. - cache_key: Option<(u64, PullRequestFilter)>, + /// Filter [`Self::visible_prs`] was last rebuilt for. + /// + /// A filter change notifies even when the visible rows are unchanged, + /// e.g. switching between two empty filters. + synced_filter: PullRequestFilter, /// Virtual list state of the pull requests list. scroll_handle: VirtualListScrollHandle, + /// Rebuild the rows and re-render when the store's data changes. + _subscription: Subscription, } impl PullRequestsView { pub fn new( dock_area: WeakEntity, store: Entity, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Self { let repo_name = store.read(cx).name(); + let subscription = cx.observe(&store, |this, _store, cx| { + this.rebuild(cx); + }); + + cx.defer_in(window, |this, _window, cx| { + this.rebuild(cx); + }); + Self { focus_handle: cx.focus_handle(), dock_area, @@ -98,14 +109,62 @@ impl PullRequestsView { repo_name, filter: PullRequestFilter::Open, item_sizes: Rc::new(Vec::new()), - pr_len: 0, visible_prs: Vec::new(), counts: (0, 0, 0, 0, 0), - cache_key: None, + synced_filter: PullRequestFilter::Open, scroll_handle: VirtualListScrollHandle::new(), + _subscription: subscription, } } + /// Rebuild the visible rows, header counts and virtual-list sizes. + fn rebuild(&mut self, cx: &mut Context) { + let filter = self.filter; + + let (visible_prs, counts) = { + let store = self.store.read(cx); + let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize); + + let visible_prs: Vec = store + .pull_requests + .iter() + .enumerate() + .filter_map(|(ix, pr)| { + if pr.kind != Kind::GitPullRequest { + return None; + } + + let status = store.status_of(pr); + counts.0 += 1; + + match status { + RepoStatus::Open => counts.1 += 1, + RepoStatus::Closed => counts.2 += 1, + RepoStatus::Draft => counts.3 += 1, + RepoStatus::Applied => counts.4 += 1, + } + + filter.matches(status).then_some(ix) + }) + .collect(); + + (visible_prs, counts) + }; + + let filter_changed = self.synced_filter != filter; + + if !filter_changed && self.visible_prs == visible_prs && self.counts == counts { + return; + } + + self.synced_filter = filter; + self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); visible_prs.len()]); + self.visible_prs = visible_prs; + self.counts = counts; + + cx.notify(); + } + /// Open the detail panel of `pr_id` in the dock area. fn open_pull_request_detail( &mut self, @@ -220,7 +279,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::All) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::All; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -230,7 +289,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::Open) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Open; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -240,7 +299,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::Closed) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Closed; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -250,7 +309,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::Draft) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Draft; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -260,7 +319,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::Merged) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Merged; - cx.notify(); + this.rebuild(cx); })), ), ) @@ -333,49 +392,7 @@ impl Focusable for PullRequestsView { impl Render for PullRequestsView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let filter = self.filter; - - // Rows and counts are rebuilt only when the store refreshed or filter changed. - let version = self.store.read(cx).version(); - - if self.cache_key != Some((version, filter)) { - let store = self.store.read(cx); - let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize); - self.visible_prs = store - .pull_requests - .iter() - .enumerate() - .filter_map(|(ix, pr)| { - if pr.kind != Kind::GitPullRequest { - return None; - } - - let status = store.status_of(pr); - counts.0 += 1; - - match status { - RepoStatus::Open => counts.1 += 1, - RepoStatus::Closed => counts.2 += 1, - RepoStatus::Draft => counts.3 += 1, - RepoStatus::Applied => counts.4 += 1, - } - - filter.matches(status).then_some(ix) - }) - .collect(); - - self.counts = counts; - self.cache_key = Some((version, filter)); - } - let count = self.visible_prs.len(); - - // The virtual list's item count comes from `item_sizes`. - // Rebuild it whenever the filtered pull request count changes. - if count != self.pr_len { - self.pr_len = count; - self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]); - } - let sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); let view = cx.entity().clone(); diff --git a/docs/repo-state-plan.md b/docs/repo-state-plan.md new file mode 100644 index 0000000..bd4deee --- /dev/null +++ b/docs/repo-state-plan.md @@ -0,0 +1,330 @@ +# Repository state and panel flow plan + +Status: proposed (2026-09-13) + +Builds on `docs/backend-rearchitecture.md`, especially §7 (notify audit), +§11 (split independently-observed state), §12 (one debounce at the source) +and §13 (merge the repo listing files, not the entities). + +Scope: `signed_state::{repo, repos}`, `workspace::views::{repo, issues, +pull_requests, inbox}`. + +## Goal + +1. Open an issue or PR directly, from any surface (inbox entry, notification, + future deep link), without walking Explore -> repository panel first. +2. One per-repository entity that spans both identities: the local git + repository and the NIP-34 announcement, instead of today's separate + `LocalReposStore` entry / `Option` + `Option>` + view state. + +## What the code does today (verified against the current tree) + +### 1. Item panels are cached and never observe their store + +The dock renders the active panel through `panel.cached(...)` +(`crates/dock/src/tab_panel.rs:777-795`), so a panel re-renders only when its +own entity notifies. Cross-entity reads are not reactive. + +`RepoDetailView` observes its store (`views/repo/store.rs:35-43`), but the +panels that read the same store do not: + +- `IssueDetailView` (`views/issues/detail.rs:79-85`) renders + `placeholder("Issue not found", cx)` when the issue is absent, with no + observer. When a directly opened store's first pass lands later and notifies, + nothing re-renders the panel: it stays on "Issue not found". +- `PullRequestDetailView::load` runs once from `cx.defer_in` + (`views/pull_requests/detail.rs:88-91`) and caches + `error = "Pull request not found"` when the store is empty + (`:124-133`). It can never recover. +- `IssuesView` / `PullRequestsView` memoize rows behind + `(store.version(), filter)` (`views/issues/mod.rs:324-351`, + `views/pull_requests/mod.rs:334-369`) but nothing re-renders them when the + version changes, so an open list does not see new events either. +- `NewPullRequestView` reads `store.head` / the announcement in `new`, so a + late first pass does not reach its defaults. + +The normal flow hides this because the repository panel is opened first: by +the time the issues list or an item panel is created, the store has already +applied its first pass. Opening an item directly is the case where the store +is still empty at construction. + +### 2. The initial pass waits on a timer the backend already provides + +`RepoStore::new` defers `refresh` (`signed_state/src/repo.rs:130-141`), and +`refresh` always waits `REFRESH_DEBOUNCE` = 300 ms (`repo.rs:25-26`, +`:230-242`) before the local database query. `RepoListStore` has the same +timer (`repos.rs:120-123`, `:270-281`) plus a separate `refresh_initial` +that skips it (`:257-268`). + +Both stores only refresh on `Backend` events: + +- `NostrUpdate` is already batched by the notification pump with its own + `PUMP_DEBOUNCE` = 200 ms (`backend.rs:37`, `:112-166`). Per + `backend-rearchitecture.md` §12, the per-store timers were to be dropped + once the pump absorbed the bursts. +- `Published` and `Synced` are one-off events; `RefreshGate` already folds + them into an in-flight run. + +The timers are therefore pure added latency for these two stores: ~300 ms +(batched updates) to ~500 ms (pump window + store window) before local data +appears. + +### 3. Opening an item requires a hydrated `Announcement` + +`repo_store(announcement)` (`views/repo/actions.rs:216-219`) and the inbox's +lookup in `RepoListStore` (`views/inbox.rs:384-404`) need the announcement in +hand and silently no-op when it is missing (`RepoListStore` not synced yet, +deleted repo, inbox section without a matching list entry). The store itself +can load the announcement from the local database; `run_refresh` already +queries `filters::announcement(&addr)` (`repo.rs:251-261`). + +### 4. Local and announced repositories have no shared identity + +- `LocalReposStore` holds scan paths; `RepoListStore` holds announcements; + `CheckoutsStore` joins them (`checkouts.rs:211-213`, `:569-609`). +- `RepoDetailView` encodes both modes in three option fields: + `initial: Option`, `store: Option>`, + `local_path: Option` (`views/repo/mod.rs:74-85`), with + `apply_announcement` moving between them (`views/repo/store.rs:13-31`). + The invalid combinations and the `initial` fallback in `announcement()` + (`views/repo/mod.rs:322-331`) are the cost of the missing per-repo entity. + +## Design + +### 1. Panels observe, derive into local state, notify on change + +Every panel that reads a store keeps a local snapshot of exactly the slice it +renders, updates it in an observer, and calls `cx.notify()` only when the +slice changed. This is the pattern `RepoDetailView::refresh_statuses` +(`views/repo/store.rs:88-101`) and `SidebarPanel::refresh` already use; the +item panels are missing the observer half. + +| View | Observed entity | Local snapshot | +|---|---|---| +| `IssueDetailView` | `Entity` | root issue, status, comments | +| `PullRequestDetailView` | `Entity` | root PR, description, tip, base, clone urls | +| `IssuesView` | `Entity` | `visible_issues`, `counts`, `item_sizes`, `cache_key` | +| `PullRequestsView` | `Entity` | `visible_prs`, `counts`, `item_sizes`, `cache_key` | +| `NewPullRequestView` | `Entity` | announced head, default base | + +Sketch, matching the existing idiom: + +```rust +// new() +let subscription = cx.observe(&store, |this, store, cx| this.sync(store, cx)); + +// Copy the slice; notify only when it changed. +fn sync(&mut self, store: &Entity, cx: &mut Context) { + let store = store.read(cx); + let issue = store.issues.iter().find(|issue| issue.id == self.issue_id).cloned(); + let comments: Vec = store.comments_of(&self.issue_id).cloned().collect(); + let status = issue.as_ref().map(|issue| store.status_of(issue)); + + if self.issue != issue || self.comments != comments || self.status != status { + self.issue = issue; + self.comments = comments; + self.status = status; + cx.notify(); + } +} +``` + +`PullRequestDetailView` needs its one-shot `load` split in two: + +- `bind` finds the root PR in the store and snapshots description, tip, base + and clone urls. Re-runnable on every store version change. +- `load_diff` does the async patch/git work. Runs once bound, and again when + the bound tip changes (a PR update arriving late). + +### 2. One debounce, at the backend pump + +Delete the `REFRESH_DEBOUNCE` timers from `RepoStore` and `RepoListStore`. +Their triggers all come from `Backend`; the pump batches relay traffic and +`RefreshGate` folds one-off events into an in-flight run. Keep `RefreshGate` +exactly as is, used without a timer: + +```rust +pub fn refresh(&mut self, cx: &mut Context) { + if self.refresh.request() != RefreshRequest::Schedule { + return; + } + self.run_refresh(cx); +} +``` + +`RepoListStore::refresh_initial` collapses into `refresh`; the +`new`-time `cx.defer` call becomes the initial load, with no timer. + +Add `pub loaded: bool` to `RepoStore`, set when the first pass applies. It +separates "no data yet" from "genuinely empty": panels render a loading state +while `!loaded`, and "not found" only after `loaded`. + +`CheckoutsStore` and `InboxView` keep their timers in this plan. Their +refresh request sources are not only the backend pump (checkout requests, +settings and scan observations), so the same argument does not hold +unchanged; revisit separately if measurements show the timers redundant. + +### 3. `RepoStore` is the one per-repository entity + +Keep the name `RepoStore` (`Repository` collides with `gix::Repository`, +already imported in `views/repo/loading.rs`). Shape: + +```rust +pub struct RepoStore { + /// NIP-34 address. `None` while the repository is local-only. + addr: Option, + /// Latest announcement. Seeded from the open-time hint, replaced by the + /// database's latest on the first pass. `None` while local-only. + pub announcement: Option, + /// Local working copy: the scan path for a local repo, an associated + /// checkout for an announced one. A snapshot; `CheckoutsStore` stays the + /// authority for the full list of checkouts. + pub path: Option, + /// The first local pass has been applied. + pub loaded: bool, + // issues, patches, pull_requests, comments, status_by_root, head, flags... + _subscription: Option, +} +``` + +`addr` is required in addition to `announcement`: `(announcement: None, +path: Some(_))` is otherwise ambiguous between "local-only" and "announced, +first pass pending", and the store needs the address to run its query. + +Constructors and the state transition: + +```rust +impl RepoStore { + /// Announced repository. Resolves `path` from `CheckoutsStore` if the + /// user already has a checkout. + pub fn new(addr: RepoAddr, hint: Option, cx: &mut Context) -> Self; + + /// Local repository discovered by the scan, not announced yet. + pub fn new_local(path: PathBuf, cx: &mut Context) -> Self; + + /// Local -> NIP-34 in place. Keeps `path`, so the panel keeps its worktree. + pub fn announce(&mut self, announcement: Announcement, cx: &mut Context); + + pub fn addr(&self) -> Option<&RepoAddr>; +} +``` + +- `new`: subscribes to `Backend`, seeds `announcement` from the hint, defers + remote subscribe/connect, runs the local pass immediately. +- `new_local`: no `Backend` subscription, `loaded = true`, path set. +- `announce`: sets `addr`, `announcement`, keeps `path`; installs the + `Backend` subscription, connects the announced relays and refreshes. Called + from `apply_announcement`, which loses its field surgery. +- Nostr-side actions (`push_repository`, `clone_to_folder`, + `open_issue`/`open_pull_request`, status changes) already have + `action_error("Repository announcement is not loaded yet")` + (`repo.rs:1368-1378`); they now also handle `addr == None` the same way. +- `announce` should also `CheckoutsStore::record(path, addr)` for the scan + path, so the association exists immediately instead of waiting for the + origin/EUC match in `resolve_associations`. Optional, verify behavior. + +`RepoDetailView` then holds `store: Entity` plus explorer state +only. `initial` and `local_path` are deleted; `announcement()` reads the +store; local-mode checks become `store.read(cx).addr().is_none()`; +`load_repo` opens `path` when not announced, and keeps today's cache-mirror +flow for announced repositories. + +### 4. Opening a repository or item needs only a `RepoAddr` + +- `repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx) -> + Entity`. +- `open_repo_item(dock_area, addr: &RepoAddr, item, window, cx)`. +- Inbox passes its already-parsed `address` (`signed_core::InboxItem.address`, + the root event's `a` tag) and drops the `RepoListStore` lookup. +- `open_upstream` drops the 60 x 250 ms wait loop: the store's + `repo_filters` already include the announcement filter and + `subscribe_remote` runs on creation, so the panel opens immediately and + fills in. +- `RepoItem::Patch` behavior is unchanged. + +## Phases + +### Phase 1 - make item panels react to the store (fixes the reported flow) + +Status: implemented, except step 6. + +1. `signed_state/src/repo.rs`: `REFRESH_DEBOUNCE` and the timer spawn are + gone; `refresh` runs immediately. `loaded` was added and is set in the + apply closure. A first pass notifies even when it found nothing, so views + leave their loading state and show the empty result. +2. `signed_state/src/repos.rs`: same timer removal; `refresh_initial` folded + into `refresh`. +3. `views/issues/detail.rs`: observes the store and re-renders; loading + placeholder while `!store.loaded()`. +4. `views/pull_requests/detail.rs`: observes the store; `sync`/`sync_missing` + bind the root and `load_diff` does the async work, keyed to a `PrBinding` + and guarded by a generation so late results are discarded. Loading vs not + found is decided by `store.loaded()`. +5. `views/issues/mod.rs`, `views/pull_requests/mod.rs`: observe the store; + `rebuild` recomputes rows/counts/item sizes and `sync` notifies on change. + Filter buttons call `rebuild` before notifying. +6. `views/pull_requests/new.rs`: not done. Its store-derived inputs are + defaults for the compare/base selectors; re-applying them on a late store + pass would clobber a selection the user already made. Left for a follow-up + once the defaults can be derived without resetting the selectors. + +### Phase 2 - entry points by identity + +1. `views/repo/actions.rs`: `repo_store(addr, hint, cx)`; + `open_repo_item(addr, ...)`; `open_upstream` opens directly. +2. `views/inbox.rs`: pass `address`, delete the announcement lookup and its + silent early-return. + +### Phase 3 - one entity for local and NIP-34 + +1. `signed_state/src/repo.rs`: `addr`/`path` options, `new_local`, + `announce`, `Option`, action guards. +2. `views/repo/mod.rs`: single `store` field; `new`/`new_local`; header, + display name, `load_repo`, `open_init_dialog` derive from the store. +3. `views/repo/store.rs`: always observe; `refresh_statuses` returns false + when not announced. +4. `views/repo/actions.rs`, `header.rs`, `banners.rs`: drop + `Option>` guards, guard on `addr()` instead. +5. `LocalReposStore` stays as the scan index; `CheckoutsStore` stays the + association authority. + +### Phase 4 - deferred, only if duplicate stores become a problem + +One store per address via `HashMap>` inside +`RepoListStore`, so an item panel opened while the repository panel is open +shares the same store and its subscriptions. Not needed for correctness once +Phase 1 lands; each open then loads from the local database immediately. + +## Non-goals + +- No per-store debounce; the pump is the one debounce (§12). `RepoStore` and + `RepoListStore` must not grow timers again. +- No new global store, and no merging `LocalReposStore`, `RepoListStore` + and `CheckoutsStore` into one entity (§13). +- Views never query the database directly; `RepoStore` stays the single + projection so status/comment derivation is not duplicated. +- Explorer state (tree, refs, commits, scroll) stays in `RepoDetailView`. +- No `Repository` rename. + +## Validation + +- `cargo check -p signed_state -p workspace`, then clippy. +- Manual scenarios: + 1. Fresh database, never open Explore: click an issue in the inbox. Panel + shows a loading state, then the issue with comments and status. + 2. Same for a PR, including the patch diff loading once the root binds. + 3. Open the issues list, then receive a new issue (second client or + publish); the row appears without reopening. + 4. Local repo: open from the sidebar, Init, panel keeps the worktree and + gains the nostr header; reopening from Explore shows the same data. + 5. Existing flows: Explore list, ready-to-contribute and ready-to-push + banners, new issue/PR dialogs. + +## Open questions + +1. Should an announced repository with an associated checkout browse that + checkout instead of the cache mirror? Today the panel always mirrors. + `RepoStore.path` makes this a one-line decision later. +2. Store sharing (Phase 4): worth it only if duplicate subscriptions show up + in practice. -- 2.54.0 From 025fd15837ff4267eebd919a7c81e0c450944cbe Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 12:20:49 +0700 Subject: [PATCH 05/12] refactor 2 --- crates/workspace/src/views/inbox.rs | 12 +- crates/workspace/src/views/repo/actions.rs | 119 ++++++------------ .../workspace/src/views/repo/init_dialog.rs | 2 +- crates/workspace/src/views/repo/mod.rs | 33 +++-- crates/workspace/src/views/repo/store.rs | 27 +++- crates/workspace/src/views/repo_list.rs | 8 +- .../src/views/sidebar/create_repo_dialog.rs | 8 +- crates/workspace/src/views/sidebar/mod.rs | 8 +- docs/repo-state-plan.md | 35 ++++-- 9 files changed, 124 insertions(+), 128 deletions(-) diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 01d7fee..172a26c 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -393,16 +393,6 @@ impl InboxView { return; }; - let Some(announcement) = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|announcement| announcement.addr() == address) - .cloned() - else { - return; - }; - let item = match kind { Some(Kind::GitIssue) => RepoItem::Issue(root), Some(Kind::GitPullRequest) => RepoItem::PullRequest(root), @@ -410,7 +400,7 @@ impl InboxView { _ => return, }; - open_repo_item(&self.dock_area, &announcement, item, window, cx); + open_repo_item(&self.dock_area, &address, None, item, window, cx); } fn render_entry(&self, ix: usize, cx: &Context) -> AnyElement { diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs index 095fec5..8d35f94 100644 --- a/crates/workspace/src/views/repo/actions.rs +++ b/crates/workspace/src/views/repo/actions.rs @@ -1,6 +1,5 @@ use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; use anyhow::Error; use dock::{DockArea, add_center_panel, panel_handle}; @@ -8,8 +7,8 @@ use gpui::prelude::*; use gpui::{App, Context, Entity, WeakEntity, Window}; use gpui_base::dock::PanelView; use nostr::prelude::EventId; -use signed_core::{Announcement, filters}; -use signed_state::{Backend, RepoListStore, RepoStore}; +use signed_core::{Announcement, RepoAddr}; +use signed_state::RepoStore; use super::RepoDetailView; use crate::views::issues::IssuesView; @@ -114,74 +113,20 @@ impl RepoDetailView { } /// Open the upstream repository, the `u` tag of this fork's announcement. - /// The upstream announcement may not be in the local database yet. - /// Subscribe for it and open the panel as soon as it lands. + /// + /// The announcement may not be in the local database yet. The panel opens + /// from the address and fills in when the store loads it; the store's + /// `subscribe_remote` fetches it from the bootstrap relays. pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { - if self.pending_upstream.is_some() { - return; - } - - let Some(announcement) = self.announcement(cx).cloned() else { + let Some(addr) = self + .announcement(cx) + .and_then(|announcement| announcement.upstream.as_ref()) + .and_then(|upstream| upstream.addr.clone()) + else { return; }; - let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else { - return; - }; - - if let Some(found) = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == addr) - .cloned() - { - open_repo_panel(&self.dock_area, &found, window, &mut *cx); - return; - } - - let backend = Backend::global(cx); - backend.update(cx, |backend, cx| { - backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx); - }); - self.pending_upstream = Some(addr); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - for _ in 0..60 { - cx.background_executor() - .timer(Duration::from_millis(250)) - .await; - - let opened = this.update_in(cx, |this, window, cx| { - let Some(addr) = this.pending_upstream.clone() else { - return true; - }; - let found = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == addr) - .cloned(); - match found { - Some(found) => { - this.pending_upstream = None; - open_repo_panel(&this.dock_area, &found, window, &mut *cx); - true - } - None => false, - } - })?; - - if opened { - return Ok(()); - } - } - - this.update(cx, |this, _cx| this.pending_upstream = None)?; - Ok(()) - }); - - task.detach(); + open_repo_panel(&self.dock_area, &addr, None, window, &mut *cx); } /// Open the dialog guiding the user through publishing the local repository to NIP-34. @@ -194,15 +139,21 @@ impl RepoDetailView { } } -/// Open `announcement` as a repository panel in the dock's center. +/// Open `addr`'s repository as a panel in the dock's center. +/// +/// `hint` is an announcement already in hand for `addr`. It seeds the store's +/// relays and lets the explorer load without waiting for the database; the +/// store loads the announcement itself when the hint is absent, so an entry +/// point with only an address works too. pub(crate) fn open_repo_panel( dock_area: &WeakEntity, - announcement: &Announcement, + addr: &RepoAddr, + hint: Option<&Announcement>, window: &mut Window, cx: &mut App, ) -> Entity { - let detail = - cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx)); + let detail = cx + .new(|cx| RepoDetailView::new(dock_area.clone(), addr.clone(), hint.cloned(), window, cx)); if let Some(dock_area) = dock_area.upgrade() { dock_area.update(cx, |dock_area, cx| { @@ -213,9 +164,16 @@ pub(crate) fn open_repo_panel( detail } -/// The nostr store of `announcement`'s repository, without opening a repository panel. -fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity { - cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)) +/// The nostr store of `addr`'s repository, without opening a repository panel. +/// +/// `hint` is an announcement already in hand for `addr`. It only seeds the +/// relays to connect to right away; the store loads the announcement from the +/// local database on its first pass, so the hint is optional. +fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity { + let relays = hint + .map(|announcement| announcement.relays.clone()) + .unwrap_or_default(); + cx.new(|cx| RepoStore::new(addr.clone(), relays, cx)) } /// An item of a repository to open from outside its detail panel. @@ -226,16 +184,21 @@ pub(crate) enum RepoItem { Patch, } -/// Open the detail panel of `item` in `announcement`'s repository, in the dock's center. +/// Open the detail panel of `item` in `addr`'s repository, in the dock's center. /// /// The repository store is built here, not taken from a `RepoDetailView`, so the /// item panel is the only panel docked. /// +/// `hint` is an announcement already in hand for `addr`, e.g. the inbox row the +/// item was clicked from. The store resolves the repository from the local +/// database on its own, so an entry point with only the address works too. +/// /// A patch opens nothing: patches are only consumed inside a pull request's /// detail panel, and have no panel of their own. pub(crate) fn open_repo_item( dock_area: &WeakEntity, - announcement: &Announcement, + addr: &RepoAddr, + hint: Option<&Announcement>, item: RepoItem, window: &mut Window, cx: &mut App, @@ -243,11 +206,11 @@ pub(crate) fn open_repo_item( let panel: Arc = match item { RepoItem::Issue(issue_id) => { - let store = repo_store(announcement, cx); + let store = repo_store(addr, hint, cx); panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) } RepoItem::PullRequest(pr_id) => { - let store = repo_store(announcement, cx); + let store = repo_store(addr, hint, cx); panel_handle(cx.new(|cx| { PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx) })) diff --git a/crates/workspace/src/views/repo/init_dialog.rs b/crates/workspace/src/views/repo/init_dialog.rs index 02083bf..8187111 100644 --- a/crates/workspace/src/views/repo/init_dialog.rs +++ b/crates/workspace/src/views/repo/init_dialog.rs @@ -190,7 +190,7 @@ fn init_repository( window.close_dialog(cx); if let Some(view) = view.upgrade() { view.update(cx, |this, cx| { - this.apply_announcement(announcement, cx); + this.apply_announcement(announcement, window, cx); }); } }) diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 5607fce..18edd49 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -73,7 +73,8 @@ pub struct RepoDetailView { dock_area: WeakEntity, /// Snapshot taken at open time. /// - /// `None` for local repositories that haven't been published yet. + /// `None` for local repositories that haven't been published yet, and for a + /// repository opened by address until its store loads the announcement. initial: Option, /// Per-repository nostr store, holding announcement, issues, PRs and statuses. /// @@ -162,37 +163,32 @@ pub struct RepoDetailView { /// The global checkouts store's ready-to-push statuses of this repository, /// last seen when they drove a render. push_statuses: Vec, - /// Upstream repository, from this fork's `u` tag, the user asked to open. - /// Its announcement is still being fetched. - pending_upstream: Option, } impl RepoDetailView { - /// Open a repository announced. + /// Open a repository by address. /// - /// The store connects to the announcement's relays and loads issues, PRs and statuses. + /// `hint` is an announcement already in hand. It seeds the store's relays + /// and the explorer's clone URLs; without it the panel waits for the store + /// to load the announcement from the local database. pub fn new( dock_area: WeakEntity, - initial: Announcement, + addr: RepoAddr, + hint: Option, window: &mut Window, cx: &mut Context, ) -> Self { // The announcement we opened from already carries the NIP-34 `relays` tag. // // The store connects to those relays immediately, no bootstrap fetch wait. - let addr = initial.addr(); - let relays = initial.relays.clone(); + let relays = hint + .as_ref() + .map(|announcement| announcement.relays.clone()) + .unwrap_or_default(); let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); - let mut view = Self::new_common( - dock_area, - Some(initial), - Some(store.clone()), - None, - window, - cx, - ); - view.attach_store(&store, cx); + let mut view = Self::new_common(dock_area, hint, Some(store.clone()), None, window, cx); + view.attach_store(&store, window, cx); view } @@ -313,7 +309,6 @@ impl RepoDetailView { ready_head: None, ready_statuses: Vec::new(), push_statuses: Vec::new(), - pending_upstream: None, focus_handle: cx.focus_handle(), _subscriptions: subscriptions, } diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs index 2bc4e38..9279200 100644 --- a/crates/workspace/src/views/repo/store.rs +++ b/crates/workspace/src/views/repo/store.rs @@ -1,5 +1,5 @@ use gpui::prelude::*; -use gpui::{Context, Entity}; +use gpui::{Context, Entity, Window}; use signed_core::Announcement; use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; @@ -13,6 +13,7 @@ impl RepoDetailView { pub(crate) fn apply_announcement( &mut self, announcement: Announcement, + window: &mut Window, cx: &mut Context, ) { // The repository is no longer a bare local repo. @@ -24,7 +25,7 @@ impl RepoDetailView { cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); // Re-render on store refreshes, issues, PRs and statuses. // Keep the ready-to-contribute statuses of this repository requested. - self.attach_store(&store, cx); + self.attach_store(&store, window, cx); self.store = Some(store); self.initial = Some(announcement); cx.notify(); @@ -32,11 +33,27 @@ impl RepoDetailView { /// Observe the repository's store, re-render on refreshes. /// Request the ready-to-contribute statuses for it. - pub(super) fn attach_store(&mut self, store: &Entity, cx: &mut Context) { + pub(super) fn attach_store( + &mut self, + store: &Entity, + window: &mut Window, + cx: &mut Context, + ) { self._subscriptions - .push(cx.observe(store, |this, _store, cx| { - log::debug!("repo detail: store notify"); + .push(cx.observe_in(store, window, |this, store, window, cx| { this.refresh_ready_statuses(cx); + + // A repository opened from its address alone starts without an + // announcement. Adopt the store's first one so the explorer can + // load; later passes leave the snapshot and the selection alone. + let announcement = store.read(cx).announcement.clone(); + if this.initial.is_none() + && let Some(announcement) = announcement + { + this.initial = Some(announcement); + this.load_repo(window, cx); + } + cx.notify(); })); self.refresh_ready_statuses(cx); diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index c3dec27..b3a6593 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -189,7 +189,13 @@ impl RepoListView { window: &mut Window, cx: &mut Context, ) { - open_repo_panel(&self.dock_area, announcement, window, cx); + open_repo_panel( + &self.dock_area, + &announcement.addr(), + Some(announcement), + window, + cx, + ); } fn render_card( diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index a4723f0..9353446 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -257,5 +257,11 @@ fn open_repo( window: &mut Window, cx: &mut App, ) { - open_repo_panel(&dock_area, &announcement, window, cx); + open_repo_panel( + &dock_area, + &announcement.addr(), + Some(&announcement), + window, + cx, + ); } diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 950e790..c660e16 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -260,7 +260,13 @@ impl SidebarPanel { window: &mut Window, cx: &mut Context, ) { - open_repo_panel(&self.dock_area, announcement, window, &mut *cx); + open_repo_panel( + &self.dock_area, + &announcement.addr(), + Some(announcement), + window, + &mut *cx, + ); } /// Open a local repository's detail view in the dock's center. diff --git a/docs/repo-state-plan.md b/docs/repo-state-plan.md index bd4deee..5ab7777 100644 --- a/docs/repo-state-plan.md +++ b/docs/repo-state-plan.md @@ -1,6 +1,6 @@ # Repository state and panel flow plan -Status: proposed (2026-09-13) +Status: phases 1-2 implemented, phase 3 next (2026-09-13) Builds on `docs/backend-rearchitecture.md`, especially §7 (notify audit), §11 (split independently-observed state), §12 (one debounce at the source) @@ -237,10 +237,8 @@ flow for announced repositories. - `open_repo_item(dock_area, addr: &RepoAddr, item, window, cx)`. - Inbox passes its already-parsed `address` (`signed_core::InboxItem.address`, the root event's `a` tag) and drops the `RepoListStore` lookup. -- `open_upstream` drops the 60 x 250 ms wait loop: the store's - `repo_filters` already include the announcement filter and - `subscribe_remote` runs on creation, so the panel opens immediately and - fills in. +- `open_upstream` no longer polls the list; it opens the panel by address and + the store fills it in (Phase 2). - `RepoItem::Patch` behavior is unchanged. ## Phases @@ -271,17 +269,32 @@ Status: implemented, except step 6. ### Phase 2 - entry points by identity -1. `views/repo/actions.rs`: `repo_store(addr, hint, cx)`; - `open_repo_item(addr, ...)`; `open_upstream` opens directly. -2. `views/inbox.rs`: pass `address`, delete the announcement lookup and its - silent early-return. +Status: implemented. + +1. `views/repo/actions.rs`: `repo_store(addr, hint, cx)` seeds the store's + relays from an optional hint but needs nothing else; the store loads the + announcement itself. `open_repo_item(addr, hint, item, window, cx)` takes + the address, not a hydrated announcement. +2. `views/inbox.rs`: `open` passes its already-parsed `address` and the + `RepoListStore` lookup with its silent early-return is gone. An inbox row + opens whether or not the repository is in the list yet. +3. `open_repo_panel` and `RepoDetailView::new` take an address plus an optional + hint, so a repository panel opens from a `RepoAddr` alone. This pulls the + address-based constructor forward from Phase 3 step 2. +4. `RepoDetailView::attach_store` adopts the store's first announcement when + `initial` is still empty and calls `load_repo`, so a panel opened by address + fills in instead of waiting for the caller to have the announcement. +5. `open_upstream`: the 60 x 250 ms poll and the `pending_upstream` field are + gone. It opens the panel by address; the store's `subscribe_remote` fetches + the announcement from the bootstrap relays and step 4 loads the explorer. ### Phase 3 - one entity for local and NIP-34 1. `signed_state/src/repo.rs`: `addr`/`path` options, `new_local`, `announce`, `Option`, action guards. -2. `views/repo/mod.rs`: single `store` field; `new`/`new_local`; header, - display name, `load_repo`, `open_init_dialog` derive from the store. +2. `views/repo/mod.rs`: single `store` field; `new_local`; header, + display name, `load_repo`, `open_init_dialog` derive from the store. The + address-based `new` is already in place from Phase 2. 3. `views/repo/store.rs`: always observe; `refresh_statuses` returns false when not announced. 4. `views/repo/actions.rs`, `header.rs`, `banners.rs`: drop -- 2.54.0 From f8277e4c2e1e3cb6a535febadacd780de1a3ac60 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 13:00:20 +0700 Subject: [PATCH 06/12] refactor 3 --- crates/signed_state/src/repo.rs | 281 +++++++++++++----- .../src/views/pull_requests/detail.rs | 52 ++-- .../workspace/src/views/pull_requests/new.rs | 66 ++-- crates/workspace/src/views/repo/actions.rs | 34 +-- crates/workspace/src/views/repo/banners.rs | 48 ++- crates/workspace/src/views/repo/header.rs | 31 +- .../workspace/src/views/repo/init_dialog.rs | 2 +- crates/workspace/src/views/repo/loading.rs | 28 +- crates/workspace/src/views/repo/mod.rs | 98 +++--- crates/workspace/src/views/repo/store.rs | 59 ++-- docs/repo-state-plan.md | 55 +++- 11 files changed, 451 insertions(+), 303 deletions(-) diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 206189b..006b7af 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -31,8 +31,14 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; /// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses. /// Always derived from the local database. pub struct RepoStore { - addr: RepoAddr, + /// NIP-34 address. `None` while the repository is local-only. + addr: Option, + /// Latest announcement. Seeded from the open-time hint, replaced by the + /// database's latest on the first pass. `None` while local-only. pub announcement: Option, + /// Local working copy. The scan path for a local repository, kept when it is + /// later announced so the panel keeps its worktree. + pub path: Option, /// The first local pass has been applied. /// /// Views distinguish "no data yet" from a genuinely empty repository with it. @@ -82,52 +88,21 @@ pub struct RepoStore { root_fetches: HashSet, /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, - _subscription: Subscription, + /// Backend subscription of an announced repository. `None` while local-only. + _subscription: Option, } impl RepoStore { - pub fn new(addr: RepoAddr, announced_relays: Vec, cx: &mut Context) -> Self { - let backend = Backend::global(cx); - - let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { - let relevant = match event { - BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| { - // Deletions may target any event of this repository. - let deletion = - update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; - let coordinate = update.coordinate.as_ref() == Some(&this.addr); - let author = update.author == this.addr.public_key; - let kind = update.kind == Kind::GitRepoAnnouncement; - // NIP-22 comments carry no `a` tag. - // Coordinate matching fails for them. - // Any comment may reference this repository's roots. - let comment = update.kind == Kind::Comment; - // Status events may omit their `a` tag, NIP-34. - // Any status event may reference a root of this repository. - let status = RepoStatus::from_kind(update.kind).is_some(); - - deletion || coordinate || (author && kind) || comment || status - }), - BackendEvent::Published(event) => { - let kind = event.kind == Kind::GitRepoAnnouncement; - let author = event.pubkey == this.addr.public_key; - let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); - // Locally published deletions may target any event of this repository. - // Refresh so they take effect immediately, like relay deletions. - let deletion = - event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; - - coordinate || (kind && author) || deletion - } - _ => false, - }; - - if relevant { - this.refresh(cx); - } - }); - + /// Announced repository. + pub fn new(addr: RepoAddr, hint: Option, cx: &mut Context) -> Self { let weak = cx.entity().downgrade(); + let subscription = Self::subscribe_backend(cx); + + let announced_relays = hint + .as_ref() + .map(|announcement| announcement.relays.clone()) + .unwrap_or_default(); + cx.defer(move |cx| { let result = weak.update(cx, |this, cx| { this.subscribe_remote(cx); @@ -141,8 +116,9 @@ impl RepoStore { }); Self { - addr, - announcement: None, + addr: Some(addr), + announcement: hint, + path: None, loaded: false, head: None, issues: Vec::new(), @@ -161,13 +137,103 @@ impl RepoStore { repo_relays: HashSet::new(), root_fetches: HashSet::new(), refresh: RefreshGate::default(), - _subscription: subscription, + _subscription: Some(subscription), } } - /// Returns the repository's address. - pub fn addr(&self) -> &RepoAddr { - &self.addr + /// Local repository discovered by the scan, not announced to NIP-34 yet. + pub fn new_local(path: PathBuf) -> Self { + Self { + addr: None, + announcement: None, + path: Some(path), + loaded: true, + head: None, + issues: Vec::new(), + patches: Vec::new(), + pull_requests: Vec::new(), + comments: Vec::new(), + status_by_root: HashMap::new(), + open_issue_count: 0, + open_pr_count: 0, + version: 0, + last_error: None, + last_warning: None, + last_push_warning: None, + pushing: false, + cloning: false, + repo_relays: HashSet::new(), + root_fetches: HashSet::new(), + refresh: RefreshGate::default(), + _subscription: None, + } + } + + /// Switch a local repository to its NIP-34 mode, keeping its path. + pub fn announce(&mut self, announcement: Announcement, cx: &mut Context) { + self.addr = Some(announcement.addr()); + self.announcement = Some(announcement.clone()); + self.loaded = false; + + if self._subscription.is_none() { + self._subscription = Some(Self::subscribe_backend(cx)); + } + + self.subscribe_remote(cx); + self.connect_announced_relays(&announcement.relays, cx); + self.refresh(cx); + } + + /// Subscriptions to the backend events concerning this repository. + fn subscribe_backend(cx: &mut Context) -> Subscription { + let backend = Backend::global(cx); + + cx.subscribe(&backend, |this, _backend, event, cx| { + let Some(addr) = this.addr.as_ref() else { + return; + }; + + let relevant = match event { + BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| { + // Deletions may target any event of this repository. + let deletion = + update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; + let coordinate = update.coordinate.as_ref() == Some(addr); + let author = update.author == addr.public_key; + let kind = update.kind == Kind::GitRepoAnnouncement; + // NIP-22 comments carry no `a` tag. + // Coordinate matching fails for them. + // Any comment may reference this repository's roots. + let comment = update.kind == Kind::Comment; + // Status events may omit their `a` tag, NIP-34. + // Any status event may reference a root of this repository. + let status = RepoStatus::from_kind(update.kind).is_some(); + + deletion || coordinate || (author && kind) || comment || status + }), + BackendEvent::Published(event) => { + let kind = event.kind == Kind::GitRepoAnnouncement; + let author = event.pubkey == addr.public_key; + let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr); + // Locally published deletions may target any event of this repository. + // Refresh so they take effect immediately, like relay deletions. + let deletion = + event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; + + coordinate || (kind && author) || deletion + } + _ => false, + }; + + if relevant { + this.refresh(cx); + } + }) + } + + /// Returns the repository's NIP-34 address. `None` while it is local-only. + pub fn addr(&self) -> Option<&RepoAddr> { + self.addr.as_ref() } /// Returns the repository's name, or `Unknown` when not known. @@ -199,6 +265,10 @@ impl RepoStore { /// Fetch this repository's events from the relays in its NIP-34 `relays` tag. fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + return; + }; + let new: Vec = relays .iter() .filter(|url| !self.repo_relays.contains(*url)) @@ -211,7 +281,6 @@ impl RepoStore { self.repo_relays.extend(new.iter().cloned()); let backend = Backend::global(cx); - let addr = self.addr.clone(); backend.update(cx, |backend, cx| { backend.connect_repo_relays(new, Self::repo_filters(&addr), cx); @@ -220,8 +289,11 @@ impl RepoStore { /// Fetch this repository's events from the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + return; + }; + let backend = Backend::global(cx); - let addr = self.addr.clone(); backend.update(cx, |backend, cx| { backend.subscribe_bootstrap(Self::repo_filters(&addr), cx); @@ -233,6 +305,10 @@ impl RepoStore { /// Runs immediately. The backend pump already batches the relay events that /// trigger a refresh, so no per-store debounce is needed. pub fn refresh(&mut self, cx: &mut Context) { + if self.addr.is_none() { + return; + } + if self.refresh.request() != RefreshRequest::Schedule { return; } @@ -241,11 +317,14 @@ impl RepoStore { } fn run_refresh(&mut self, cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + return; + }; + self.refresh.begin(); let backend = Backend::global(cx); let client = backend.read(cx).client(); - let addr = self.addr.clone(); let work = cx.background_spawn(async move { let (announcements, states, activity, deletion_events) = async { @@ -405,12 +484,18 @@ impl RepoStore { // The first pass is the exception: it must notify even when it // found nothing, so views can leave their loading state and show // the empty result. + // + // Keep the open-time hint until that first pass has confirmed what + // the database holds; afterwards the database is the truth, + // including a deletion. + let keep_hint = announcement.is_none() && !this.loaded; + let first_pass = !this.loaded; let head_changed = state .as_ref() .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); let changed = first_pass - || this.announcement != announcement + || (!keep_hint && this.announcement != announcement) || head_changed || this.issues != issues || this.patches != patches @@ -418,7 +503,9 @@ impl RepoStore { || this.comments != comments || this.status_by_root != status_by_root; - this.announcement = announcement; + if !keep_hint { + this.announcement = announcement; + } // The announcement may list relays for this repository's activity. // Connect to any we have not fetched from yet. @@ -526,13 +613,20 @@ impl RepoStore { /// The author is the public key of the repository address. /// Only the author may manage pull requests, close, reopen or merge. pub fn is_author(&self, user: &PublicKey) -> bool { - &self.addr.public_key == user + self.addr + .as_ref() + .is_some_and(|addr| &addr.public_key == user) } /// Open an issue on this repository. pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let builder = GitIssue { - repository: self.addr.clone(), + repository: addr, content, subject, labels: Vec::new(), @@ -564,6 +658,11 @@ impl RepoStore { content: String, cx: &mut Context, ) { + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let relay_hint = self .announcement .as_ref() @@ -571,7 +670,7 @@ impl RepoStore { .cloned(); self.publish( - comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content), + comment_builder(root, parent, relay_hint.as_ref(), &addr, content), cx, ); } @@ -592,6 +691,11 @@ impl RepoStore { self.last_error = None; self.last_warning = None; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let series: Vec = signed_git::split_patch_series(&patch) .into_iter() .map(str::to_owned) @@ -635,8 +739,7 @@ impl RepoStore { // The author's npub names their GRASP-06 namespace, `/prs/...`. let author_npub = user.to_bech32().unwrap(); - let addr = self.addr.clone(); - let owner = self.addr.public_key; + let owner = addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let repo_id = addr.identifier.clone(); let base_npub = owner.to_bech32().unwrap(); @@ -752,7 +855,7 @@ impl RepoStore { let clone = pr_clone_urls(prs_urls, base_clone); let builder = GitPullRequest { - repository: this.addr.clone(), + repository: addr.clone(), content: description, subject, labels: Vec::new(), @@ -971,8 +1074,11 @@ impl RepoStore { .map(|p| p.id) }); - let addr = self.addr.clone(); - let owner = self.addr.public_key; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let owner = addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let root = root.clone(); let clone: Vec = self @@ -1000,9 +1106,9 @@ impl RepoStore { }); } - let builder = this.update(cx, |this, _cx| { + let builder = this.update(cx, |_this, _cx| { let builder = GitPullRequestUpdate { - repository: this.addr.clone(), + repository: addr.clone(), pull_request_event: root.id, pull_request_author: root.pubkey, current_commit, @@ -1059,6 +1165,11 @@ impl RepoStore { pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let maintainers = self .announcement .as_ref() @@ -1084,9 +1195,9 @@ impl RepoStore { let builder = EventBuilder::new(status.kind(), "").tags([ root_ref, - Tag::public_key(self.addr.public_key), + Tag::public_key(addr.public_key), Tag::public_key(root.pubkey), - Tag::coordinate(self.addr.clone(), None), + Tag::coordinate(addr, None), ]); self.publish(builder, cx); @@ -1097,6 +1208,11 @@ impl RepoStore { self.last_error = None; self.last_warning = None; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let is_author = Backend::global(cx) .read(cx) .current_user() @@ -1107,7 +1223,6 @@ impl RepoStore { } let cache = GitStore::global(cx).cache().clone(); - let addr = self.addr.clone(); let clone_urls: Vec = self .announcement @@ -1176,12 +1291,13 @@ impl RepoStore { /// The latest announcement of this repository, /// for operations that need its clone URLs and relays. fn action_announcement(&self, cx: &App) -> Option { + let addr = self.addr.as_ref()?; self.announcement.clone().or_else(|| { RepoListStore::global(cx) .read(cx) .announcements .iter() - .find(|announcement| announcement.addr() == self.addr) + .find(|announcement| announcement.addr() == *addr) .cloned() }) } @@ -1243,6 +1359,10 @@ impl RepoStore { ))); } + let Some(addr) = self.addr.clone() else { + return self.action_error("This repository is not published to Nostr yet", cx); + }; + let Some(announcement) = self.action_announcement(cx) else { return self.action_error("Repository announcement is not loaded yet", cx); }; @@ -1250,7 +1370,6 @@ impl RepoStore { // 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; @@ -1298,7 +1417,9 @@ impl RepoStore { /// 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(); + let Some(addr) = self.addr.clone() else { + return self.action_error("This repository is not published to Nostr yet", cx); + }; self.last_error = None; let backend = Backend::global(cx); @@ -1328,12 +1449,16 @@ impl RepoStore { "A clone of this repository is already in progress" ))); } + + let Some(addr) = self.addr.clone() else { + return self.action_error("This repository is not published to Nostr yet", cx); + }; + let Some(announcement) = self.action_announcement(cx) else { return self.action_error("Repository announcement is not loaded yet", cx); }; let clone_urls = announcement.clone.clone(); - let addr = self.addr.clone(); self.cloning = true; self.last_error = None; @@ -1370,6 +1495,12 @@ impl RepoStore { }) } + /// Record that an action needs a NIP-34 address this repository does not have. + fn not_announced(&mut self, cx: &mut Context) { + self.last_error = Some("This repository is not published to Nostr yet".into()); + cx.notify(); + } + /// Fail an operation whose announcement is not loaded yet. fn action_error( &mut self, @@ -1393,11 +1524,15 @@ impl RepoStore { euc: Option<&str>, cx: &mut Context, ) { + let Some(addr) = self.addr.clone() else { + return; + }; + let mut tags = vec![ Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"), - Tag::public_key(self.addr.public_key), + Tag::public_key(addr.public_key), Tag::public_key(root.pubkey), - Tag::coordinate(self.addr.clone(), None), + Tag::coordinate(addr, None), ]; if let Some(euc) = euc diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index f4d7c4d..5e7e526 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -151,35 +151,37 @@ impl PullRequestDetailView { let binding = { let store = self.store.read(cx); - store - .pull_requests - .iter() - .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) - .map(|root| { - let update = latest_update(store.pull_requests.iter(), root); + store.addr().and_then(|addr| { + store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + .map(|root| { + let update = latest_update(store.pull_requests.iter(), root); - let tip = update - .and_then(current_commit_of) - .or_else(|| current_commit_of(root)); + let tip = update + .and_then(current_commit_of) + .or_else(|| current_commit_of(root)); - let base = update - .and_then(merge_base_of) - .or_else(|| merge_base_of(root)); + let base = update + .and_then(merge_base_of) + .or_else(|| merge_base_of(root)); - let clone_urls = clone_urls_of(root) - .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) - .unwrap_or_default(); + let clone_urls = clone_urls_of(root) + .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) + .unwrap_or_default(); - PrBinding { - description: root.content.clone(), - patch: pull_request_patch(root, store.patches.iter()), - tip, - base, - clone_urls, - addr: store.addr().clone(), - has_patch_link: root.tags.event_ids().next().is_some(), - } - }) + PrBinding { + description: root.content.clone(), + patch: pull_request_patch(root, store.patches.iter()), + tip, + base, + clone_urls, + addr: addr.clone(), + has_patch_link: root.tags.event_ids().next().is_some(), + } + }) + }) }; let Some(binding) = binding else { diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index f087c4a..06f19ea 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -305,7 +305,24 @@ impl NewPullRequestView { ), ]; - let mut view = Self { + cx.defer_in(window, |this, window, cx| { + let Some(addr) = this.store.read(cx).addr().cloned() else { + return; + }; + + let Some(path) = CheckoutsStore::global(cx) + .read(cx) + .associations_of(&addr) + .into_iter() + .next() + else { + return; + }; + + this.apply_folder_path(path, window, cx); + }); + + Self { focus_handle: cx.focus_handle(), dock_area, store, @@ -330,20 +347,7 @@ impl NewPullRequestView { scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), _subscriptions: subscriptions, - }; - - // Prefill with the store's freshest associated checkout, no folder dialog. - let addr = view.store.read(cx).addr().clone(); - if let Some(path) = CheckoutsStore::global(cx) - .read(cx) - .associations_of(&addr) - .into_iter() - .next() - { - view.apply_folder_path(path, window, cx); } - - view } /// Whether a compare source, a checkout or a fork, is applied. @@ -496,11 +500,12 @@ impl NewPullRequestView { // Remember this folder as a checkout of the target repository. // The next panel pre-fills it. - let addr = self.store.read(cx).addr().clone(); - let checkout_store = CheckoutsStore::global(cx); - checkout_store.update(cx, |store, cx| { - store.record(PathBuf::from(&path), addr, cx); - }); + if let Some(addr) = self.store.read(cx).addr().cloned() { + let checkout_store = CheckoutsStore::global(cx); + checkout_store.update(cx, |store, cx| { + store.record(PathBuf::from(&path), addr, cx); + }); + } let branches = self.branches.clone(); let base = SharedString::from(base.clone()); @@ -524,18 +529,21 @@ impl NewPullRequestView { /// The base repository of the panel, its address and announced EUC. /// - /// Used to find fork candidates. - fn base_repo(&self, cx: &App) -> (RepoAddr, Option) { + /// Used to find fork candidates. `None` while the repository is not announced. + fn base_repo(&self, cx: &App) -> Option<(RepoAddr, Option)> { let store = self.store.read(cx); + let addr = store.addr()?.clone(); let euc = store.announcement.as_ref().and_then(|a| a.euc.clone()); - (store.addr().clone(), euc) + Some((addr, euc)) } /// Announced forks of the target repository a compare can use, own first. /// /// Re-read whenever the picker opens. fn fork_candidates(&self, cx: &App) -> Vec { - let (base, euc) = self.base_repo(cx); + let Some((base, euc)) = self.base_repo(cx) else { + return Vec::new(); + }; let user = Backend::global(cx).read(cx).current_user(); let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); fork_candidates(&announcements, &base, euc.as_deref(), user) @@ -556,7 +564,9 @@ impl NewPullRequestView { .as_ref() .is_some_and(|fork| fork.announcement.addr() == announcement.addr()); - let (base, _euc) = self.base_repo(cx); + let Some((base, _euc)) = self.base_repo(cx) else { + return; + }; let cache = GitStore::global(cx).cache().clone(); let mirror_path = cache.repo_path(&base); let namespace = fork_namespace(&announcement); @@ -1124,8 +1134,12 @@ impl NewPullRequestView { cx: &Context, ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static { let view = cx.entity().downgrade(); - let addr = self.store.read(cx).addr().clone(); - let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr); + let associated = self + .store + .read(cx) + .addr() + .map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr)) + .unwrap_or_default(); let active_path = (self.fork.is_none()) .then(|| self.repo_path.clone()) diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs index 8d35f94..ee04c28 100644 --- a/crates/workspace/src/views/repo/actions.rs +++ b/crates/workspace/src/views/repo/actions.rs @@ -20,14 +20,10 @@ use crate::views::repo::init_dialog; impl RepoDetailView { /// Re-push the repository's refs to its announced grasp servers. pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - self.error = None; cx.notify(); - store + self.store .update(cx, |store, cx| store.push_repository(cx)) .detach(); } @@ -39,9 +35,7 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) { - let Some(store) = self.store.clone() else { - return; - }; + let store = self.store.clone(); if store.read(cx).pushing { return; @@ -72,23 +66,22 @@ impl RepoDetailView { /// Delete the repository from nostr, announcement, state and activity. pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - store + self.store .update(cx, |store, cx| store.delete_repository(cx)) .detach(); } /// Open the issues list panel in the dock area. pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { + if self.store.read(cx).addr().is_none() { return; - }; + } + let Some(dock_area) = self.dock_area.upgrade() else { return; }; + let store = self.store.clone(); let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); dock_area.update(cx, |dock_area, cx| { @@ -98,13 +91,15 @@ impl RepoDetailView { /// Open the pull requests list panel in the dock area. pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { + if self.store.read(cx).addr().is_none() { return; - }; + } + let Some(dock_area) = self.dock_area.upgrade() else { return; }; + let store = self.store.clone(); let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); dock_area.update(cx, |dock_area, cx| { @@ -131,7 +126,7 @@ impl RepoDetailView { /// Open the dialog guiding the user through publishing the local repository to NIP-34. pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(local_path) = self.local_path.clone() else { + let Some(local_path) = self.store.read(cx).path.clone() else { return; }; let view = cx.entity().downgrade(); @@ -170,10 +165,7 @@ pub(crate) fn open_repo_panel( /// relays to connect to right away; the store loads the announcement from the /// local database on its first pass, so the hint is optional. fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity { - let relays = hint - .map(|announcement| announcement.relays.clone()) - .unwrap_or_default(); - cx.new(|cx| RepoStore::new(addr.clone(), relays, cx)) + cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx)) } /// An item of a repository to open from outside its detail panel. diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index c50a7c2..f686233 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -17,14 +17,15 @@ impl RepoDetailView { /// The repository's own checkouts are not suggested here. /// Their work is pushed, see [`Self::push_suggestion`]. fn ready_suggestion(&self, cx: &App) -> Option { - let store = self.store.as_ref()?; - let addr = store.read(cx).addr().clone(); + let store = self.store.read(cx); + let addr = store.addr()?; let user = Backend::global(cx).read(cx).current_user()?; - if store.read(cx).is_author(&user) { + + if store.is_author(&user) { return None; } - let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr); + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr); 'status: for status in statuses { if self @@ -33,7 +34,6 @@ impl RepoDetailView { { continue; } - let store = store.read(cx); for pr in &store.pull_requests { if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) { @@ -50,15 +50,15 @@ impl RepoDetailView { /// /// Not dismissed in this panel. fn push_suggestion(&self, cx: &App) -> Option { - let entity = self.store.as_ref()?; + let store = self.store.read(cx); + let addr = store.addr()?; let user = Backend::global(cx).read(cx).current_user()?; - if !entity.read(cx).is_author(&user) { + if !store.is_author(&user) { return None; } - let addr = entity.read(cx).addr().clone(); - let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr); + let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr); statuses.into_iter().find(|status| { !self @@ -75,10 +75,7 @@ impl RepoDetailView { 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 pushing = self.store.read(cx).pushing; let commits = if status.ahead == 1 { SharedString::from("1 commit") @@ -160,8 +157,7 @@ impl RepoDetailView { /// Warning after a push that only some grasp servers accepted. pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { - let store = self.store.as_ref()?; - let store = store.read(cx); + let store = self.store.read(cx); let warning = store.last_push_warning.clone()?; let pushing = store.pushing; @@ -213,11 +209,9 @@ impl RepoDetailView { .ghost() .disabled(pushing) .on_click(cx.listener(|this, _ev, _window, cx| { - if let Some(store) = this.store.clone() { - store.update(cx, |store, _| { - store.last_push_warning = None; - }); - } + this.store.update(cx, |store, _| { + store.last_push_warning = None; + }); cx.notify(); })), ), @@ -299,14 +293,12 @@ impl RepoDetailView { .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, - ); - } + open_new_pull_panel( + this.dock_area.clone(), + this.store.clone(), + window, + cx, + ); })), ) .child( diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs index b652dd5..87912cf 100644 --- a/crates/workspace/src/views/repo/header.rs +++ b/crates/workspace/src/views/repo/header.rs @@ -27,15 +27,11 @@ impl RepoDetailView { /// The NIP-34 header, actions and issues/PR counts. /// Or the local header with an Init button for an unpublished repository. pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { - if self.local_path.is_some() { + if self.store.read(cx).addr().is_none() { return self.render_local_header(cx); } - let Some(store_entity) = self.store.as_ref() else { - return div().into_any_element(); - }; - - let store = store_entity.read(cx); + let store = self.store.read(cx); let issue_count = SharedString::from(store.issue_count().to_string()); let pr_count = SharedString::from(store.pull_request_count().to_string()); @@ -43,7 +39,7 @@ impl RepoDetailView { let pushing = store.pushing; let cloning = store.cloning; - let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else { + let Some(source) = store.announcement.as_ref() else { return div().into_any_element(); }; @@ -73,19 +69,18 @@ impl RepoDetailView { .on_action( cx.listener(|this, action: &RepoAction, window, cx| match action { RepoAction::NewIssue => { - if let Some(store) = this.store.clone() { - open_new_issue_dialog(store, window, cx); - } + open_new_issue_dialog(this.store.clone(), window, cx); } RepoAction::NewPR => { - if let Some(store) = this.store.clone() { - open_new_pull_panel(this.dock_area.clone(), store, window, cx); - } + open_new_pull_panel(this.dock_area.clone(), this.store.clone(), window, cx); } RepoAction::SendPatch => { - if let Some(store) = this.store.clone() { - open_send_patch_panel(this.dock_area.clone(), store, window, cx); - } + open_send_patch_panel( + this.dock_area.clone(), + this.store.clone(), + window, + cx, + ); } RepoAction::About => { if let Some(announcement) = this.announcement(cx) { @@ -421,7 +416,9 @@ impl RepoDetailView { fn render_local_header(&self, cx: &mut Context) -> AnyElement { let name = self.display_name(cx); let path = self - .local_path + .store + .read(cx) + .path .as_ref() .map(|path| path.display().to_string()) .unwrap_or_default(); diff --git a/crates/workspace/src/views/repo/init_dialog.rs b/crates/workspace/src/views/repo/init_dialog.rs index 8187111..02083bf 100644 --- a/crates/workspace/src/views/repo/init_dialog.rs +++ b/crates/workspace/src/views/repo/init_dialog.rs @@ -190,7 +190,7 @@ fn init_repository( window.close_dialog(cx); if let Some(view) = view.upgrade() { view.update(cx, |this, cx| { - this.apply_announcement(announcement, window, cx); + this.apply_announcement(announcement, cx); }); } }) diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs index bf6a761..d3a8266 100644 --- a/crates/workspace/src/views/repo/loading.rs +++ b/crates/workspace/src/views/repo/loading.rs @@ -39,9 +39,24 @@ impl RepoDetailView { self.error = None; cx.notify(); + let (addr, announcement, local_path) = { + let store = self.store.read(cx); + ( + store.addr().cloned(), + store.announcement.clone(), + store.path.clone(), + ) + }; + // Local repositories live on disk at their scan path. // No clone step or network refresh applies here. - if let Some(local_path) = self.local_path.clone() { + if addr.is_none() { + self.repo_started = true; + + let Some(local_path) = local_path else { + return; + }; + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let data = cx .background_spawn(async move { @@ -67,13 +82,14 @@ impl RepoDetailView { return; } - let Some(initial) = self.initial.as_ref() else { + let Some(announcement) = announcement else { return; }; + self.repo_started = true; let cache = GitStore::global(cx).cache().clone(); - let addr = initial.addr(); - let clone_urls: Vec = initial.clone.clone(); + let addr = announcement.addr(); + let clone_urls: Vec = announcement.clone.clone(); // Captured before the loads start. // A branch/tag switch bumps the generation, discarding the refresh below. @@ -324,9 +340,7 @@ impl RepoDetailView { /// Clone the repository into a user-chosen folder outside the cache. pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; + let store = self.store.clone(); let name = { let Some(announcement) = self.announcement(cx) else { diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 18edd49..8a59f45 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -71,19 +71,14 @@ pub struct RepoDetailView { /// /// New panels, commit diffs, are added there. dock_area: WeakEntity, - /// Snapshot taken at open time. + /// Per-repository store, holding the local path and the announcement, + /// issues, PRs and statuses. The single identity of both modes. + store: Entity, + /// The initial explorer load has been started. /// - /// `None` for local repositories that haven't been published yet, and for a - /// repository opened by address until its store loads the announcement. - initial: Option, - /// Per-repository nostr store, holding announcement, issues, PRs and statuses. - /// - /// `None` until a local repository is initialized to NIP-34. - store: Option>, - /// Path of the local repository when opened from the scan. - /// - /// `None` once it is initialized to NIP-34, or for announced repositories. - local_path: Option, + /// A repository opened by address alone starts without an announcement; the + /// store observer starts the load once the first one lands. + repo_started: bool, /// File explorer state, the worktree of the local clone. tree_state: Entity, /// Root of the local clone, for reading files on demand. @@ -178,18 +173,8 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) -> Self { - // The announcement we opened from already carries the NIP-34 `relays` tag. - // - // The store connects to those relays immediately, no bootstrap fetch wait. - let relays = hint - .as_ref() - .map(|announcement| announcement.relays.clone()) - .unwrap_or_default(); - let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); - - let mut view = Self::new_common(dock_area, hint, Some(store.clone()), None, window, cx); - view.attach_store(&store, window, cx); - view + let store = cx.new(|cx| RepoStore::new(addr, hint, cx)); + Self::new_common(dock_area, store, window, cx) } /// Open a local repository discovered by the scan. @@ -199,7 +184,8 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) -> Self { - Self::new_common(dock_area, None, None, Some(local_path), window, cx) + let store = cx.new(move |_cx| RepoStore::new_local(local_path)); + Self::new_common(dock_area, store, window, cx) } /// Shared construction. @@ -207,9 +193,7 @@ impl RepoDetailView { /// File explorer state, ref selectors and the deferred repository load. fn new_common( dock_area: WeakEntity, - initial: Option, - store: Option>, - local_path: Option, + store: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -271,11 +255,10 @@ impl RepoDetailView { this.load_repo(window, cx); }); - Self { - initial, + let mut view = Self { dock_area, - store, - local_path, + store: store.clone(), + repo_started: false, tree_state, worktree: None, worktree_paths: Vec::new(), @@ -311,31 +294,40 @@ impl RepoDetailView { push_statuses: Vec::new(), focus_handle: cx.focus_handle(), _subscriptions: subscriptions, - } + }; + + view.attach_store(&store, window, cx); + view } - /// The latest announcement from the store or the open-time snapshot. - /// `None` for local repositories that haven't been published yet. + /// The latest announcement of the repository, `None` while local-only or + /// until the store's first pass loads it. fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { - let store = self.store.as_ref()?; - store - .read(cx) - .announcement - .as_ref() - .or(self.initial.as_ref()) + self.store.read(cx).announcement.as_ref() } /// Display name, the announcement's name or ID for announced repositories. /// The directory name for local ones. fn display_name(&self, cx: &App) -> SharedString { - if let Some(path) = &self.local_path { - return SharedString::from( - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()), - ); + let store = self.store.read(cx); + + if store.addr().is_none() { + return store + .path + .as_ref() + .map(|path| { + SharedString::from( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ) + }) + .unwrap_or_default(); } - self.announcement(cx) + + store + .announcement + .as_ref() .map(|announcement| { announcement .name @@ -386,8 +378,10 @@ impl Render for RepoDetailView { // 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)) + .read(cx) + .last_error + .clone() + .map(SharedString::from) }); v_flex() @@ -405,9 +399,7 @@ impl Render for RepoDetailView { .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); - } + this.store.update(cx, |store, _| store.last_error = None); cx.notify(); })), ) diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs index 9279200..4653b22 100644 --- a/crates/workspace/src/views/repo/store.rs +++ b/crates/workspace/src/views/repo/store.rs @@ -1,4 +1,3 @@ -use gpui::prelude::*; use gpui::{Context, Entity, Window}; use signed_core::Announcement; use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; @@ -6,33 +5,29 @@ use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; use super::RepoDetailView; impl RepoDetailView { - /// Switch the repository into its NIP-34 mode after a successful init. - /// Creates the nostr store for the announced repository. - /// Drops the local scan identity. - /// The worktree is unchanged, so the explorer keeps its loaded content. + /// Switch a local repository into its NIP-34 mode after a successful init. + /// The store is kept, so the panel keeps its path and loaded worktree. + /// Drops the local scan identity so it leaves the sidebar's local section. pub(crate) fn apply_announcement( &mut self, announcement: Announcement, - window: &mut Window, cx: &mut Context, ) { - // The repository is no longer a bare local repo. - // Drop it from the scan results so it leaves the sidebar's local section. - if let Some(path) = self.local_path.take() { + let path = self.store.read(cx).path.clone(); + if let Some(path) = path { LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); } - let store = - cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); - // Re-render on store refreshes, issues, PRs and statuses. - // Keep the ready-to-contribute statuses of this repository requested. - self.attach_store(&store, window, cx); - self.store = Some(store); - self.initial = Some(announcement); + + self.store + .update(cx, |store, cx| store.announce(announcement, cx)); + + // The new address needs its ready-to-contribute statuses requested. + self.refresh_ready_statuses(cx); cx.notify(); } - /// Observe the repository's store, re-render on refreshes. - /// Request the ready-to-contribute statuses for it. + /// Observe the repository's store and re-render on its refreshes. + /// Start the explorer once the store has an announcement. pub(super) fn attach_store( &mut self, store: &Entity, @@ -42,18 +37,9 @@ impl RepoDetailView { self._subscriptions .push(cx.observe_in(store, window, |this, store, window, cx| { this.refresh_ready_statuses(cx); - - // A repository opened from its address alone starts without an - // announcement. Adopt the store's first one so the explorer can - // load; later passes leave the snapshot and the selection alone. - let announcement = store.read(cx).announcement.clone(); - if this.initial.is_none() - && let Some(announcement) = announcement - { - this.initial = Some(announcement); + if !this.repo_started && store.read(cx).announcement.is_some() { this.load_repo(window, cx); } - cx.notify(); })); self.refresh_ready_statuses(cx); @@ -64,11 +50,11 @@ impl RepoDetailView { /// Owned repositories are watched for unpushed commits. /// Other repositories for ready-to-contribute checkouts. fn refresh_ready_statuses(&mut self, cx: &mut Context) { - let Some(entity) = self.store.clone() else { + let Some(addr) = self.store.read(cx).addr().cloned() else { return; }; - let head = entity.read(cx).head.clone(); + let head = self.store.read(cx).head.clone(); if self.ready_requested && self.ready_head == head { return; @@ -77,14 +63,13 @@ impl RepoDetailView { self.ready_requested = true; self.ready_head = head.clone(); - let addr = entity.read(cx).addr().clone(); let backend = Backend::global(cx); let checkout = CheckoutsStore::global(cx); let owned = backend .read(cx) .current_user() - .is_some_and(|user| entity.read(cx).is_author(&user)); + .is_some_and(|user| self.store.read(cx).is_author(&user)); checkout.update(cx, |store, cx| { // The ready statuses keep the fast poll running while the panel is open. @@ -97,16 +82,12 @@ impl RepoDetailView { }); } - /// The ready-to-push statuses of this repository in the global checkouts - /// store changed since they last drove a render. - /// - /// Updates the cached slices. `None` store (a local, not yet published, - /// repository) has no statuses. + /// The ready-to-contribute and ready-to-push statuses of this repository. pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { - let Some(entity) = self.store.clone() else { + let Some(addr) = self.store.read(cx).addr().cloned() else { return false; }; - let addr = entity.read(cx).addr().clone(); + let checkouts = CheckoutsStore::global(cx).read(cx); let ready_statuses = checkouts.ready_statuses_of(&addr); let push_statuses = checkouts.push_statuses_of(&addr); diff --git a/docs/repo-state-plan.md b/docs/repo-state-plan.md index 5ab7777..e35db14 100644 --- a/docs/repo-state-plan.md +++ b/docs/repo-state-plan.md @@ -1,6 +1,6 @@ # Repository state and panel flow plan -Status: phases 1-2 implemented, phase 3 next (2026-09-13) +Status: phases 1-3 implemented (2026-09-13) Builds on `docs/backend-rearchitecture.md`, especially §7 (notify audit), §11 (split independently-observed state), §12 (one debounce at the source) @@ -281,27 +281,56 @@ Status: implemented. 3. `open_repo_panel` and `RepoDetailView::new` take an address plus an optional hint, so a repository panel opens from a `RepoAddr` alone. This pulls the address-based constructor forward from Phase 3 step 2. -4. `RepoDetailView::attach_store` adopts the store's first announcement when - `initial` is still empty and calls `load_repo`, so a panel opened by address - fills in instead of waiting for the caller to have the announcement. +4. `RepoDetailView::attach_store` starts the explorer from the store's first + announcement when the panel was opened by address alone, so a panel opened + by address fills in instead of waiting for the caller to have the + announcement. Phase 3 moves this onto the single store observer, gated by + `repo_started`. 5. `open_upstream`: the 60 x 250 ms poll and the `pending_upstream` field are gone. It opens the panel by address; the store's `subscribe_remote` fetches the announcement from the bootstrap relays and step 4 loads the explorer. ### Phase 3 - one entity for local and NIP-34 -1. `signed_state/src/repo.rs`: `addr`/`path` options, `new_local`, - `announce`, `Option`, action guards. -2. `views/repo/mod.rs`: single `store` field; `new_local`; header, - display name, `load_repo`, `open_init_dialog` derive from the store. The - address-based `new` is already in place from Phase 2. -3. `views/repo/store.rs`: always observe; `refresh_statuses` returns false - when not announced. -4. `views/repo/actions.rs`, `header.rs`, `banners.rs`: drop - `Option>` guards, guard on `addr()` instead. +Status: implemented. + +1. `signed_state/src/repo.rs`: `addr: Option`, + `path: Option`, `announcement: Option`, + `_subscription: Option`. `new(addr, hint, cx)` seeds the + announcement and relays from the hint; `new_local(path)`; `announce` + switches a local store to NIP-34 in place, keeping `path`. `addr()` returns + `Option<&RepoAddr>`; `refresh`/`connect_announced_relays`/`subscribe_remote` + no-op without an address. Nostr-side actions guard with `not_announced` + (unit actions) or `action_error` (task actions). +2. `views/repo/mod.rs`: one `store: Entity` field. `initial` and + `local_path` are deleted; `new_local` builds a local store. The store + observer starts the explorer once an announcement lands, tracked by + `repo_started`. `display_name`, `load_repo` and `open_init_dialog` derive + their mode from `addr()`/`path` instead of the removed fields. +3. `views/repo/store.rs`: the store is observed from construction for both + modes; `apply_announcement` calls `store.announce` on the existing entity. + `refresh_ready_statuses` and `refresh_statuses` return early when `addr()` + is `None`. +4. `views/repo/{actions,header,banners,loading}.rs`: the + `Option>` guards are gone. Announced-only entry points + (issue/PR lists, new PR, send patch) guard on `addr()`; `NewPullRequestView` + and `PullRequestDetailView` thread the address option through their + prefill/binding paths. 5. `LocalReposStore` stays as the scan index; `CheckoutsStore` stays the association authority. +Deviations from the sketch above: + +- `path` is set only by `new_local` and kept by `announce`. `new` does not + resolve an associated checkout: the explorer still mirrors the cache for + announced repositories, so a stored checkout path would be dead weight. + The field is the seam for the open question below. +- `new_local` takes no `Context`: a local store has nothing to subscribe to and + no first pass to defer. +- `new` keeps the open-time hint until the first pass has confirmed what the + database holds, so a panel opened from a hint renders before the query lands + and still adopts a later deletion. + ### Phase 4 - deferred, only if duplicate stores become a problem One store per address via `HashMap>` inside -- 2.54.0 From b3991810b6b5304f2db8cd23c5911d0c41b2b8d1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 13:36:27 +0700 Subject: [PATCH 07/12] clean up --- Cargo.lock | 1 - crates/assets/src/lib.rs | 58 - crates/dock/src/tiles.rs | 1 - crates/dock/src/window_controls.rs | 1 - crates/paths/src/lib.rs | 4 - crates/settings/src/settings.rs | 69 +- crates/settings/src/store.rs | 30 - crates/signed_core/Cargo.toml | 3 - crates/signed_core/src/addr.rs | 16 - crates/signed_core/src/annotations.rs | 62 - crates/signed_core/src/clone_url.rs | 29 - crates/signed_core/src/filters.rs | 21 - crates/signed_core/src/inbox.rs | 175 --- crates/signed_core/src/model.rs | 133 -- crates/signed_core/src/state.rs | 34 - crates/signed_core/src/status.rs | 62 - crates/signed_git/src/cache.rs | 6 - crates/signed_git/src/diff.rs | 11 +- crates/signed_git/src/history.rs | 10 +- crates/signed_git/src/lib.rs | 2 - crates/signed_git/src/patch.rs | 9 +- crates/signed_git/src/remote.rs | 3 - crates/signed_git/src/repo.rs | 15 +- crates/signed_git/src/scan.rs | 8 +- crates/signed_git/src/tests.rs | 742 +-------- crates/signed_git/src/worktree.rs | 10 - crates/signed_nostr/src/update.rs | 1 - crates/signed_state/src/backend.rs | 152 -- crates/signed_state/src/checkouts.rs | 108 +- crates/signed_state/src/git_store.rs | 3 - crates/signed_state/src/inbox.rs | 11 +- crates/signed_state/src/lib.rs | 1 - crates/signed_state/src/profile.rs | 6 - crates/signed_state/src/refresh.rs | 7 - crates/signed_state/src/repo.rs | 52 - crates/signed_state/src/repos.rs | 11 - crates/signed_ui/src/dropdown_button.rs | 21 - crates/signed_ui/src/nav_item.rs | 1 - crates/signed_ui/src/pixel_avatar.rs | 28 +- crates/signed_ui/src/setting.rs | 2 - crates/signed_ui/src/tree_row.rs | 1 - crates/signed_ui/src/util.rs | 1 - crates/utils/src/time.rs | 6 +- crates/workspace/src/views/commit_diff/mod.rs | 25 - crates/workspace/src/views/dialog_state.rs | 7 +- crates/workspace/src/views/inbox.rs | 29 +- crates/workspace/src/views/issues/detail.rs | 3 - crates/workspace/src/views/issues/mod.rs | 23 +- .../src/views/pull_requests/detail.rs | 55 +- .../workspace/src/views/pull_requests/mod.rs | 29 +- .../workspace/src/views/pull_requests/new.rs | 52 +- crates/workspace/src/views/repo/about.rs | 12 - crates/workspace/src/views/repo/actions.rs | 15 +- crates/workspace/src/views/repo/banners.rs | 5 - crates/workspace/src/views/repo/files.rs | 28 +- crates/workspace/src/views/repo/header.rs | 11 - crates/workspace/src/views/repo/helpers.rs | 80 +- crates/workspace/src/views/repo/history.rs | 10 - .../workspace/src/views/repo/init_dialog.rs | 6 - crates/workspace/src/views/repo/loading.rs | 13 +- crates/workspace/src/views/repo/mod.rs | 79 +- crates/workspace/src/views/repo/refs.rs | 7 - crates/workspace/src/views/repo/store.rs | 3 - crates/workspace/src/views/repo_list.rs | 14 - crates/workspace/src/views/send_patch.rs | 8 - .../src/views/sidebar/create_repo_dialog.rs | 7 +- .../src/views/sidebar/grasp_servers.rs | 16 +- .../src/views/sidebar/import_dialog.rs | 1 - crates/workspace/src/views/sidebar/mod.rs | 34 +- .../src/views/sidebar/onboarding_dialog.rs | 3 +- .../src/views/sidebar/passphrase_dialog.rs | 7 +- .../src/views/sidebar/settings_dialog.rs | 36 +- crates/workspace/src/workspace.rs | 5 - desktop/src/main.rs | 6 +- docs/backend-rearchitecture.md | 1384 ----------------- docs/inbox-plan.md | 973 ------------ docs/repo-state-plan.md | 372 ----- 77 files changed, 96 insertions(+), 5189 deletions(-) delete mode 100644 docs/backend-rearchitecture.md delete mode 100644 docs/inbox-plan.md delete mode 100644 docs/repo-state-plan.md diff --git a/Cargo.lock b/Cargo.lock index 7dc6af9..a67be4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7968,7 +7968,6 @@ version = "0.1.0-alpha" dependencies = [ "nostr", "serde", - "serde_json", ] [[package]] diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 68d3d6f..ba31461 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -111,61 +111,3 @@ impl IconNamed for CustomIconName { .into() } } - -#[cfg(test)] -mod tests { - use super::*; - - fn signed_theme_set() -> gpui_component::ThemeSet { - let themes = Assets.themes(); - assert_eq!(themes.len(), 1, "expected exactly one embedded theme file"); - let (name, content) = &themes[0]; - assert_eq!(name, "signed.json"); - serde_json::from_str(content).expect("theme file must be a valid ThemeSet") - } - - #[test] - fn signed_theme_set_parses() { - let set = signed_theme_set(); - let names: Vec<&str> = set.themes.iter().map(|t| t.name.as_ref()).collect(); - assert_eq!(names, vec!["Signed Light", "Signed Dark"]); - } - - #[test] - fn signed_theme_palette_applies() { - let set = signed_theme_set(); - let parse = |hex: &str| gpui_component::try_parse_color(hex).unwrap(); - for config in &set.themes { - let mut theme = gpui_component::Theme::default(); - theme.apply_config(&std::rc::Rc::new(config.clone())); - - assert_eq!(theme.mode, config.mode); - // The resolved colors must match the brand palette. - assert_eq!(theme.primary, parse("#C6FF4D")); // nostr-lime - assert_eq!(theme.success, parse("#2FBF71")); // merge - assert_eq!(theme.primary_active, parse("#65A30D")); // lime-600 - - if config.mode.is_dark() { - // Dark theme chrome is neutral, mirroring the light theme. - assert_eq!(theme.background, parse("#0A0A0A")); // neutral-950 - assert_eq!(theme.border, parse("#27272A")); // neutral-800 - assert_eq!(theme.green, parse("#22C55E")); // green-500 - } else { - // Light theme chrome is neutral, lime is a brand accent only. - assert_eq!(theme.background, parse("#FFFFFF")); - assert_eq!(theme.foreground, parse("#18181B")); - assert_eq!(theme.border, parse("#E4E4E7")); - assert_eq!(theme.green, parse("#16A34A")); - } - // Active tab, a paler lime on light and a dim moss on dark. - // Each is paired with readable contrasting text. - if config.mode.is_dark() { - assert_eq!(theme.tab_active, parse("#19200A")); // dim lime - assert_eq!(theme.tab_active_foreground, parse("#C6FF4D")); // nostr-lime - } else { - assert_eq!(theme.tab_active, parse("#EBFFC1")); // pale nostr-lime - assert_eq!(theme.tab_active_foreground, parse("#3F6212")); // deep-lime - } - } - } -} diff --git a/crates/dock/src/tiles.rs b/crates/dock/src/tiles.rs index d511d99..39e6000 100644 --- a/crates/dock/src/tiles.rs +++ b/crates/dock/src/tiles.rs @@ -59,7 +59,6 @@ impl SignedTilesSkin { } } - /// One edge or corner handle. fn resize_handle( &self, tile: &TileContext, diff --git a/crates/dock/src/window_controls.rs b/crates/dock/src/window_controls.rs index 1f9e833..b441ca7 100644 --- a/crates/dock/src/window_controls.rs +++ b/crates/dock/src/window_controls.rs @@ -7,7 +7,6 @@ use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex}; use crate::TAB_BAR_HEIGHT; -/// The standard width of a window control button. const CONTROL_WIDTH: f32 = 34.; #[derive(IntoElement, Clone)] diff --git a/crates/paths/src/lib.rs b/crates/paths/src/lib.rs index 7668e60..f370bad 100644 --- a/crates/paths/src/lib.rs +++ b/crates/paths/src/lib.rs @@ -23,7 +23,6 @@ static CURRENT_DATA_DIR: OnceLock = OnceLock::new(); /// On Windows, this is `%APPDATA%\Signed`. static CONFIG_DIR: OnceLock = OnceLock::new(); -/// Returns the current user's home directory. pub fn home_dir() -> PathBuf { dirs::home_dir().expect("failed to determine home directory") } @@ -42,7 +41,6 @@ pub fn documents_dir() -> PathBuf { dirs::document_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default()) } -/// Returns the path to the configuration directory. pub fn config_dir() -> &'static PathBuf { CONFIG_DIR.get_or_init(|| { if cfg!(target_os = "windows") { @@ -62,7 +60,6 @@ pub fn config_dir() -> &'static PathBuf { }) } -/// Returns the path to the data directory. pub fn data_dir() -> &'static PathBuf { CURRENT_DATA_DIR.get_or_init(|| { if cfg!(target_os = "macos") { @@ -98,7 +95,6 @@ pub fn repos_dir() -> &'static PathBuf { REPOS_DIR.get_or_init(|| data_dir().join("repos")) } -/// Returns the path to the `settings.json` file. pub fn settings_file() -> &'static PathBuf { static SETTINGS_FILE: OnceLock = OnceLock::new(); SETTINGS_FILE.get_or_init(|| config_dir().join("settings.json")) diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 75406a6..323cd6e 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -2,29 +2,23 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -/// The default grasp servers, -/// offered while the user has not published a grasp list. +/// The default grasp servers, offered while the user has not published a grasp list. pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [ "wss://relay.ngit.dev", "wss://gitnostr.com", "wss://git.shakespeare.diy", ]; -/// How the application picks its appearance. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AppearanceMode { - /// Follow the system appearance, light or dark, at runtime. #[default] System, - /// Always use the light theme. Light, - /// Always use the dark theme. Dark, } -/// Theme configuration, -/// fields mirror the gpui-component `Theme` surface customized at startup. +/// Fields mirror the gpui-component `Theme` surface customized at startup. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ThemeSettings { @@ -42,7 +36,6 @@ pub struct ThemeSettings { pub radius_lg: f32, /// Whether focused controls draw a ring outside their border. pub focus_ring: bool, - /// Whether to render shadows. pub shadow: bool, } @@ -61,7 +54,6 @@ impl Default for ThemeSettings { } } -/// Default grasp server settings. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct GraspServersSettings { @@ -80,7 +72,6 @@ impl Default for GraspServersSettings { } } -/// Local repository scanning. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct LocalReposSettings { @@ -108,7 +99,6 @@ impl Default for LocalReposSettings { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] pub struct CheckoutRecord { - /// Local folder of the checkout. pub path: PathBuf, /// Repository address as a string, `30617::`. pub addr: String, @@ -116,16 +106,13 @@ pub struct CheckoutRecord { pub last_used: u64, } -/// Remembered local checkouts, see [`CheckoutRecord`]. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct CheckoutsSettings { - /// The remembered records. /// The latest use of a path and repo pair replaces the older record. pub records: Vec, } -/// The create-repository dialog. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct CreateRepositorySettings { @@ -137,17 +124,11 @@ pub struct CreateRepositorySettings { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Settings { - /// How the application picks its appearance. pub appearance: AppearanceMode, - /// Theme configuration. pub theme: ThemeSettings, - /// Default grasp servers. pub grasp_servers: GraspServersSettings, - /// Local repository scanning. pub local_repos: LocalReposSettings, - /// Remembered local checkouts. pub checkouts: CheckoutsSettings, - /// The create-repository dialog. pub create_repository: CreateRepositorySettings, } @@ -155,30 +136,6 @@ pub struct Settings { mod tests { use super::*; - #[test] - fn defaults_match_the_app_conventions() { - let settings = Settings::default(); - assert_eq!(settings.appearance, AppearanceMode::System); - assert_eq!(settings.theme.light_theme, "Signed Light"); - assert_eq!(settings.theme.dark_theme, "Signed Dark"); - assert_eq!(settings.theme.font_size, 16.0); - assert_eq!(settings.theme.mono_font_size, 13.0); - assert_eq!(settings.theme.radius, 2.0); - assert_eq!(settings.theme.radius_lg, 6.0); - assert!(!settings.theme.focus_ring); - assert!(!settings.theme.shadow); - assert_eq!( - settings.grasp_servers.default_servers, - DEFAULT_GRASP_SERVERS.map(String::from).to_vec() - ); - assert_eq!(settings.local_repos.scan_paths.len(), 2); - assert_eq!( - settings.local_repos.scan_paths, - vec![paths::desktop_dir(), paths::documents_dir()] - ); - assert_eq!(settings.create_repository.default_folder, None); - } - #[test] fn json_roundtrip_preserves_everything() { let settings = Settings { @@ -198,12 +155,6 @@ mod tests { assert_eq!(parsed, settings); } - #[test] - fn missing_keys_fall_back_to_defaults() { - let settings: Settings = serde_json::from_str("{}").unwrap(); - assert_eq!(settings, Settings::default()); - } - #[test] fn partial_json_merges_with_defaults() { let settings: Settings = @@ -215,20 +166,4 @@ mod tests { assert_eq!(settings.grasp_servers, GraspServersSettings::default()); assert_eq!(settings.create_repository.default_folder, None); } - - #[test] - fn appearance_serializes_to_snake_case_names() { - assert_eq!( - serde_json::to_string(&AppearanceMode::System).unwrap(), - "\"system\"" - ); - assert_eq!( - serde_json::to_string(&AppearanceMode::Light).unwrap(), - "\"light\"" - ); - assert_eq!( - serde_json::to_string(&AppearanceMode::Dark).unwrap(), - "\"dark\"" - ); - } } diff --git a/crates/settings/src/store.rs b/crates/settings/src/store.rs index 87bc1de..bca510c 100644 --- a/crates/settings/src/store.rs +++ b/crates/settings/src/store.rs @@ -24,7 +24,6 @@ impl SettingsStore { cx.global::().0.clone() } - /// Install the store as a global. pub fn set_global(entity: Entity, cx: &mut App) { cx.set_global(GlobalSettingsStore(entity)); } @@ -103,8 +102,6 @@ impl SettingsStore { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use gpui::{AppContext, TestAppContext}; - use super::*; static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0); @@ -133,16 +130,6 @@ mod tests { cleanup(&path); } - #[test] - fn corrupt_file_loads_defaults() { - let path = temp_settings_path(); - std::fs::write(&path, "{ not json").unwrap(); - - let settings = SettingsStore::load(&path); - assert_eq!(settings, Settings::default()); - cleanup(&path); - } - #[test] fn save_and_load_roundtrip() { let path = temp_settings_path(); @@ -160,21 +147,4 @@ mod tests { assert_eq!(SettingsStore::load(&path), expected); cleanup(&path); } - - #[gpui::test] - fn edit_mutates_and_persists(cx: &mut TestAppContext) { - let path = temp_settings_path(); - cleanup(&path); - - let store = cx.update(|cx| cx.new(|cx| SettingsStore::new(path.clone(), cx))); - cx.read(|cx| assert_eq!(store.read(cx).settings(), &Settings::default())); - - store.update(cx, |store, cx| { - store.edit(|settings| settings.theme.radius = 12.0, cx); - }); - - cx.read(|cx| assert_eq!(store.read(cx).settings().theme.radius, 12.0)); - assert_eq!(SettingsStore::load(&path).theme.radius, 12.0); - cleanup(&path); - } } diff --git a/crates/signed_core/Cargo.toml b/crates/signed_core/Cargo.toml index 7328213..f4d90a4 100644 --- a/crates/signed_core/Cargo.toml +++ b/crates/signed_core/Cargo.toml @@ -7,6 +7,3 @@ publish.workspace = true [dependencies] nostr.workspace = true serde.workspace = true - -[dev-dependencies] -serde_json.workspace = true diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index 729322b..79d57fc 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -6,12 +6,10 @@ use nostr::prelude::*; /// the alias reuses the SDK type while keeping repository-specific vocabulary. pub type RepoAddr = Coordinate; -/// Build the address of a NIP-34 repository announcement. pub fn repo_addr(owner: PublicKey, id: impl Into) -> RepoAddr { Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id) } -/// Derive a repository identifier from a display name pub fn identifier_from_name(name: &str) -> String { name.chars() .map(|c| { @@ -23,17 +21,3 @@ pub fn identifier_from_name(name: &str) -> String { }) .collect() } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn identifier_from_name_slugs_like_gitworkshop() { - assert_eq!(identifier_from_name("My Repo"), "My-Repo"); - assert_eq!(identifier_from_name("my-repo"), "my-repo"); - assert_eq!(identifier_from_name("Foo_Bar!"), "Foo-Bar-"); - assert_eq!(identifier_from_name("a/b"), "a/b"); - assert_eq!(identifier_from_name("Café"), "Caf-"); - } -} diff --git a/crates/signed_core/src/annotations.rs b/crates/signed_core/src/annotations.rs index affa928..e45365d 100644 --- a/crates/signed_core/src/annotations.rs +++ b/crates/signed_core/src/annotations.rs @@ -196,61 +196,6 @@ mod tests { assert_eq!(labels, vec!["bug", "help-wanted"]); } - #[test] - fn labels_ignore_unauthorized_and_misnamed_events() { - let root = root_event(); - let maintainer = - keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002"); - let stranger = - keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003"); - - // A stranger's label event is not authorized. - let stranger_labels = signed( - &stranger, - Kind::Label, - vec![ - e_tag(&root), - Tag::parse(["L", "#t"]).expect("valid L tag"), - Tag::parse(["l", "nope", "#t"]).expect("valid l tag"), - ], - 200, - ); - // A valid author referencing a different event. - let other_labels = signed( - &maintainer, - Kind::Label, - vec![ - Tag::parse([ - "e", - "2222222222222222222222222222222222222222222222222222222222222222", - ]) - .expect("valid e tag"), - Tag::parse(["L", "#t"]).expect("valid L tag"), - Tag::parse(["l", "nope", "#t"]).expect("valid l tag"), - ], - 200, - ); - // A valid author without the namespace declaration. - let missing_namespace = signed( - &maintainer, - Kind::Label, - vec![ - e_tag(&root), - Tag::parse(["l", "nope", "#t"]).expect("valid l tag"), - ], - 200, - ); - - assert_eq!( - labels( - &root, - &[stranger_labels, other_labels, missing_namespace], - &[maintainer.public_key()] - ), - vec!["bug"] - ); - } - #[test] fn subject_override_latest_authorized_event_wins() { let root = root_event(); @@ -300,11 +245,4 @@ mod tests { let note = cover_note(&root, &events, &maintainers); assert_eq!(note.map(|event| event.id), Some(newer_id)); } - - #[test] - fn cover_note_none_without_valid_events() { - let root = root_event(); - - assert_eq!(cover_note(&root, &[], &[]), None); - } } diff --git a/crates/signed_core/src/clone_url.rs b/crates/signed_core/src/clone_url.rs index e212b28..b66bf66 100644 --- a/crates/signed_core/src/clone_url.rs +++ b/crates/signed_core/src/clone_url.rs @@ -69,35 +69,6 @@ fn percent_decode(input: &str) -> String { mod tests { use super::*; - #[test] - fn parses_user_repo_without_relay() { - let target = parse_clone_url( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit", - ) - .unwrap(); - assert_eq!( - target, - CloneTarget::UserRepo { - user: "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr".to_owned(), - relay_hint: None, - identifier: "ngit".to_owned(), - } - ); - } - - #[test] - fn parses_user_repo_with_relay_hint() { - let target = parse_clone_url("nostr://danconwaydev.com/relay.ngit.dev/ngit").unwrap(); - assert_eq!( - target, - CloneTarget::UserRepo { - user: "danconwaydev.com".to_owned(), - relay_hint: RelayUrl::parse("relay.ngit.dev").ok(), - identifier: "ngit".to_owned(), - } - ); - } - #[test] fn decodes_percent_encoded_parts() { let target = parse_clone_url( diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 5b040a3..04a6f18 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -253,13 +253,6 @@ mod tests { Tag::parse([name, &kind.as_u16().to_string()]).expect("valid kind tag") } - #[test] - fn root_git_kinds_are_activity() { - for kind in [Kind::GitIssue, Kind::GitPatch, Kind::GitPullRequest] { - assert!(is_git_activity(&signed(&keys(1), kind, Vec::new()))); - } - } - #[test] fn comment_activity_depends_on_the_uppercase_k_tag() { let on_git = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::GitIssue)]); @@ -307,18 +300,4 @@ mod tests { Vec::new() ))); } - - #[test] - fn non_git_kinds_are_not_activity() { - assert!(!is_git_activity(&signed( - &keys(1), - Kind::TextNote, - Vec::new() - ))); - assert!(!is_git_activity(&signed( - &keys(1), - Kind::GitPullRequestUpdate, - Vec::new(), - ))); - } } diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index 463ad48..138e7d6 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -44,7 +44,6 @@ impl InboxItem { .unwrap_or_else(|| "Untitled".to_string()) } - /// Kind shown for the thread. pub fn kind(&self) -> Option { self.root_kind.or_else(|| { self.root_event @@ -102,7 +101,6 @@ impl InboxItem { !self.archived && !self.unread_ids.is_empty() } - /// Recompute the unread and archived flags from `state`. pub fn apply_state(&mut self, state: &InboxReadState) { self.unread_ids = self .events @@ -468,10 +466,6 @@ mod tests { Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag") } - fn a_tag(owner: &PublicKey, id: &str) -> Tag { - Tag::parse(["a", &format!("30617:{}:{id}", owner.to_hex())]).expect("valid a tag") - } - fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option + '_ { move |id| events.iter().find(|event| event.id == id).cloned() } @@ -489,18 +483,6 @@ mod tests { ) } - #[test] - fn issue_and_pull_request_are_their_own_root() { - let events = [ - issue(&keys(1), 100), - signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100), - ]; - let lookup = lookup(&events); - for event in &events { - assert_eq!(notification_root(event, &lookup), Some(event.id)); - } - } - #[test] fn comment_resolves_to_its_uppercase_root() { let issue = issue(&keys(1), 100); @@ -520,17 +502,6 @@ mod tests { ); } - #[test] - fn comment_without_root_pointer_has_no_root() { - let comment = signed( - &keys(2), - Kind::Comment, - vec![e_tag(&issue(&keys(1), 100))], - 200, - ); - assert_eq!(notification_root(&comment, &lookup(&[])), None); - } - #[test] fn child_patch_resolves_to_the_root_patch() { let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100); @@ -555,19 +526,6 @@ mod tests { assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id)); } - #[test] - fn pull_request_update_resolves_via_uppercase_e() { - let pr = signed(&keys(1), Kind::GitPullRequest, Vec::new(), 100); - let update = signed( - &keys(2), - Kind::GitPullRequestUpdate, - vec![uppercase_e_tag(&pr)], - 200, - ); - let events = [pr.clone(), update.clone()]; - assert_eq!(notification_root(&update, &lookup(&events)), Some(pr.id)); - } - #[test] fn nested_comment_chain_follows_to_the_root() { let issue = issue(&keys(1), 100); @@ -577,96 +535,6 @@ mod tests { assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id)); } - #[test] - fn group_excludes_self_and_sorts_groups_newest_first() { - let me = keys(1); - let issue = issue(&keys(2), 100); - let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 300); - let other_issue = signed( - &keys(2), - Kind::GitIssue, - vec![Tag::parse(["p", &me.public_key().to_hex()]).expect("valid p tag")], - 200, - ); - let mine = signed(&keys(1), Kind::Comment, vec![uppercase_e_tag(&issue)], 400); - - let events = [issue.clone(), comment.clone(), other_issue.clone(), mine]; - let items = group( - events, - Vec::new(), - me.public_key(), - &InboxReadState::default(), - &lookup(&[]), - ); - - assert_eq!(items.len(), 2); - assert_eq!(items[0].root, issue.id); - // The issue itself plus the comment; the self-authored comment is out. - assert_eq!(items[0].events.len(), 2); - assert_eq!(items[1].root, other_issue.id); - } - - #[test] - fn group_reports_unread_oldest_first_and_archived() { - let me = keys(1); - let issue = issue(&keys(2), 100); - let older = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); - let newer = signed(&keys(4), Kind::Comment, vec![uppercase_e_tag(&issue)], 300); - - let events = [issue.clone(), older.clone(), newer.clone()]; - let items = group( - events, - Vec::new(), - me.public_key(), - &InboxReadState::default(), - &lookup(&[]), - ); - assert_eq!(items[0].unread_ids, vec![issue.id, older.id, newer.id]); - assert!(!items[0].archived); - assert!(items[0].is_unread()); - - let state = InboxReadState { - archived_before: Timestamp::from_secs(1000), - ..Default::default() - }; - let items = group( - [issue.clone(), older, newer], - Vec::new(), - me.public_key(), - &state, - &lookup(&[]), - ); - assert!(items[0].archived); - assert!(!items[0].unread_ids.is_empty()); - assert!(!items[0].is_unread()); - } - - #[test] - fn group_reads_root_kind_and_address_from_the_root_event() { - let me = keys(1); - let owner_keys = keys(2); - let owner = owner_keys.public_key(); - let issue = signed( - &owner_keys, - Kind::GitIssue, - vec![a_tag(&owner, "my-repo")], - 100, - ); - let comment = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); - - let events = [issue.clone(), comment]; - let items = group( - events.clone(), - Vec::new(), - me.public_key(), - &InboxReadState::default(), - &lookup(&events), - ); - - assert_eq!(items[0].root_kind, Some(Kind::GitIssue)); - assert_eq!(items[0].address, issue.tags.coordinates().next()); - } - #[test] fn group_merges_own_events_into_the_matching_thread() { let me = keys(1); @@ -809,47 +677,4 @@ mod tests { state.mark_archived(&event); assert_eq!(state.archived_ids, HashSet::from([event.id])); } - - #[test] - fn apply_state_recomputes_unread_and_archived() { - let now = Timestamp::from_secs(1_000_000_000); - let first = issue(&keys(2), now.as_secs() - 2000); - let second = issue(&keys(2), now.as_secs() - 1000); - let mut item = InboxItem { - root: first.id, - root_event: None, - root_kind: None, - address: None, - events: vec![second.clone(), first.clone()], - own_events: Vec::new(), - unread_ids: Vec::new(), - archived: false, - }; - - let state = InboxReadState { - read_before: first.created_at, - ..Default::default() - }; - item.apply_state(&state); - - assert_eq!(item.unread_ids, vec![second.id]); - assert!(!item.archived); - } - - #[test] - fn serde_round_trip_preserves_state() { - let first = issue(&keys(1), 100); - let second = issue(&keys(2), 200); - let state = InboxReadState { - read_before: Timestamp::from_secs(150), - read_ids: HashSet::from([second.id]), - archived_before: Timestamp::from_secs(50), - archived_ids: HashSet::from([first.id]), - }; - - let json = serde_json::to_string(&state).expect("serialized"); - let parsed: InboxReadState = serde_json::from_str(&json).expect("deserialized"); - - assert_eq!(parsed, state); - } } diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index e600c52..b5bc37b 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -46,7 +46,6 @@ pub struct Upstream { } impl Upstream { - /// Parse the `u` tag values. fn parse(raw: &str, relay_hint: Option<&str>) -> Self { let coordinate = raw.split('|').next().unwrap_or(raw); let addr = coordinate @@ -60,7 +59,6 @@ impl Upstream { } } - /// Text for display. pub fn display(&self) -> String { match &self.addr { Some(addr) => addr.to_string(), @@ -143,7 +141,6 @@ pub fn pull_request_patches<'a>( series } -/// The patch content of a pull request. pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator) -> String { let patches: Vec<&'a Event> = patches.into_iter().collect(); let series = pull_request_patches(pr, patches.iter().copied()); @@ -342,7 +339,6 @@ impl Announcement { }) } - /// The repository address of this announcement. pub fn addr(&self) -> RepoAddr { repo_addr(self.owner, self.id.clone()) } @@ -409,7 +405,6 @@ mod tests { ) } - /// Build a signed kind `30617` event from raw tag values. fn announcement_event(tags: &[&[&str]]) -> Event { let tags: Vec = tags .iter() @@ -469,22 +464,6 @@ mod tests { assert_eq!(announcement.hashtags, vec!["rust", "nostr"]); } - #[test] - fn requires_d_tag() { - let event = announcement_event(&[&["name", "No id"]]); - - assert!(Announcement::from_event(&event).is_none()); - } - - #[test] - fn ignores_other_kinds() { - let event = EventBuilder::new(Kind::GitIssue, "") - .finalize(&keys()) - .expect("signed event"); - - assert!(Announcement::from_event(&event).is_none()); - } - #[test] fn drops_malformed_values() { let event = announcement_event(&[ @@ -505,17 +484,6 @@ mod tests { assert!(announcement.maintainers.is_empty()); } - #[test] - fn ignores_unknown_tags() { - let event = announcement_event(&[&["d", "my-repo"], &["t", "label"], &["subject", "n/a"]]); - - let announcement = Announcement::from_event(&event).expect("parses"); - - assert_eq!(announcement.id, "my-repo"); - assert!(announcement.name.is_none()); - assert!(announcement.web.is_empty()); - } - #[test] fn parses_upstream_tag() { let event = announcement_event(&[ @@ -553,25 +521,6 @@ mod tests { ); } - #[test] - fn parses_git_url_upstream() { - // The `u` tag may reference a non-nostr upstream by git URL only. - // There is no repository address to navigate to. - let event = announcement_event(&[ - &["d", "my-fork"], - &["u", "https://example.com/upstream.git"], - ]); - - let announcement = Announcement::from_event(&event).expect("parses"); - let upstream = announcement.upstream.expect("parses the u tag"); - - assert_eq!(upstream.addr, None); - assert_eq!( - upstream.display().to_string(), - "https://example.com/upstream.git" - ); - } - #[test] fn is_fork_of_matches_the_u_tag_coordinate() { // The base repository, announced by the `u` tag's owner. @@ -632,17 +581,6 @@ mod tests { assert!(fork.is_fork_of(&base, Some(base_euc))); } - #[test] - fn is_fork_of_excludes_the_base_itself() { - let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; - let event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]); - let base = Announcement::from_event(&event).expect("parses"); - let base_addr = base.addr(); - - // The base announcement matches its own EUC but is not a fork of itself. - assert!(!base.is_fork_of(&base_addr, base.euc.as_deref())); - } - #[test] fn effective_maintainers_include_owner_for_primary_repos() { let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]); @@ -677,7 +615,6 @@ mod tests { ); } - /// Build a signed PR event with the given tags and content. fn pr_event(content: &str, tags: Vec) -> Event { EventBuilder::new(Kind::GitPullRequest, content) .tags(tags) @@ -685,35 +622,6 @@ mod tests { .expect("signed event") } - #[test] - fn pull_request_patch_prefers_linked_patch_event() { - let patch = EventBuilder::new(Kind::GitPatch, "patch-content") - .finalize(&keys()) - .expect("signed event"); - let pr = pr_event("description", vec![Tag::event(patch.id)]); - - assert_eq!(pull_request_patch(&pr, [&patch]), "patch-content"); - } - - #[test] - fn pull_request_patch_falls_back_to_inline_content() { - // Older PRs carried the patch in the content and link no patch event. - let pr = pr_event("patch-inline", vec![]); - - assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline"); - } - - #[test] - fn pull_request_patch_ignores_unrelated_patch_events() { - let patch = EventBuilder::new(Kind::GitPatch, "patch-content") - .finalize(&keys()) - .expect("signed event"); - let pr = pr_event("description", vec![]); - - assert_eq!(pull_request_patch(&pr, [&patch]), "description"); - } - - /// Build a signed patch event with a controlled `created_at`. fn patch_event(content: &str, tags: Vec, created_at: u64) -> Event { EventBuilder::new(Kind::GitPatch, content) .tags(tags) @@ -757,24 +665,6 @@ mod tests { ); } - #[test] - fn pull_request_patches_ignores_unrelated_replies() { - let root = patch_event("patch-one", vec![], 100); - let other = patch_event("other-patch", vec![Tag::event(root.id)], 250); - // A patch replying to a different root is not part of the set. - let stranger = patch_event("stranger", vec![], 150); - let pr = pr_event("description", vec![Tag::event(root.id)]); - - let series = pull_request_patches(&pr, [&root, &other, &stranger]); - assert_eq!( - series - .iter() - .map(|p| p.content.as_str()) - .collect::>(), - vec!["patch-one", "other-patch"] - ); - } - #[test] fn pull_request_patches_finds_the_set_via_the_tip_commit() { // PRs without an `e` tag fall back to the patch producing the tip commit. @@ -807,7 +697,6 @@ mod tests { const COMMIT_HEX: &str = "1111111111111111111111111111111111111111"; const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222"; - /// Build a signed event of `kind` with the given tags and `created_at`. fn signed_at(kind: Kind, tags: Vec, created_at: u64) -> Event { EventBuilder::new(kind, "") .tags(tags) @@ -827,20 +716,6 @@ mod tests { ) } - #[test] - fn reads_current_commit_and_branch_name() { - let pr = pr_root(); - assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX)); - assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x")); - } - - #[test] - fn returns_none_without_pr_tags() { - let pr = signed_at(Kind::GitPullRequest, vec![], 100); - assert_eq!(current_commit_of(&pr), None); - assert_eq!(branch_name_of(&pr), None); - } - #[test] fn latest_update_picks_newest_revision_of_the_root() { let root = pr_root(); @@ -886,19 +761,12 @@ mod tests { assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); } - #[test] - fn latest_update_ignores_roots_without_revisions() { - let root = pr_root(); - assert!(latest_update([&root].into_iter(), &root).is_none()); - } - const OWNER_KEYS: [&str; 3] = [ "0000000000000000000000000000000000000000000000000000000000000001", "0000000000000000000000000000000000000000000000000000000000000002", "0000000000000000000000000000000000000000000000000000000000000003", ]; - /// Build a signed kind-30617 event for `owner` with the given tags. fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event { let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key")); let tags: Vec = tags @@ -960,7 +828,6 @@ mod tests { let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey"); let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user)); - // The user's fork comes first, then the other author's. let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); assert_eq!(ids, vec!["my-fork", "their-fork"]); } diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs index 53e9c9d..1e36932 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_core/src/state.rs @@ -55,7 +55,6 @@ mod tests { ) } - /// Build a signed kind `30618` event from raw tag values. fn state_event(tags: &[&[&str]]) -> Event { let tags: Vec = tags .iter() @@ -90,26 +89,6 @@ mod tests { ); } - #[test] - fn head_without_prefix_is_ignored() { - let event = state_event(&[&["HEAD", "main"]]); - - let (refs, head) = parse_state(&event); - - assert!(refs.is_empty()); - assert!(head.is_none()); - } - - #[test] - fn ignores_non_state_tags() { - let event = state_event(&[&["d", "my-repo"], &["name", "ignored"]]); - - let (refs, head) = parse_state(&event); - - assert!(refs.is_empty()); - assert!(head.is_none()); - } - #[test] fn build_state_round_trips_through_parse() { let refs = [ @@ -129,17 +108,4 @@ mod tests { assert_eq!(parsed_refs, refs); assert_eq!(head.as_deref(), Some("main")); } - - #[test] - fn build_state_omits_head_when_detached() { - let refs = [("refs/heads/main".to_owned(), COMMIT_A.to_owned())]; - - let event = build_state("my-repo", &refs, None) - .finalize(&keys()) - .expect("signed event"); - - let (parsed_refs, head) = parse_state(&event); - assert_eq!(parsed_refs, refs); - assert!(head.is_none()); - } } diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index abc24d2..4ce9e57 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -76,7 +76,6 @@ mod tests { EventId::from_hex(ROOT_ID_HEX).expect("valid event id") } - /// Build a signed status event with a controlled `created_at`. fn status_event(author: &Keys, kind: Kind, root: EventId, created_at: u64) -> Event { EventBuilder::new(kind, "") .tags([Tag::event(root)]) @@ -102,53 +101,6 @@ mod tests { )); } - #[test] - fn references_root_matches_uppercase_e_tag() { - let root = root_event_id(); - let event = EventBuilder::new(Kind::Comment, "") - .tags([Tag::parse(["E", ROOT_ID_HEX]).expect("valid E tag")]) - .finalize(&keys_from_hex( - "0000000000000000000000000000000000000000000000000000000000000001", - )) - .expect("signed event"); - - assert!(references_root(&event, &root)); - assert!(!references_root( - &event, - &EventId::from_hex(OTHER_ID_HEX).expect("valid id") - )); - } - - #[test] - fn references_root_false_without_e_tags() { - let event = EventBuilder::new(Kind::GitStatusOpen, "") - .finalize(&keys_from_hex( - "0000000000000000000000000000000000000000000000000000000000000001", - )) - .expect("signed event"); - - assert!(!references_root(&event, &root_event_id())); - } - - #[test] - fn defaults_to_open_without_status_events() { - let owner = - keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); - let maintainer = - keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002"); - - let statuses: Vec = Vec::new(); - - assert_eq!( - resolve_status( - statuses.iter(), - &owner.public_key(), - &[maintainer.public_key()] - ), - RepoStatus::Open - ); - } - #[test] fn latest_status_wins() { let owner = @@ -196,18 +148,4 @@ mod tests { RepoStatus::Draft ); } - - #[test] - fn ignores_non_status_kinds() { - let owner = - keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); - let root = root_event_id(); - - let statuses = [status_event(&owner, Kind::GitIssue, root, 100)]; - - assert_eq!( - resolve_status(statuses.iter(), &owner.public_key(), &[]), - RepoStatus::Open - ); - } } diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs index bdfdde1..2ae2e78 100644 --- a/crates/signed_git/src/cache.rs +++ b/crates/signed_git/src/cache.rs @@ -16,19 +16,16 @@ impl GitCache { Self { root } } - /// The root directory holding the mirror clones. pub fn root(&self) -> &Path { &self.root } - /// Local path of the clone for a repository. pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root .join(addr.public_key.to_hex()) .join(sanitize_path_component(&addr.identifier)) } - /// Open an existing clone. pub fn open(&self, addr: &RepoAddr) -> Result> { let path = self.repo_path(addr); match gix::open(&path) { @@ -64,9 +61,6 @@ impl GitCache { } /// Map an untrusted repository id or display name to a safe single path component. -/// -/// Everything outside `[A-Za-z0-9._-]` becomes `_`. -/// An id that maps to exactly `.` or `..` becomes `_`. pub fn sanitize_path_component(id: &str) -> String { let sanitized: String = id .chars() diff --git a/crates/signed_git/src/diff.rs b/crates/signed_git/src/diff.rs index cffc382..8e0afb3 100644 --- a/crates/signed_git/src/diff.rs +++ b/crates/signed_git/src/diff.rs @@ -3,18 +3,14 @@ use std::path::Path; use anyhow::Result; use gix::diff::blob::unified_diff::{ConsumeHunk, DiffLineKind as GixLineKind, HunkHeader}; -/// The kind of a [`DiffLine`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffLineKind { /// An unchanged context line, present on both sides. Context, - /// A line added by the commit. Addition, - /// A line removed by the commit. Deletion, } -/// One line of a file diff. #[derive(Debug, Clone)] pub struct DiffLine { pub kind: DiffLineKind, @@ -31,16 +27,13 @@ pub struct DiffLine { pub struct DiffHunk { /// 1-based start line in the old version. pub old_start: u32, - /// Number of old lines covered by the hunk. pub old_lines: u32, /// 1-based start line in the new version. pub new_start: u32, - /// Number of new lines covered by the hunk. pub new_lines: u32, pub lines: Vec, } -/// How a file changed in a commit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DiffStatus { Added, @@ -50,7 +43,6 @@ pub enum DiffStatus { Copied, } -/// The diff of one file in a commit. #[derive(Debug, Clone)] pub struct FileDiff { /// Path of the file relative to the repo root. @@ -69,7 +61,6 @@ pub struct FileDiff { pub hunks: Vec, } -/// The changes of one commit. #[derive(Debug, Clone)] pub struct CommitDiff { pub files: Vec, @@ -110,7 +101,7 @@ pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Resu .tree()?; tree_diff(&repo, Some(&base_tree), &tip_tree) } -/// The changes between two trees. Used by both [`commit_diff`] and [`worktree_commit_range_diff`]. + fn tree_diff( repo: &gix::Repository, old_tree: Option<&gix::Tree<'_>>, diff --git a/crates/signed_git/src/history.rs b/crates/signed_git/src/history.rs index 731ce32..5c8ca1f 100644 --- a/crates/signed_git/src/history.rs +++ b/crates/signed_git/src/history.rs @@ -20,7 +20,6 @@ pub struct FileCommit { /// /// `None` for single-line commit messages. pub description: Option, - /// Author name. pub author: String, /// Author time, seconds since the Unix epoch. pub time: i64, @@ -36,7 +35,7 @@ pub(crate) fn open_with_cache(workdir: &Path) -> Result { Ok(repo) } -/// A [`FileCommit`] from a commit, with author, message title, body and shortened id. +/// A [`FileCommit`] with author, message title, body and shortened id. /// /// The diff panel fetches the full commit on demand. fn file_commit(commit: &gix::Commit<'_>) -> Result { @@ -50,7 +49,6 @@ fn file_commit_summary(commit: &gix::Commit<'_>) -> Result { file_commit_with_description(commit, false) } -/// [`file_commit`] and [`file_commit_summary`], `include_description` picks the body. fn file_commit_with_description( commit: &gix::Commit<'_>, include_description: bool, @@ -96,8 +94,6 @@ pub fn worktree_last_commits( last_commits(&open_with_cache(workdir)?, rels) } -/// The walk behind [`last_commit`] and [`worktree_last_commits`]. -/// /// Stops as soon as every pending path has its commit. fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result> { use gix::traverse::commit::simple::CommitTimeOrder; @@ -106,7 +102,6 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result = Vec::with_capacity(rels.len()); let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len()); @@ -136,7 +131,6 @@ fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result Result { Ok(CommitList { total, commits }) } -/// Like [`all_commits`], but opens the repository at `workdir` first. -/// /// For non-bare clones the clone root is the worktree. pub fn worktree_all_commits(workdir: &Path) -> Result { all_commits(&open_with_cache(workdir)?) diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index bb92b84..19c668a 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -38,8 +38,6 @@ pub use worktree::{ worktree_commits_ahead, worktree_dirty, worktree_entries, worktree_read, worktree_snapshot, }; -/// Run a git command in `dir`, returning trimmed stdout. -/// /// The terminal prompt is disabled so a credential request fails instead of hanging. #[cfg(test)] fn git_in(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { diff --git a/crates/signed_git/src/patch.rs b/crates/signed_git/src/patch.rs index 873c23a..cf67bdc 100644 --- a/crates/signed_git/src/patch.rs +++ b/crates/signed_git/src/patch.rs @@ -9,10 +9,11 @@ use diffy::{Hunk, Line}; use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff}; use crate::history::FileCommit; -/// Apply a `git format-patch` patch or series with `git am`, -/// uses the git CLI because it handles the mbox format natively. +/// Apply a `git format-patch` patch or series with `git am`. /// -/// TODO: Replaced with a pure-Rust implementation later without changing callers. +/// Uses the git CLI because it handles the mbox format natively. +/// +/// TODO: replace with a pure-Rust implementation later without changing callers. pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { let mut child = Command::new("git") .arg("am") @@ -116,7 +117,6 @@ pub fn patch_diffs(patch: &str) -> Result { Ok(CommitDiff { files }) } -/// The [`FileDiff`] of one parsed file patch. fn file_diff(file: FilePatch<'_, str>) -> Result { // The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path // component is dropped, the same way `git apply -p1` does. @@ -296,7 +296,6 @@ pub fn patch_commits(patch: &str) -> Vec { commits } -/// The name part of a `From: Name ` header value. fn name_from_address(from: &str) -> String { match from.trim().find('<') { Some(ix) => from[..ix].trim().to_string(), diff --git a/crates/signed_git/src/remote.rs b/crates/signed_git/src/remote.rs index 432eef0..6bbc4e4 100644 --- a/crates/signed_git/src/remote.rs +++ b/crates/signed_git/src/remote.rs @@ -41,7 +41,6 @@ pub fn fetch_all(repo: &gix::Repository) -> Result<()> { Ok(()) } -/// Push `commit` to `reference` on the server at `url`, from `repo_path`. pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> { let output = Command::new("git") .arg("-C") @@ -107,7 +106,6 @@ fn clone(url: &str, path: &Path) -> Result { Ok(repo) } -/// Push the `main` branch of the repository at `repo_path` to a grasp server. pub fn push_main(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> Result<()> { push_refspecs( repo_path, @@ -131,7 +129,6 @@ pub fn push_all(repo_path: &Path, base_url: &str, owner: &str, repo_id: &str) -> ) } -/// Push `refspecs` to the grasp server URL derived from `base_url`, `owner` and `repo_id`. fn push_refspecs( repo_path: &Path, base_url: &str, diff --git a/crates/signed_git/src/repo.rs b/crates/signed_git/src/repo.rs index 4b86afb..324b5d0 100644 --- a/crates/signed_git/src/repo.rs +++ b/crates/signed_git/src/repo.rs @@ -55,7 +55,6 @@ pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result }; let Some(base) = base else { - // `HEAD` alone when no base is given. return Ok(vec![head.to_string()]); }; @@ -168,7 +167,7 @@ pub fn init_repository(path: &Path, name: &str, description: &str) -> Result Result> { }; let Ok(head) = repo.head_id() else { - // An unborn HEAD with no commits yet has no root commit. return Ok(None); }; @@ -234,7 +232,6 @@ pub fn refs_with_prefix(repo_path: &Path, prefix: &str) -> Result> { } } - // Sort lexicographically, like `git for-each-ref`. names.sort(); Ok(names) @@ -267,7 +264,6 @@ pub fn delete_refs_with_prefix(repo_path: &Path, prefix: &str) -> Result<()> { }) .collect::>>()?; - // Delete all refs with the given prefix. repo.edit_references(edits)?; Ok(()) @@ -282,7 +278,6 @@ pub fn worktree_current_branch(workdir: &Path) -> Option { Some(String::from_utf8_lossy(name.shorten()).into_owned()) } -/// Whether the reference `name` exists in the repository at `workdir`. pub fn worktree_ref_exists(workdir: &Path, name: &str) -> bool { let Ok(repo) = gix::open(workdir) else { return false; @@ -374,10 +369,8 @@ pub fn fast_forward_branches(workdir: &Path) -> Result { let tree = repo.find_object(remote_oid)?.peel_to_tree()?.id; - // Check out the remote tree, discarding local changes. force_checkout(&repo, &tree)?; - // Update the branch reference to point to the remote tree. repo.edit_references_as( [edit(gix::refs::Target::Object(remote_oid))], Some(signature), @@ -385,7 +378,6 @@ pub fn fast_forward_branches(workdir: &Path) -> Result { moved = true; } else { - // Update the branch reference to point to the remote tree. repo.edit_references_as( [edit(gix::refs::Target::Object(remote_oid))], Some(signature), @@ -447,10 +439,6 @@ pub struct RepoRefState { pub head: Option, } -/// Collect the refs of `repo`. -/// -/// Local branches and tags become `(refname, commit-id)` pairs. -/// Also reports the branch HEAD points to. pub fn repo_ref_state(repo: &gix::Repository) -> Result { let mut refs = Vec::new(); @@ -482,7 +470,6 @@ pub fn repo_ref_state(repo: &gix::Repository) -> Result { Ok(RepoRefState { refs, head }) } -/// [`repo_ref_state`] for the repository at `workdir`. pub fn worktree_ref_state(workdir: &Path) -> Result { repo_ref_state(&gix::open(workdir)?) } diff --git a/crates/signed_git/src/scan.rs b/crates/signed_git/src/scan.rs index 718cb3e..cd16606 100644 --- a/crates/signed_git/src/scan.rs +++ b/crates/signed_git/src/scan.rs @@ -2,13 +2,11 @@ use std::path::{Path, PathBuf}; use ignore::WalkBuilder; -/// Maximum directory nesting depth when scanning for local repositories. -/// -/// Pathological trees can't stall the scan. +/// Caps nesting so pathological trees can't stall the scan. const SCAN_MAX_DEPTH: usize = 12; -/// Walk `root` recursively and collect the paths of git repositories below it, -/// honouring `.gitignore` (and `.ignore`) files. +/// Walk `root` recursively and collect the paths of git repositories below it. +/// `.gitignore` and `.ignore` files are honoured. pub fn find_git_repos(root: &Path) -> Vec { if !root.is_dir() { return Vec::new(); diff --git a/crates/signed_git/src/tests.rs b/crates/signed_git/src/tests.rs index c73b066..e157b47 100644 --- a/crates/signed_git/src/tests.rs +++ b/crates/signed_git/src/tests.rs @@ -1,25 +1,9 @@ use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; -use nostr::prelude::*; -use signed_core::{Announcement, repo_addr}; - use super::*; -#[test] -fn keeps_plain_ids() { - assert_eq!(sanitize_path_component("my-repo"), "my-repo"); - assert_eq!(sanitize_path_component("repo.v2"), "repo.v2"); - assert_eq!(sanitize_path_component("a_b-c"), "a_b-c"); -} - -#[test] -fn replaces_unsafe_characters() { - assert_eq!(sanitize_path_component("a/b\\c:d"), "a_b_c_d"); - assert_eq!(sanitize_path_component(""), ""); -} - #[test] fn blocks_parent_components() { assert_eq!(sanitize_path_component(".."), "_"); @@ -29,34 +13,6 @@ fn blocks_parent_components() { assert_eq!(sanitize_path_component("a/../b"), "a_.._b"); } -#[test] -fn fork_namespace_combines_owner_and_sanitized_id() { - let keys = Keys::generate(); - let event = EventBuilder::new(Kind::GitRepoAnnouncement, "") - .tags([Tag::parse(["d", "my/repo"]).expect("valid tag")]) - .finalize(&keys) - .expect("signed event"); - let announcement = Announcement::from_event(&event).expect("parses"); - - assert_eq!( - fork_namespace(&announcement), - format!("{}/my_repo", keys.public_key().to_hex()) - ); -} - -#[test] -fn repo_path_stays_inside_root() { - let cache = GitCache::new("/cache".into()); - let owner = Keys::generate().public_key(); - - let path = cache.repo_path(&repo_addr(owner, "..")); - assert!(path.starts_with("/cache")); - assert_eq!( - path.file_name().map(|n| n.to_string_lossy().into_owned()), - Some("_".into()) - ); -} - #[test] fn find_git_repos_discovers_repositories_recursively() { let temp = tempfile::tempdir().unwrap(); @@ -74,18 +30,14 @@ fn find_git_repos_discovers_repositories_recursively() { ) .unwrap(); - // Plain directories are not repositories. std::fs::create_dir_all(root.join("plain")).unwrap(); - // A `.gitignore` at the root excludes dependency caches. std::fs::write(root.join(".gitignore"), "node_modules/\n").unwrap(); std::fs::create_dir_all(root.join("node_modules/pkg/.git")).unwrap(); - // Hidden entries are skipped. std::fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap(); - // A repository is not descended into. - // Repositories inside it, like submodule worktrees, are not reported. + // A repository inside another, like a submodule worktree, is not reported. let outer = root.join("outer"); std::fs::create_dir_all(outer.join(".git")).unwrap(); std::fs::create_dir_all(outer.join("sub/other/.git")).unwrap(); @@ -120,13 +72,6 @@ fn root_commit_reports_the_first_ancestor() { ); } -#[test] -fn root_commit_is_none_without_commits() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - let workdir = repo.workdir().expect("workdir"); - assert_eq!(root_commit(workdir).expect("root"), None); -} - #[test] fn push_all_mirrors_branches_and_tags() { // A bare server repository reachable via a `file://` URL. @@ -145,7 +90,6 @@ fn push_all_mirrors_branches_and_tags() { commit_all(&repo, "initial"); let dir = dir.path(); - // Two branches plus a tag are all mirrored. git_run(dir, &["checkout", "-b", "feature"]); std::fs::write(dir.join("b.txt"), b"two").expect("write"); commit_all(&repo, "feature work"); @@ -161,34 +105,6 @@ fn push_all_mirrors_branches_and_tags() { assert!(refs.contains("refs/tags/v1.0")); } -#[test] -fn push_all_tolerates_a_missing_ref_kind() { - // A repository with only tags and no branches still pushes. - // Wildcard refspecs without a local match are ignored. - let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - git_run(dir, &["tag", "v1.0"]); - git_run(dir, &["update-ref", "-d", "refs/heads/main"]); - - let base_url = format!("file://{}", server.path().display()); - push_all(dir, &base_url, "npub1test", "my-repo").expect("push"); - - let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); - assert!(refs.contains("refs/tags/v1.0")); - assert!(!refs.contains("refs/heads/")); -} - #[test] fn remote_has_refs_reports_whether_pushed_refs_landed() { let server = tempfile::tempdir().unwrap(); @@ -208,7 +124,6 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() { let url = format!("file://{}/npub1test/my-repo.git", server.path().display()); let expected = vec![("refs/heads/main".to_owned(), main.clone())]; - // Nothing pushed yet: the ref is absent. assert!(!remote_has_refs(dir, &url, &expected).expect("probe")); push_all( @@ -219,7 +134,6 @@ fn remote_has_refs_reports_whether_pushed_refs_landed() { ) .expect("push"); - // The pushed ref is advertised at the expected commit. assert!(remote_has_refs(dir, &url, &expected).expect("probe")); // A stale expectation - the exact race a retry resolves - is false. @@ -253,7 +167,6 @@ fn repo_ref_state_lists_branches_tags_and_head() { assert_eq!(state.refs[0].0, format!("refs/heads/{branch}")); assert_eq!(state.refs[0].1.len(), 40); - // Additional branches and tags are listed alongside. git_run(&workdir, &["branch", "feature"]); git_run(&workdir, &["tag", "v1.0"]); @@ -273,7 +186,6 @@ fn repo_ref_state_lists_branches_tags_and_head() { expected ); - // A detached HEAD yields no head branch. git_run(&workdir, &["checkout", "--detach"]); let state = repo_ref_state(&repo).expect("refs"); assert!(state.head.is_none()); @@ -294,52 +206,6 @@ fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { (dir, repo) } -#[test] -fn worktree_entries_lists_all_files_and_dirs() { - let (_dir, repo) = fixture(&[ - ("README.md", b"# Hi"), - ("src/main.rs", b"fn main() {}"), - ("src/lib.rs", b""), - ("docs/guide.md", b"guide"), - ]); - - let entries = worktree_entries(&repo).expect("entries"); - let entries: Vec = entries - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - - assert_eq!( - entries, - vec![ - "docs", - "src", - "README.md", - "docs/guide.md", - "src/lib.rs", - "src/main.rs" - ] - ); -} - -#[test] -fn worktree_read_returns_bytes_or_none() { - let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]); - - assert_eq!( - worktree_read(&repo, Path::new("a.txt")).expect("read"), - Some(b"hello".to_vec()) - ); - assert_eq!( - worktree_read(&repo, Path::new("sub/b.bin")).expect("read"), - Some(vec![0x00, 0x01]) - ); - assert_eq!( - worktree_read(&repo, Path::new("missing.txt")).expect("read"), - None - ); -} - /// Stage everything and create a commit with the git CLI. /// Like [`apply_patch`], the crate already shells out to the CLI. fn commit_all(repo: &gix::Repository, message: &str) { @@ -379,50 +245,6 @@ fn merge_base_finds_the_fork_point_and_reports_unrelated_history() { assert!(merge_base(&path, "orphan", "no-such-ref").is_err()); } -#[test] -fn format_patch_between_produces_the_series_and_rejects_empty_ranges() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let initial = init_repository(&path, "My Repo", "desc").expect("init"); - - git_run(&path, &["checkout", "-b", "feature"]); - std::fs::write(path.join("feature.txt"), "feature\n").expect("write"); - commit_all(&gix::open(&path).expect("open"), "feature commit"); - - let patch = format_patch_between(&path, &initial, "feature").expect("patch"); - assert!(patch.contains("Subject: [PATCH] feature commit")); - assert!(patch.contains("feature.txt")); - - // An empty range has no commits to send. - assert!(format_patch_between(&path, "feature", "feature").is_err()); -} - -#[test] -fn push_commit_ref_pushes_to_the_event_namespace() { - // A bare server repository reachable via a `file://` URL. - // Mirrors a grasp server's `{base}/{owner}/{repo-id}.git` layout. - let server = tempfile::tempdir().unwrap(); - let server_repo = server.path().join("npub1test").join("my-repo.git"); - std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap(); - let init_status = Command::new("git") - .args(["init", "--bare", "-q"]) - .arg(&server_repo) - .status() - .expect("spawn git init --bare"); - assert!(init_status.success()); - - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - let tip = git_in(dir, &["rev-parse", "HEAD"]).expect("tip"); - - let url = format!("file://{}/npub1test/my-repo.git", server.path().display()); - push_commit_ref(dir, &url, &tip, "refs/nostr/abcd1234").expect("push"); - - let refs = git_in(&server_repo, &["show-ref"]).expect("server refs"); - assert!(refs.contains("refs/nostr/abcd1234")); -} - #[test] fn split_patch_series_splits_real_multi_commit_mboxes() { let dir = tempfile::tempdir().expect("tempdir"); @@ -448,14 +270,6 @@ fn split_patch_series_splits_real_multi_commit_mboxes() { assert_ne!(first, second); } -#[test] -fn split_patch_series_keeps_single_patches_whole() { - let patch = "From abcdefabcdefabcdefabcdefabcdefabcdefab Mon Sep 17 00:00:00 2001\nFrom: A \nSubject: [PATCH] fix\n\n---\n"; - let parts = split_patch_series(patch); - assert_eq!(parts.len(), 1); - assert_eq!(parts[0], patch); -} - #[test] fn head_commit_and_commits_since_track_applied_commits() { let dir = tempfile::tempdir().expect("tempdir"); @@ -466,7 +280,7 @@ fn head_commit_and_commits_since_track_applied_commits() { head_commit_id(&path).expect("head").as_deref(), Some(initial.as_str()) ); - // No commits yet, `HEAD` alone. + // No base given, `HEAD` alone. assert_eq!( commits_since(&path, None).expect("commits"), vec![initial.clone()] @@ -491,24 +305,6 @@ fn head_commit_and_commits_since_track_applied_commits() { ); } -#[test] -fn head_commit_reports_unborn_repositories() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let status = Command::new("git") - .args(["init", "-q"]) - .arg(&path) - .status() - .expect("spawn git init"); - assert!(status.success()); - - assert_eq!(head_commit_id(&path).expect("head"), None); - assert_eq!( - commits_since(&path, None).expect("commits"), - Vec::::new() - ); -} - #[test] fn init_repository_creates_main_branch_and_readme() { let dir = tempfile::tempdir().expect("tempdir"); @@ -541,70 +337,12 @@ fn init_repository_creates_main_branch_and_readme() { assert!(!worktree_dirty(workdir)); } -#[test] -fn init_repository_omits_description_when_empty() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - - init_repository(&path, "My Repo", " ").expect("init"); - let repo = gix::open(&path).expect("open"); - let workdir = repo.workdir().expect("workdir"); - - assert_eq!( - std::fs::read_to_string(workdir.join("README.md")).expect("read"), - "# My Repo\n" - ); -} - -#[test] -fn ensure_origin_adds_remote_only_once() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("my-repo"); - init_repository(&path, "My Repo", "").expect("init"); - - ensure_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); - assert_eq!( - git_in(&path, &["remote", "get-url", "origin"]).expect("url"), - "https://gitnostr.com/npub1test/repo.git" - ); - // The standard fetch mapping is configured with the remote. - // Later `git fetch origin` updates `refs/remotes/origin/*`. - assert_eq!( - git_in(&path, &["config", "remote.origin.fetch"]).expect("refspec"), - "+refs/heads/*:refs/remotes/origin/*" - ); - - // A second call must not override the existing remote. - ensure_origin(&path, "https://other.example/repo.git").expect("keep"); - assert_eq!( - git_in(&path, &["remote", "get-url", "origin"]).expect("url"), - "https://gitnostr.com/npub1test/repo.git" - ); -} - -#[test] -fn origin_url_reads_the_remote_or_reports_none() { - let (dir, _repo) = fixture(&[("a.txt", b"one")]); - commit_all(&_repo, "initial"); - let dir = dir.path(); - - // No remote configured yet. - assert_eq!(origin_url(dir).expect("read"), None); - - ensure_origin(dir, "https://gitnostr.com/npub1test/repo.git").expect("add"); - assert_eq!( - origin_url(dir).expect("read").as_deref(), - Some("https://gitnostr.com/npub1test/repo.git") - ); -} - #[test] fn set_origin_creates_or_replaces_the_remote() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("my-repo"); init_repository(&path, "My Repo", "").expect("init"); - // No origin yet, so one is added. set_origin(&path, "https://gitnostr.com/npub1test/repo.git").expect("add"); assert_eq!( origin_url(&path).expect("url").as_deref(), @@ -664,7 +402,6 @@ fn fast_forward_branches_moves_the_mirror_and_keeps_local_work() { .expect("spawn git init --bare"); assert!(init_status.success()); - // The owner's working repo pushes the initial commit. let (work_dir, work_repo) = fixture(&[("a.txt", b"one")]); commit_all(&work_repo, "initial"); let work = work_dir.path(); @@ -746,7 +483,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ) .expect("push"); - // The base mirror is a plain clone of the base server. let base_url = format!("file://{}", base_server.display()); let mirror = dir.path().join("mirror"); git_run( @@ -792,7 +528,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ) .expect("fetch"); - // The imported refs are listed under the prefix only. assert_eq!( refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), vec!["refs/fork/npub1fork/fork/feature"] @@ -820,7 +555,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { assert!(patch.contains("Subject: [PATCH] feature commit")); assert!(patch.contains("feature.txt")); - // Pruning the prefix removes the import again. delete_refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("delete"); assert_eq!( refs_with_prefix(&mirror, "refs/fork/npub1fork/fork").expect("refs"), @@ -828,30 +562,6 @@ fn fetch_repo_refs_imports_heads_under_a_prefix() { ); } -#[test] -fn fetch_repo_refs_fails_when_every_url_fails() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = _dir.path(); - - let dead = format!("file://{}/missing.git", dir.display()); - let err = - fetch_repo_refs(dir, &[dead], "+refs/heads/*:refs/fork/x/*").expect_err("all URLs fail"); - assert!(err.to_string().contains("failed to fetch")); - - // Without any URL there is nothing to try. - let err = - fetch_repo_refs(dir, &[] as &[String], "+refs/heads/*:refs/fork/x/*").expect_err("no URLs"); - assert!(err.to_string().contains("no clone URLs")); -} - -#[test] -fn delete_refs_with_prefix_is_a_noop_without_matches() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - delete_refs_with_prefix(_dir.path(), "refs/fork/nothing").expect("noop"); -} - /// Run a git command in `dir`, asserting success. fn git_run(dir: &Path, args: &[&str]) { let status = Command::new("git") @@ -910,25 +620,6 @@ fn all_commits_lists_every_commit() { ); } -#[test] -fn all_commits_returns_empty_without_head() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - - let list = all_commits(&repo).expect("commits"); - assert!(list.commits.is_empty()); - assert_eq!(list.total, 0); -} - -#[test] -fn last_commit_returns_none_for_untracked_files() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - std::fs::write(dir.path().join("untracked.txt"), b"x").expect("write"); - - let commit = last_commit(&repo, Path::new("untracked.txt")).expect("lookup"); - assert!(commit.is_none()); -} - #[test] fn last_commit_reports_merge_commits() { let (dir, repo) = fixture(&[("a.txt", b"base")]); @@ -964,35 +655,6 @@ fn last_commit_reports_merge_commits() { assert!(commit.summary.starts_with("Merge branch")); } -#[test] -fn last_commits_batches_multiple_paths() { - let (dir, repo) = fixture(&[("a.txt", b"one"), ("b.txt", b"b")]); - commit_all(&repo, "initial"); - - std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); - commit_all(&repo, "change a"); - std::fs::write(dir.path().join("b.txt"), b"bb").expect("write"); - commit_all(&repo, "change b"); - - let found = worktree_last_commits( - dir.path(), - &[ - PathBuf::from("a.txt"), - PathBuf::from("b.txt"), - // Untracked paths are absent from the result. - PathBuf::from("missing.txt"), - ], - ) - .expect("commits"); - let by_path: HashMap<&Path, &FileCommit> = found - .iter() - .map(|(path, commit)| (path.as_path(), commit)) - .collect(); - assert_eq!(by_path.len(), 2); - assert_eq!(by_path[Path::new("a.txt")].summary, "change a"); - assert_eq!(by_path[Path::new("b.txt")].summary, "change b"); -} - #[test] fn find_readme_prefers_markdown() { let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); @@ -1004,63 +666,6 @@ fn find_readme_prefers_markdown() { ); } -#[test] -fn find_readme_falls_back_to_any_readme() { - let (_dir, repo) = fixture(&[("README.rst", b"rst")]); - - let readme = find_readme(&repo).expect("find"); - assert_eq!( - readme.map(|p| p.to_string_lossy().into_owned()), - Some("README.rst".into()) - ); -} - -#[test] -fn find_readme_returns_none_without_one() { - let (_dir, repo) = fixture(&[("main.rs", b"")]); - assert!(find_readme(&repo).expect("find").is_none()); -} - -#[test] -fn head_commit_reports_head() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - - // Unborn HEAD means no commit yet. - assert!(head_commit(&repo).expect("head").is_none()); - - commit_all(&repo, "initial"); - let head = head_commit(&repo).expect("head").expect("commit"); - assert_eq!( - head.id, - repo.head_id().expect("head id").shorten_or_id().to_string() - ); - assert_eq!(head.summary, "initial"); - assert_eq!(head.author, "Test Author"); -} - -#[test] -fn worktree_branches_and_tags_list_short_names() { - let (dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "initial"); - let dir = dir.path(); - - git_run(dir, &["checkout", "-b", "feature"]); - git_run(dir, &["tag", "v0.9"]); - git_run(dir, &["tag", "v1.0"]); - - // The initial branch name depends on git configuration. - // Only the branch we created is fixed. - let branches = worktree_branches(dir).expect("branches"); - assert_eq!(branches.len(), 2); - assert!(branches.contains(&"feature".to_string())); - assert!(branches.windows(2).all(|pair| pair[0] <= pair[1]), "sorted"); - - assert_eq!( - repo_tags(&repo).expect("tags"), - vec!["v0.9".to_string(), "v1.0".to_string()] - ); -} - #[test] fn current_branch_tracks_checkout() { let (dir, repo) = fixture(&[("a.txt", b"one")]); @@ -1083,12 +688,10 @@ fn current_branch_tracks_checkout() { Some("feature") ); - // Tags detach HEAD. git_run(dir, &["tag", "v1.0"]); worktree_checkout_tag(dir, "v1.0").expect("checkout tag"); assert_eq!(current_branch(&repo).expect("branch"), None); - // Branches re-attach HEAD. worktree_checkout_branch(dir, &default).expect("checkout branch"); assert_eq!( current_branch(&repo).expect("branch").as_deref(), @@ -1239,25 +842,6 @@ fn commit_range_diff_lists_changes_between_two_commits() { assert!(diff.files.iter().all(|file| file.path != "b.txt")); } -#[test] -fn commit_range_commits_lists_only_new_commits_newest_first() { - let (dir, repo) = fixture(&[("a.txt", b"one\n")]); - commit_all(&repo, "one"); - let base = repo.head_id().expect("head").to_string(); - - std::fs::write(dir.path().join("a.txt"), b"two\n").expect("write"); - commit_all(&repo, "two"); - std::fs::write(dir.path().join("a.txt"), b"three\n").expect("write"); - commit_all(&repo, "three"); - let tip = repo.head_id().expect("head").to_string(); - - let commits = worktree_commit_range_commits(dir.path(), &base, &tip).expect("commits"); - - assert_eq!(commits.len(), 2); - assert_eq!(commits[0].summary, "three"); - assert_eq!(commits[1].summary, "two"); -} - #[test] fn commit_diff_reports_binary_files_without_hunks() { let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]); @@ -1279,56 +863,6 @@ fn commit_diff_reports_binary_files_without_hunks() { assert_eq!(file.deletions, 0); } -#[test] -fn commit_diff_resolves_short_ids_and_root_commit() { - let (dir, repo) = fixture(&[("a.txt", b"one\n")]); - commit_all(&repo, "initial"); - - // The root commit diffs against the empty tree, everything is added. - let head = repo.head_id().expect("head").shorten_or_id().to_string(); - let diff = worktree_commit_diff(dir.path(), &head).expect("diff"); - assert_eq!(diff.files.len(), 1); - assert_eq!(diff.files[0].path, "a.txt"); - assert_eq!(diff.files[0].status, DiffStatus::Added); - assert_eq!(diff.files[0].insertions, 1); -} - -#[test] -fn file_commit_includes_message_body() { - let (_dir, repo) = fixture(&[("a.txt", b"one")]); - commit_all(&repo, "title"); - - // A single-line message has no body. - let head = head_commit(&repo).expect("head").expect("commit"); - assert_eq!(head.summary, "title"); - assert_eq!(head.description, None); - - // A message with a body exposes it, trimmed. - let dir = _dir.path(); - let status = Command::new("git") - .current_dir(dir) - .env("GIT_AUTHOR_NAME", "Test Author") - .env("GIT_AUTHOR_EMAIL", "test@example.com") - .env("GIT_COMMITTER_NAME", "Test Author") - .env("GIT_COMMITTER_EMAIL", "test@example.com") - .env("GIT_EDITOR", "true") - .args([ - "commit", - "--allow-empty", - "-m", - "title two", - "-m", - "line one\n\nline two", - ]) - .status() - .expect("spawn git"); - assert!(status.success(), "git commit failed"); - - let head = head_commit(&repo).expect("head").expect("commit"); - assert_eq!(head.summary, "title two"); - assert_eq!(head.description.as_deref(), Some("line one\n\nline two")); -} - #[test] fn commit_diff_reports_renames() { let (_dir, repo) = fixture(&[("old.txt", b"same content\n")]); @@ -1352,143 +886,6 @@ fn commit_diff_reports_renames() { assert_eq!(file.deletions, 0); } -#[test] -fn parses_format_patch_output() { - let patch = r#"From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001 -From: A -Subject: [PATCH] fix - -fix the thing - ---- - src/lib.rs | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/src/lib.rs b/src/lib.rs -index 1234567..89abcde 100644 ---- a/src/lib.rs -+++ b/src/lib.rs -@@ -1,3 +1,3 @@ - fn main() { -- println!("old"); -+ println!("new"); - } -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files.len(), 1); - let file = &diff.files[0]; - assert_eq!(file.path, "src/lib.rs"); - assert_eq!(file.old_path, None); - assert_eq!(file.status, DiffStatus::Modified); - assert_eq!(file.insertions, 1); - assert_eq!(file.deletions, 1); - - let hunk = &file.hunks[0]; - assert_eq!(hunk.old_start, 1); - assert_eq!(hunk.old_lines, 3); - assert_eq!(hunk.new_start, 1); - assert_eq!(hunk.new_lines, 3); - assert_eq!(hunk.lines.len(), 4); - assert_eq!(hunk.lines[0].kind, DiffLineKind::Context); - assert_eq!(hunk.lines[0].old, Some(1)); - assert_eq!(hunk.lines[0].new, Some(1)); - assert_eq!(hunk.lines[1].kind, DiffLineKind::Deletion); - assert_eq!(hunk.lines[1].old, Some(2)); - assert_eq!(hunk.lines[1].new, None); - assert_eq!(hunk.lines[2].kind, DiffLineKind::Addition); - assert_eq!(hunk.lines[2].old, None); - assert_eq!(hunk.lines[2].new, Some(2)); - assert_eq!(hunk.lines[3].kind, DiffLineKind::Context); - assert_eq!(hunk.lines[3].old, Some(3)); - assert_eq!(hunk.lines[3].new, Some(3)); -} - -#[test] -fn parses_new_file_as_added() { - let patch = r#"diff --git a/README.md b/README.md -new file mode 100644 -index 0000000..1234567 ---- /dev/null -+++ b/README.md -@@ -0,0 +1 @@ -+# hello -"#; - let diff = patch_diffs(patch).expect("parse"); - - let file = &diff.files[0]; - assert_eq!(file.path, "README.md"); - assert_eq!(file.status, DiffStatus::Added); - assert_eq!(file.old_path, None); - assert_eq!(file.insertions, 1); - assert_eq!(file.deletions, 0); - assert_eq!(file.hunks[0].old_start, 0); - assert_eq!(file.hunks[0].old_lines, 0); - assert_eq!(file.hunks[0].new_start, 1); -} - -#[test] -fn parses_renames_with_old_path() { - let patch = r#"diff --git a/old.rs b/new.rs -similarity index 85% -rename from old.rs -rename to new.rs -index 123..456 100644 ---- a/old.rs -+++ b/new.rs -@@ -1 +1 @@ --fn main() {} -+fn main() { println!("hi"); } -"#; - let diff = patch_diffs(patch).expect("parse"); - - let file = &diff.files[0]; - assert_eq!(file.path, "new.rs"); - assert_eq!(file.old_path.as_deref(), Some("old.rs")); - assert_eq!(file.status, DiffStatus::Renamed); - assert_eq!(file.insertions, 1); - assert_eq!(file.deletions, 1); -} - -#[test] -fn parses_patch_series_and_skips_envelope() { - let patch = r#"From aaaa Mon Sep 17 00:00:00 2001 -From: A -Subject: [PATCH 1/2] one - ---- - a.txt | 1 + - 1 file changed, 1 insertion(+) - -diff --git a/a.txt b/a.txt -index 1..2 100644 ---- a/a.txt -+++ b/a.txt -@@ -1 +1,2 @@ - a -+b - -From bbbb Mon Sep 17 00:00:00 2001 -From: A -Subject: [PATCH 2/2] two - -diff --git a/b.txt b/b.txt -index 3..4 100644 ---- a/b.txt -+++ b/b.txt -@@ -1 +1 @@ --x -+y -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files.len(), 2); - assert_eq!(diff.files[0].path, "a.txt"); - assert_eq!(diff.files[0].insertions, 1); - assert_eq!(diff.files[1].path, "b.txt"); - assert_eq!(diff.files[1].deletions, 1); -} - #[test] fn patch_commits_lists_every_patch_in_order() { let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 @@ -1536,119 +933,6 @@ diff --git a/b.txt b/b.txt assert_eq!(commits[1].time, 1690975800); } -#[test] -fn patch_commits_strips_patch_subject_prefixes() { - let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 -From: A -Subject: [RFC PATCH v3 4/7] the real title - ---- -"#; - - let commits = patch_commits(patch); - assert_eq!(commits.len(), 1); - assert_eq!(commits[0].summary, "the real title"); -} - -#[test] -fn patch_commits_handles_missing_headers() { - // A hand-written patch without author or date headers still lists a commit. - // Time stays 0 and the author stays empty. - let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 -Subject: [PATCH] plain - ---- -"#; - - let commits = patch_commits(patch); - assert_eq!(commits.len(), 1); - assert_eq!(commits[0].summary, "plain"); - assert_eq!(commits[0].author, ""); - assert_eq!(commits[0].time, 0); -} - -#[test] -fn patch_commits_ignores_non_patch_lines() { - assert!(patch_commits("").is_empty()); - assert!(patch_commits("just some text\nFrom 123\n").is_empty()); - // A diff-only body without an mbox envelope has no commits. - let patch = "diff --git a/x b/x\n--- a/x\n+++ b/x\n"; - assert!(patch_commits(patch).is_empty()); -} - -#[test] -fn marks_binary_sections() { - let patch = r#"diff --git a/img.png b/img.png -index 123..456 100644 -Binary files a/img.png and b/img.png differ -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert!(diff.files[0].binary); - assert!(diff.files[0].hunks.is_empty()); -} - -#[test] -fn unquotes_quoted_paths() { - let patch = r#"diff --git "a/weird file.rs" "b/weird file.rs" -index 123..456 100644 ---- "a/weird file.rs" -+++ "b/weird file.rs" -@@ -1 +1 @@ --x -+y -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files[0].path, "weird file.rs"); - assert_eq!(diff.files[0].status, DiffStatus::Modified); -} - -#[test] -fn unquotes_non_ascii_quoted_paths() { - let patch = r#"diff --git "a/说明.md" "b/说明.md" -index 123..456 100644 ---- "a/说明.md" -+++ "b/说明.md" -@@ -1 +1 @@ --x -+y -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files[0].path, "说明.md"); - assert_eq!(diff.files[0].status, DiffStatus::Modified); -} - -#[test] -fn unquotes_octal_escaped_paths() { - let patch = r#"diff --git "a/\345\270\226.md" "b/\345\270\226.md" -index 123..456 100644 ---- "a/\345\270\226.md" -+++ "b/\345\270\226.md" -@@ -1 +1 @@ --x -+y -"#; - let diff = patch_diffs(patch).expect("parse"); - - assert_eq!(diff.files[0].path, "帖.md"); - assert_eq!(diff.files[0].status, DiffStatus::Modified); -} - -#[test] -fn empty_or_unparseable_patch_yields_no_files() { - assert_eq!(patch_diffs("").expect("parse").files.len(), 0); - assert_eq!(patch_diffs("just some text").expect("parse").files.len(), 0); - assert_eq!( - patch_diffs("---\nnot a patch\n") - .expect("parse") - .files - .len(), - 0 - ); -} - #[test] fn parses_real_format_patch_output() { // Build a commit touching a mix of file kinds. @@ -1735,7 +1019,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() { assert!(!worktree_dirty(workdir)); - // A modified tracked file is dirty. std::fs::write(workdir.join("tracked.txt"), b"two").expect("write"); assert!(worktree_dirty(workdir)); @@ -1745,7 +1028,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() { std::fs::write(workdir.join("untracked.txt"), b"new").expect("write"); assert!(worktree_dirty(workdir)); - // A staged change counts too. git_run(workdir, &["rm", "--cached", "tracked.txt"]); assert!(worktree_dirty(workdir)); @@ -1753,24 +1035,6 @@ fn worktree_dirty_tracks_changes_and_untracked_files() { assert!(!worktree_dirty(&dir.path().join("missing"))); } -#[test] -fn worktree_dirty_reports_unborn_worktrees_with_files() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("repo"); - let status = Command::new("git") - .args(["init", "-q"]) - .arg(&path) - .status() - .expect("spawn git init"); - assert!(status.success()); - - // No commits and no files: porcelain is empty. - assert!(!worktree_dirty(&path)); - // An unborn repository holding files is dirty. - std::fs::write(path.join("README.md"), "# hello\n").expect("write"); - assert!(worktree_dirty(&path)); -} - #[test] fn worktree_commits_ahead_counts_branch_only_commits() { let (dir, repo) = fixture(&[("a.txt", b"one")]); diff --git a/crates/signed_git/src/worktree.rs b/crates/signed_git/src/worktree.rs index d89a3f0..97a170a 100644 --- a/crates/signed_git/src/worktree.rs +++ b/crates/signed_git/src/worktree.rs @@ -164,8 +164,6 @@ pub struct WorktreeSnapshot { pub head_commit: Option, } -/// Snapshot the worktree after a branch or tag switch. -/// /// Collects entries, the README, the branch HEAD points to and its commit. pub fn worktree_snapshot(workdir: &Path) -> Result { let repo = gix::open(workdir)?; @@ -183,7 +181,6 @@ pub fn worktree_snapshot(workdir: &Path) -> Result { }) } -/// Check out `tree` into the worktree of `repo` pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> Result<()> { let workdir = repo .workdir() @@ -229,7 +226,6 @@ pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> R let files = gix::progress::Discard; let bytes = gix::progress::Discard; - // Check out the index into the worktree. gix_worktree_state::checkout( &mut index, workdir, @@ -240,7 +236,6 @@ pub(crate) fn force_checkout(repo: &gix::Repository, tree: &gix::hash::oid) -> R options, )?; - // Write the index to disk. index.write(gix::index::write::Options::default())?; Ok(()) @@ -258,7 +253,6 @@ fn move_head( let head = gix::refs::FullName::try_from("HEAD") .map_err(|e| anyhow::anyhow!("invalid ref name: {e}"))?; - // Update the reference, creating a reflog entry. repo.edit_references_as( [RefEdit { change: Change::Update { @@ -293,7 +287,6 @@ pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { let (signature, mut time_buf) = repository_signature(); let signature = signature.to_ref(&mut time_buf); - // Move HEAD to the branch, creating a reflog entry. move_head( &repo, signature, @@ -301,7 +294,6 @@ pub fn worktree_checkout_branch(workdir: &Path, name: &str) -> Result<()> { &format!("checkout: moving to {name}"), )?; - // Check out the branch's tree, replacing index + worktree. force_checkout(&repo, &tree)?; Ok(()) @@ -320,7 +312,6 @@ pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { let (signature, mut time_buf) = repository_signature(); let signature = signature.to_ref(&mut time_buf); - // Move HEAD to the tag, creating a reflog entry. move_head( &repo, signature, @@ -328,7 +319,6 @@ pub fn worktree_checkout_tag(workdir: &Path, name: &str) -> Result<()> { &format!("checkout: moving to {name}"), )?; - // Check out the tag's tree, replacing index + worktree. force_checkout(&repo, &tree)?; Ok(()) diff --git a/crates/signed_nostr/src/update.rs b/crates/signed_nostr/src/update.rs index b5a070f..a7d1998 100644 --- a/crates/signed_nostr/src/update.rs +++ b/crates/signed_nostr/src/update.rs @@ -10,7 +10,6 @@ pub struct Update { } impl Update { - /// Build an update from a received event. pub fn from_event(event: &Event) -> Self { let coordinate = event.tags.coordinates().nth(0); diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index f1722d8..8873381 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -17,7 +17,6 @@ use crate::git_store::GitStore; use crate::inbox::Inbox; use crate::repos::RepoListStore; -/// Keyring entry for the user credential. pub const USER_KEYRING: &str = "Signed Safe Storage"; /// Timeout for NIP-46 signer responses. pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; @@ -33,12 +32,10 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [ /// Relays used to index the user's NIP-65 relay list. pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"]; -/// Delay the notification pump waits for more events before emitting a batch. const PUMP_DEBOUNCE: Duration = Duration::from_millis(200); #[derive(Debug, Clone)] pub enum BackendEvent { - /// User has no signer configured. SignerRequired, /// The stored identity is NIP-49 encrypted key. PassphraseRequired, @@ -51,18 +48,13 @@ pub enum BackendEvent { /// instead of emitting per-event and making every subscriber debounce /// the same burst independently. NostrUpdate(Vec), - /// A negentropy sync completed. Synced, - /// A negentropy sync is in flight. SyncProgress { - /// Total events to process. total: u64, - /// Events processed so far. current: u64, }, /// An event built locally was signed, broadcast and stored. Published(Box), - /// An error occurred. Error(String), } @@ -79,13 +71,10 @@ pub struct Backend { client: Client, signer: UniversalSigner, current_user: Option, - /// User's inbox, including notifications and recent activity. inbox: Entity, - /// The progress of the current sync operation, if any. sync_progress: Option<(u64, u64)>, /// True when the stored credential is NIP-49 encrypted. passphrase_required: bool, - /// Repositories with a push in flight, mirror or checkout based. pushing_repos: Entity>, } @@ -96,7 +85,6 @@ impl Global for GlobalBackend {} impl EventEmitter for Backend {} impl Backend { - /// Retrieve the global backend. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -114,7 +102,6 @@ impl Backend { let mut pending: Vec = Vec::new(); 'outer: loop { - // Wait for the first event of a batch. match notifications.next().await { Some(ClientNotification::Event { event, .. }) => { pending.push(Update::from_event(&event)); @@ -123,7 +110,6 @@ impl Backend { None => break, } - // Collect everything else that arrives within the debounce window. let deadline = Instant::now() + PUMP_DEBOUNCE; loop { @@ -152,7 +138,6 @@ impl Backend { } } - // Collect and emit the collected events. let batch = std::mem::take(&mut pending); if let Err(e) = @@ -167,7 +152,6 @@ impl Backend { pump.detach(); - // Bootstrap the client. cx.defer(move |cx| { if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) { log::warn!("backend dropped before bootstrap could run: {error}"); @@ -185,7 +169,6 @@ impl Backend { } } - /// Bootstrap the client and restore the saved session, if any. fn bootstrap(&mut self, cx: &mut Context) { let client = self.client.clone(); @@ -259,7 +242,6 @@ impl Backend { signer.auth_url_handler(SignedAuthUrlHandler); this.update(cx, |this, cx| this.set_signer(signer, cx))?; } else if content.starts_with("ncryptsec1") { - // A passphrase is required to decrypt it before the session can resume. this.update(cx, |this, cx| { this.passphrase_required = true; cx.emit(BackendEvent::PassphraseRequired); @@ -319,7 +301,6 @@ impl Backend { }) } - /// Create a new identity. pub fn create_identity( &mut self, name: &str, @@ -348,7 +329,6 @@ impl Backend { let (keys, ncryptsec) = job.await?; let public_key = keys.public_key(); - // Persist the encrypted credential. let write = cx.update(|cx| { cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes()) }); @@ -446,7 +426,6 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to create a repository"))); }; - // The repository identifier is derived from the name. let repo_id = identifier_from_name(&name); if repo_id.is_empty() || repo_id.len() > 100 { @@ -629,7 +608,6 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to publish a repository"))); }; - // The identifier derives from the name, as in [`Self::create_repository`]. let repo_id = identifier_from_name(&name); if repo_id.is_empty() || repo_id.len() > 100 { @@ -937,7 +915,6 @@ impl Backend { let addr = addr.clone(); cx.spawn(async move |this, cx| { - // Collect every event of the repository from the local database. let events = cx.background_spawn(async move { let db = client.database(); let mut events = Vec::new(); @@ -974,7 +951,6 @@ impl Backend { } } - /// Create a fresh identity and login with it. pub fn login_with_new_identity(&mut self, cx: &mut Context) { let nsec = Keys::generate() .secret_key() @@ -983,7 +959,6 @@ impl Backend { self.login_with_nsec(&nsec, cx); } - /// Login with an `nsec1...` secret key. pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context) { let keys = match SecretKey::parse(nsec) { Ok(secret) => Keys::new(secret), @@ -1053,7 +1028,6 @@ impl Backend { task.detach(); } - /// Remove the saved credential and reset to an anonymous session. pub fn logout(&mut self, cx: &mut Context) { let delete = cx.delete_credentials(USER_KEYRING); @@ -1075,7 +1049,6 @@ impl Backend { task.detach(); } - /// Sync the user's grasp list and add the listed grasp servers as relays. fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { let client = self.client.clone(); @@ -1105,46 +1078,34 @@ impl Backend { task.detach(); } - /// Get the nostr client. pub fn client(&self) -> Client { self.client.clone() } - /// Get the current signer. pub fn signer(&self) -> UniversalSigner { self.signer.clone() } - /// Repositories with a push in flight, mirror or checkout based. - /// - /// A child entity: `cx.observe` it to react only to push-state changes. pub fn pushing_repos(&self) -> Entity> { self.pushing_repos.clone() } - /// The inbox child entity backing the home screen. - /// - /// A child entity: `cx.observe` it to react only to inbox changes. pub fn inbox(&self) -> Entity { self.inbox.clone() } - /// Get the current user's public key. pub fn current_user(&self) -> Option { self.current_user } - /// True when the stored credential is NIP-49 encrypted. pub fn passphrase_required(&self) -> bool { self.passphrase_required } - /// Surface an error message through [`BackendEvent::Error`]. pub fn emit_error(&mut self, message: impl Into, cx: &mut Context) { cx.emit(BackendEvent::error(message)); } - /// Attach the inbox to the current signer and activate or clear it. fn sync_inbox(&mut self, cx: &mut Context) { let client = self.client.clone(); let me = self.current_user; @@ -1173,12 +1134,10 @@ impl Backend { }); } - /// Progress of the in-flight negentropy sync, if any. pub fn sync_progress(&self) -> Option<(u64, u64)> { self.sync_progress } - /// Update the signer. pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) where T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static, @@ -1229,7 +1188,6 @@ impl Backend { .detach(); } - /// One-shot subscription on the bootstrap relays only. pub fn subscribe_bootstrap(&mut self, filters: Vec, cx: &mut Context) { let client = self.client.clone(); @@ -1247,7 +1205,6 @@ impl Backend { .detach(); } - /// Negentropy-sync the given filter against the bootstrap relays. pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { let client = self.client.clone(); let (tx, mut rx) = SyncProgress::channel(); @@ -1399,7 +1356,6 @@ async fn publish_best_effort(client: &Client, signer: &UniversalSigner, builder: } } -/// Add the given relays, connect and fetch the filters. async fn connect_repo_relays( client: &Client, relays: Vec, @@ -1409,12 +1365,10 @@ async fn connect_repo_relays( return Ok(()); } - // Ensure relay connections for url in relays.iter() { client.add_relay(url).and_connect().await?; } - // Run neg sync for each filter for filter in filters.into_iter() { if let Err(e) = client.sync(filter).with(relays.iter()).await { log::warn!("repo relay negentropy sync failed: {e}"); @@ -1424,7 +1378,6 @@ async fn connect_repo_relays( Ok(()) } -/// Subscribe only on the bootstrap relays. pub(crate) async fn subscribe_bootstrap_only( client: &Client, filters: Vec, @@ -1443,7 +1396,6 @@ pub(crate) async fn subscribe_bootstrap_only( Ok(()) } -/// Negentropy-sync the filter against the bootstrap relays only. pub(crate) async fn sync_bootstrap_only( client: &Client, filter: Filter, @@ -1481,7 +1433,6 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option { Some(format!("{scheme}://{host}{port}")) } -/// GRASP clone URL of a repository on a grasp server. fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option { let base = grasp_base_url(relay)?; Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok() @@ -1526,7 +1477,6 @@ fn latest_grasp_list_servers(events: Vec) -> Vec { .unwrap_or_default() } -/// Resolve the user's published grasp servers from the local database. pub async fn user_grasp_list_servers( client: Client, user: PublicKey, @@ -1540,18 +1490,14 @@ pub async fn user_grasp_list_servers( Ok(latest_grasp_list_servers(events)) } -/// Attempts per grasp server when a git push is denied transiently. const GRASP_PUSH_ATTEMPTS: usize = 3; /// Pause before re-staging a state event after a transient denial. const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1); -/// The outcome of pushing to one grasp server. #[derive(Debug, Clone)] pub struct GraspServerResult { - /// The grasp server's relay URL, e.g. `wss://relay.ngit.dev`. pub relay: RelayUrl, - /// The git URL the data was pushed to. pub git_url: String, /// `None` when the server accepted the data, the reason otherwise. pub reason: Option, @@ -1575,7 +1521,6 @@ impl GraspServerResult { } } -/// The outcome of a staged push across every grasp server of a repository. #[derive(Debug, Clone, Default)] pub struct PushOutcome { /// Per-server results, in the order the servers were listed. @@ -1587,7 +1532,6 @@ pub struct PushOutcome { } impl PushOutcome { - /// The number of grasp servers that accepted the git data. pub fn accepted(&self) -> usize { self.servers .iter() @@ -1595,12 +1539,10 @@ impl PushOutcome { .count() } - /// Servers that did not accept the push. fn failing(&self) -> impl Iterator { self.servers.iter().filter(|server| server.reason.is_some()) } - /// One-line summary of every server failure, for error messages. pub fn failure_summary(&self) -> String { self.failing() .map(|server| { @@ -1628,7 +1570,6 @@ impl PushOutcome { } } -/// Collapse a multi-line relay or git error into one display line. fn flatten_whitespace(text: &str) -> String { const MAX_CHARS: usize = 200; let flat: String = text.split_whitespace().collect::>().join(" "); @@ -1760,7 +1701,6 @@ async fn stage_event_on_relay( } } -/// Push the repository at `path` to every grasp server in `servers`. #[allow(clippy::too_many_arguments)] async fn push_staged_to_grasps( client: &Client, @@ -1955,56 +1895,6 @@ mod tests { ); } - fn grasp_list_event(servers: &[&str], created_at: u64) -> Event { - let keys = Keys::generate(); - let tags: Vec = servers - .iter() - .map(|url| Tag::parse(vec!["g", *url]).expect("valid tag")) - .collect(); - EventBuilder::new(Kind::GitUserGraspList, "") - .tags(tags) - .custom_created_at(Timestamp::from(created_at)) - .finalize(&keys) - .expect("signed event") - } - - #[test] - fn grasp_list_servers_reads_g_tags_in_order() { - let event = grasp_list_event( - &["wss://first.example", "wss://second.example", "not a url"], - 1000, - ); - - let servers = grasp_list_servers(&event); - assert_eq!( - servers.iter().map(ToString::to_string).collect::>(), - vec!["wss://first.example", "wss://second.example"] - ); - } - - #[test] - fn latest_grasp_list_servers_takes_the_newest_list_and_falls_back_empty() { - let old = grasp_list_event(&["wss://old.example"], 1000); - let fresh = grasp_list_event(&["wss://fresh.example", "wss://also.example"], 2000); - - // The newest list wins, its `g` order preserved. - let servers = latest_grasp_list_servers(vec![old.clone(), fresh.clone()]); - assert_eq!( - servers.iter().map(ToString::to_string).collect::>(), - vec!["wss://fresh.example", "wss://also.example"] - ); - - // The order of the input events does not matter. - let servers = latest_grasp_list_servers(vec![fresh, old]); - assert_eq!( - servers.iter().map(ToString::to_string).collect::>(), - vec!["wss://fresh.example", "wss://also.example"] - ); - - // No list at all, empty, so the caller falls back to the defaults. - assert!(latest_grasp_list_servers(Vec::new()).is_empty()); - } - #[test] fn transient_grasp_denials_are_classified() { // The exact server rejection that started this work: the state event @@ -2074,13 +1964,6 @@ mod tests { )); } - #[test] - fn transient_denial_markers_match_case_insensitively() { - assert!(is_transient_grasp_denial( - "ERR NO STATE EVENTS IN PURGATORY" - )); - } - #[test] fn push_outcome_reports_partial_failures() { let outcome = PushOutcome { @@ -2110,39 +1993,4 @@ mod tests { // The multi-line server reason is a single display line. assert_eq!(warning.lines().count(), 1); } - - #[test] - fn push_outcome_with_every_server_ok_has_no_warning() { - let outcome = PushOutcome { - servers: vec![ - GraspServerResult::ok( - RelayUrl::parse("wss://gitnostr.com").expect("url"), - "https://gitnostr.com/npub1owner/repo.git".to_owned(), - ), - GraspServerResult::ok( - RelayUrl::parse("wss://relay.ngit.dev").expect("url"), - "https://relay.ngit.dev/npub1owner/repo.git".to_owned(), - ), - ], - state_event: None, - }; - - assert_eq!(outcome.accepted(), 2); - assert!(outcome.partial_warning().is_none()); - assert_eq!(outcome.failure_summary(), ""); - } - - #[test] - fn push_outcome_without_servers_or_pushes_has_no_warning() { - assert!(PushOutcome::default().partial_warning().is_none()); - } - - #[test] - fn flatten_whitespace_collapses_and_clips_long_errors() { - assert_eq!(flatten_whitespace("a\n\n b \t c"), "a b c"); - let long = "word ".repeat(100); - let flat = flatten_whitespace(&long); - assert!(flat.ends_with('…')); - assert_eq!(flat.chars().count(), 201); - } } diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index 723e978..f7ed9ee 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -13,7 +13,6 @@ use crate::git_store::GitStore; use crate::refresh::{RefreshGate, RefreshRequest}; use crate::repos::{LocalReposStore, RepoListStore}; -/// Delay between a refresh request and the actual re-computation. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// How often the statuses are recomputed against the local refs. @@ -29,7 +28,6 @@ const STATUS_POLL: Duration = Duration::from_secs(15); /// Remote refresh interval for the `ready to push` badges of the user's own repositories. const PUSH_POLL: Duration = Duration::from_secs(60); -/// Maximum checkouts considered per repository when computing statuses. const MAX_STATUS_CHECKOUTS: usize = 8; struct GlobalCheckoutsStore(Entity); @@ -41,7 +39,6 @@ impl Global for GlobalCheckoutsStore {} /// Carries the git facts needed to suggest a pull request. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CheckoutStatus { - /// The checkout folder. pub path: PathBuf, /// The branch checked out. A detached checkout is idle and yields no status. pub branch: String, @@ -95,9 +92,7 @@ pub struct CheckoutsStore { /// /// A recompute defaults the base the same way. requested_head: HashMap>, - /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, - /// A local status pass timer is pending. local_pending: bool, /// When the last full pass (with a remote refresh) completed. /// @@ -108,7 +103,6 @@ pub struct CheckoutsStore { } impl CheckoutsStore { - /// Retrieve the global checkouts store. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -117,7 +111,6 @@ impl CheckoutsStore { cx.set_global(GlobalCheckoutsStore(entity)); } - /// Create the store. pub fn new(cx: &mut Context) -> Self { let mut subscriptions = Vec::new(); @@ -176,7 +169,6 @@ impl CheckoutsStore { } } - /// Remember a successful local-checkout use. pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context) { if cfg!(target_arch = "wasm32") { return; @@ -278,7 +270,6 @@ impl CheckoutsStore { self.push_statuses.get(addr).cloned().unwrap_or_default() } - /// The number of unpushed commits for a repository. pub fn unpushed(&self, addr: &RepoAddr) -> usize { self.push_statuses .get(addr) @@ -311,7 +302,6 @@ impl CheckoutsStore { fn run_refresh(&mut self, cx: &mut Context) { self.refresh.begin(); - // Inputs snapshot, all cheap shared reads. let records = { let settings = SettingsStore::global(cx); settings.read(cx).settings().checkouts.records.clone() @@ -433,7 +423,7 @@ impl CheckoutsStore { /// Schedule the fast local status pass, unless one is already pending. /// /// Every [`LOCAL_POLL`] the pass recomputes the requested statuses against - /// the local refs — no network — so a new commit in a checkout surfaces in + /// the local refs, with no network, so a new commit in a checkout surfaces in /// a second or two instead of at the next remote reconciliation. fn schedule_local_pass(&mut self, cx: &mut Context) { if self.local_pending { @@ -546,7 +536,6 @@ impl CheckoutsStore { } } -/// Identity of a repository URL. fn url_identity(url: &str) -> Option<(String, Option, String)> { let parsed = Url::parse(url).ok()?; let host = parsed.host_str()?.to_ascii_lowercase(); @@ -557,7 +546,6 @@ fn url_identity(url: &str) -> Option<(String, Option, String)> { Some((host, parsed.port(), path)) } -/// Whether two repository URLs point at the same repository. fn same_repo_url(a: &str, b: &str) -> bool { match (url_identity(a), url_identity(b)) { (Some(a), Some(b)) => a == b, @@ -565,7 +553,6 @@ fn same_repo_url(a: &str, b: &str) -> bool { } } -/// Resolve the associations between local checkouts and announced repositories. fn resolve_associations<'a>( remembered: &[Remembered], scanned: &[(PathBuf, Option, Option)], @@ -608,7 +595,6 @@ fn resolve_associations<'a>( out } -/// The ready-to-contribute status of one checkout. fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option { let branches = signed_git::worktree_branches(path).ok()?; @@ -732,7 +718,6 @@ fn compute_statuses( (statuses, push_statuses) } -/// Whether the pull request `pr` already proposes the same change as `checkout`. pub fn pr_proposes_checkout( pr: &Event, open: bool, @@ -860,40 +845,6 @@ mod tests { assert_eq!(resolved.len(), 2); } - #[test] - fn resolve_matches_scanned_repos_by_origin_and_euc() { - let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; - let announcements = vec![ - announcement("repo", &["grasp://host/npub1x/repo"], None), - announcement("family", &[], Some(euc)), - ]; - let repo = addr("repo"); - let family = addr("family"); - - let resolved = resolve_associations( - &[], - &[ - // Origin matches modulo scheme and the `.git` suffix. - scanned("/clone", Some("https://host/npub1x/repo.git"), None), - // Root commit matches the family EUC. - scanned("/family-checkout", None, Some(euc)), - // Neither matches anything. - scanned("/unrelated", Some("https://elsewhere/x.git"), None), - ], - &announcements, - ); - - assert_eq!( - resolved.get(&repo).expect("repo matches"), - &vec![PathBuf::from("/clone")] - ); - assert_eq!( - resolved.get(&family).expect("family matches"), - &vec![PathBuf::from("/family-checkout")] - ); - assert_eq!(resolved.len(), 2); - } - #[test] fn resolve_deduplicates_paths_remembering_first() { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; @@ -1048,61 +999,4 @@ mod tests { remote_run(&["commit", "-m", "remote work"]); assert_eq!(checkout_push_status(&checkout, true), None); } - - fn pr_event(author: &str, tags: &[&[&str]]) -> Event { - let keys = Keys::new(SecretKey::from_hex(author).expect("secret")); - let tags: Vec = tags - .iter() - .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) - .collect(); - EventBuilder::new(Kind::GitPullRequest, "") - .tags(tags) - .finalize(&keys) - .expect("signed event") - } - - fn status(branch: &str, head: &str) -> CheckoutStatus { - CheckoutStatus { - path: PathBuf::from("/checkout"), - branch: branch.to_owned(), - head: head.to_owned(), - base: "main".to_owned(), - ahead: 1, - } - } - - #[test] - fn pr_proposes_checkout_matches_branch_or_tip() { - let author = "0000000000000000000000000000000000000000000000000000000000000002"; - let tip = "aa231c4c6a5777dc89b42207b499891a344add5c"; - - // A matching `branch-name` covers the proposal. - let pr = pr_event(author, &[&["branch-name", "feature"], &["c", tip]]); - let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c"); - assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status)); - - // Without a branch-name tag, the `c` tip still matches for a renamed branch. - let pr = pr_event( - author, - &[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]], - ); - assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status)); - - // Someone else's PR, a closed PR, a different branch and a missing tip. - // They all leave the checkout uncovered. - let pr = pr_event(author, &[&["branch-name", "feature"]]); - assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status)); - let other = pr_event( - "0000000000000000000000000000000000000000000000000000000000000003", - &[&["branch-name", "feature"]], - ); - assert!(!pr_proposes_checkout(&pr, true, other.pubkey, &status)); - let other_branch = pr_event(author, &[&["branch-name", "other"]]); - assert!(!pr_proposes_checkout( - &other_branch, - true, - other_branch.pubkey, - &status - )); - } } diff --git a/crates/signed_state/src/git_store.rs b/crates/signed_state/src/git_store.rs index 2d49a4c..f284482 100644 --- a/crates/signed_state/src/git_store.rs +++ b/crates/signed_state/src/git_store.rs @@ -12,14 +12,12 @@ impl Global for GlobalGitStore {} pub struct GitStore(GitCache); impl GitStore { - /// Register the clone cache rooted at `root` as an app-wide global. pub fn set_global(root: impl Into, cx: &mut App) -> Self { let store = Self::new(root); cx.set_global(GlobalGitStore(store.0.clone())); store } - /// The app-wide clone cache. pub fn global(cx: &App) -> Self { Self(cx.global::().0.clone()) } @@ -28,7 +26,6 @@ impl GitStore { Self(GitCache::new(root.into())) } - /// Underlying clone cache. pub fn cache(&self) -> &GitCache { &self.0 } diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 011dba1..8d66fec 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -11,7 +11,6 @@ use crate::backend::Backend; #[derive(Default)] pub struct Inbox { state: InboxReadState, - /// Set once the stored state has been read for the current user. loaded: bool, } @@ -21,12 +20,10 @@ impl Inbox { &self.state } - /// Whether the stored state has been read for the current user. pub fn is_loaded(&self) -> bool { self.loaded } - /// Mark the events of one notification group read, then bound the id sets. pub fn mark_read( &mut self, group: &[Event], @@ -42,7 +39,7 @@ impl Inbox { cx.notify(); } - /// Archive one notification group. Archived events are always read too. + /// Archived events are always read too. pub fn mark_archived( &mut self, group: &[Event], @@ -62,14 +59,12 @@ impl Inbox { cx.notify(); } - /// Mark every known notification read. pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context) { self.state.mark_all_read(all, me, Timestamp::now()); self.persist(cx); cx.notify(); } - /// Load the stored state for current user. pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { self.state = InboxReadState::default(); self.loaded = false; @@ -101,7 +96,6 @@ impl Inbox { .detach(); } - /// Clear the state of the signed-out user. pub(crate) fn reset(&mut self, cx: &mut Context) { self.state = InboxReadState::default(); self.loaded = false; @@ -128,7 +122,6 @@ impl Inbox { } } -/// Derive the inbox home screen's threads for `me` from the local database. pub async fn query_inbox( client: &Client, me: PublicKey, @@ -166,7 +159,6 @@ fn inbox_state_d_tag(me: PublicKey) -> String { format!("signed-inbox-state:{}", me.to_hex()) } -/// Newest stored state for `me`. async fn load_state(client: &Client, me: PublicKey) -> Result, Error> { let filter = Filter::new() .kind(Kind::ApplicationSpecificData) @@ -223,7 +215,6 @@ async fn fetch_notifications( let mut seen: HashSet = by_id.keys().copied().collect(); loop { - // Keep only ids not walked yet, and remember them. pending.retain(|id| seen.insert(*id)); if pending.is_empty() { diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 6d57e37..d3dc6c4 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -21,7 +21,6 @@ pub use repo::RepoStore; pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore}; use signed_nostr::new_backend; -/// Initialize the backend and stores, and install them as globals. #[cfg(not(target_arch = "wasm32"))] pub fn init( db_path: impl AsRef, diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index a525282..d9ae4c4 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -53,7 +53,6 @@ impl Profile { SharedString::from(shorten_pubkey(self.public_key, 4)) } - /// Avatar URL, if set. pub fn picture(&self) -> Option { self.metadata .picture @@ -83,7 +82,6 @@ struct GlobalProfileStore(Entity); impl Global for GlobalProfileStore {} impl ProfileStore { - /// Retrieve the global profile store. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -157,7 +155,6 @@ impl ProfileStore { Profile::new(public_key, Metadata::default()) } - /// Load recently seen profiles from the local database. fn load(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let client = backend.read(cx).client(); @@ -194,7 +191,6 @@ impl ProfileStore { task.detach(); } - /// Re-read the latest metadata of an author from the local database. fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context) { let backend = Backend::global(cx); let client = backend.read(cx).client(); @@ -300,7 +296,6 @@ impl ProfileStore { let mut batch: HashSet = HashSet::new(); loop { - // Wait for the first request of a batch. match receiver.recv_async().await { Ok(public_key) => { batch.insert(public_key); @@ -308,7 +303,6 @@ impl ProfileStore { Err(_) => return Ok(()), } - // Collect everything that arrives within the debounce window. // The channel has no async timeout, race the receive against a timer. let deadline = Instant::now() + BATCH_TIMEOUT; loop { diff --git a/crates/signed_state/src/refresh.rs b/crates/signed_state/src/refresh.rs index 4c11981..d2564b9 100644 --- a/crates/signed_state/src/refresh.rs +++ b/crates/signed_state/src/refresh.rs @@ -1,15 +1,11 @@ /// Refresh coalescing shared by the event stores. #[derive(Debug, Default)] pub struct RefreshGate { - /// A run is in flight. running: bool, - /// A request arrived while a run was in flight. dirty: bool, - /// The debounce timer is pending. debouncing: bool, } -/// What a refresh request decided. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefreshRequest { /// No run or timer covers the request, start the debounce timer. @@ -19,12 +15,10 @@ pub enum RefreshRequest { } impl RefreshGate { - /// Whether a run is in flight. pub fn running(&self) -> bool { self.running } - /// Whether the debounce timer is pending. pub fn debouncing(&self) -> bool { self.debouncing } @@ -45,7 +39,6 @@ impl RefreshGate { } } - /// The debounce timer fired and the run starts now. pub fn begin(&mut self) { self.debouncing = false; self.running = true; diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 006b7af..4770353 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -60,7 +60,6 @@ pub struct RepoStore { /// /// Views key their derived-data caches to it instead of recomputing on every render. version: u64, - /// Error of the last action initiated from this store, if any. pub last_error: Option, /// Non-fatal warning of the last action, if any. /// @@ -86,14 +85,12 @@ pub struct RepoStore { /// /// The per-root fetches cover NIP-22 comments and statuses without an `a` tag. root_fetches: HashSet, - /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, /// Backend subscription of an announced repository. `None` while local-only. _subscription: Option, } impl RepoStore { - /// Announced repository. pub fn new(addr: RepoAddr, hint: Option, cx: &mut Context) -> Self { let weak = cx.entity().downgrade(); let subscription = Self::subscribe_backend(cx); @@ -184,7 +181,6 @@ impl RepoStore { self.refresh(cx); } - /// Subscriptions to the backend events concerning this repository. fn subscribe_backend(cx: &mut Context) -> Subscription { let backend = Backend::global(cx); @@ -231,7 +227,6 @@ impl RepoStore { }) } - /// Returns the repository's NIP-34 address. `None` while it is local-only. pub fn addr(&self) -> Option<&RepoAddr> { self.addr.as_ref() } @@ -287,7 +282,6 @@ impl RepoStore { }); } - /// Fetch this repository's events from the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { let Some(addr) = self.addr.clone() else { return; @@ -586,8 +580,6 @@ impl RepoStore { status_of(&self.status_by_root, root) } - /// Refresh generation, incremented on every applied refresh. - /// Views use it to key their derived-data caches. pub fn version(&self) -> u64 { self.version } @@ -618,7 +610,6 @@ impl RepoStore { .is_some_and(|addr| &addr.public_key == user) } - /// Open an issue on this repository. pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { let Some(addr) = self.addr.clone() else { self.not_announced(cx); @@ -636,7 +627,6 @@ impl RepoStore { self.publish(builder, cx); } - /// Comments on a root event, an issue or PR, oldest first. pub fn comments_of(&self, root: &EventId) -> impl Iterator { self.comments .iter() @@ -675,7 +665,6 @@ impl RepoStore { ); } - /// Open a pull request on this repository. #[allow(clippy::too_many_arguments)] pub fn open_pull_request( &mut self, @@ -1203,7 +1192,6 @@ impl RepoStore { self.publish(builder, cx); } - /// Merge a pull request. pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context) { self.last_error = None; self.last_warning = None; @@ -1302,7 +1290,6 @@ impl RepoStore { }) } - /// 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!( @@ -1347,7 +1334,6 @@ impl RepoStore { }) } - /// Push the unpushed commits of the local checkout at `path`. pub fn push_checkout( &mut self, path: PathBuf, @@ -1477,7 +1463,6 @@ impl RepoStore { 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); @@ -1495,13 +1480,11 @@ impl RepoStore { }) } - /// Record that an action needs a NIP-34 address this repository does not have. fn not_announced(&mut self, cx: &mut Context) { self.last_error = Some("This repository is not published to Nostr yet".into()); cx.notify(); } - /// Fail an operation whose announcement is not loaded yet. fn action_error( &mut self, message: impl Into, @@ -1625,7 +1608,6 @@ where events.into_iter().max_by_key(|e| e.created_at) } -/// Status of `root` from the precomputed map. fn status_of(status_by_root: &HashMap, root: &Event) -> RepoStatus { status_by_root .get(&root.id) @@ -1633,7 +1615,6 @@ fn status_of(status_by_root: &HashMap, root: &Event) -> Rep .unwrap_or(RepoStatus::Open) } -/// Resolve every root event's status in one pass. fn resolve_statuses( issues: &[Event], patches: &[Event], @@ -1800,13 +1781,6 @@ mod tests { ); } - #[test] - fn no_commit_without_header() { - assert_eq!(patch_current_commit(""), None); - assert_eq!(patch_current_commit("Subject: [PATCH] x\n\n---\n"), None); - assert_eq!(patch_current_commit("From short\n"), None); - } - #[test] fn comment_builder_follows_nip22() { let keys = Keys::generate(); @@ -1842,30 +1816,4 @@ mod tests { // Signed's own `references_root` must keep matching the comment. assert!(signed_core::references_root(&event, &root.id)); } - - #[test] - fn comment_builder_replies_nest_under_the_parent() { - let keys = Keys::generate(); - let root = EventBuilder::new(Kind::GitIssue, "issue body") - .finalize(&keys) - .expect("signed event"); - let parent = EventBuilder::new(Kind::Comment, "first comment") - .finalize(&keys) - .expect("signed event"); - let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo"); - - let event = comment_builder(&root, Some(&parent), None, &addr, "reply".into()) - .finalize(&keys) - .expect("signed event"); - - // The uppercase `E` tag still scopes the root event. - // The lowercase `e` tag references the parent comment. - let root_ref = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); - let parent_ref = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); - assert_eq!(root_ref.as_slice()[1], root.id.to_hex()); - assert_eq!(parent_ref.as_slice()[1], parent.id.to_hex()); - - // The reply still threads under the root for Signed's own display. - assert!(signed_core::references_root(&event, &root.id)); - } } diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index dbebf58..be1fca7 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -18,18 +18,14 @@ impl Global for GlobalLocalReposStore {} /// Store of the git repositories discovered under a set of scan paths. pub struct LocalReposStore { - /// The directories being scanned. pub roots: Arc>, /// Git repositories discovered under [`Self::roots`], sorted by path. pub repos: Arc>, - /// A scan is currently running. pub scanning: bool, - /// A scan was requested while one was already running. scan_dirty: bool, } impl LocalReposStore { - /// Retrieve the global local-repositories store. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -38,7 +34,6 @@ impl LocalReposStore { cx.set_global(GlobalLocalReposStore(entity)); } - /// Create a store scanning `roots` right away. pub fn new(roots: Vec, cx: &mut Context) -> Self { let weak = cx.entity().downgrade(); cx.defer(move |cx| { @@ -67,7 +62,6 @@ impl LocalReposStore { cx.notify(); } - /// Re-run the scan. pub fn rescan(&mut self, cx: &mut Context) { if self.scanning { self.scan_dirty = true; @@ -154,13 +148,11 @@ pub struct RepoListStore { /// /// Used for the Popular ranking of the explore list. pub counts: Arc>, - /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, _subscription: Subscription, } impl RepoListStore { - /// Retrieve the global repository list store. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -169,7 +161,6 @@ impl RepoListStore { cx.set_global(GlobalRepoListStore(entity)); } - /// Create the store listing all announcements. pub fn new(cx: &mut Context) -> Self { let backend = Backend::global(cx); let weak = cx.entity().downgrade(); @@ -238,7 +229,6 @@ impl RepoListStore { .collect() } - /// Negentropy-sync announcements with the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { let backend = Backend::global(cx); @@ -261,7 +251,6 @@ impl RepoListStore { self.run_refresh(cx); } - /// One query and apply cycle, the refresh entry point. fn run_refresh(&mut self, cx: &mut Context) { self.refresh.begin(); diff --git a/crates/signed_ui/src/dropdown_button.rs b/crates/signed_ui/src/dropdown_button.rs index ae7126a..e14d37c 100644 --- a/crates/signed_ui/src/dropdown_button.rs +++ b/crates/signed_ui/src/dropdown_button.rs @@ -162,24 +162,3 @@ fn default_caret(id: impl Into, cx: &App) -> BaseButton { }) .child(Icon::new(IconName::ChevronDown).xsmall()) } - -#[cfg(test)] -mod tests { - use gpui::div; - - use super::*; - - #[test] - fn dropdown_button_builder_state() { - let button = DropdownButton::new("issues") - .action(div()) - .anchor(Anchor::BottomLeft) - .dropdown_menu(|menu, _, _| menu); - - assert!(button.action.is_some()); - // The caret is `None` until render, which falls back to the default. - assert!(button.caret.is_none()); - assert!(button.menu.is_some()); - assert_eq!(button.anchor, Anchor::BottomLeft); - } -} diff --git a/crates/signed_ui/src/nav_item.rs b/crates/signed_ui/src/nav_item.rs index f59a13f..f5a93f4 100644 --- a/crates/signed_ui/src/nav_item.rs +++ b/crates/signed_ui/src/nav_item.rs @@ -35,7 +35,6 @@ impl NavItem { } } - /// A trailing element rendered at the right edge of the row pub fn suffix(mut self, suffix: impl IntoElement) -> Self { self.suffix = Some(suffix.into_any_element()); self diff --git a/crates/signed_ui/src/pixel_avatar.rs b/crates/signed_ui/src/pixel_avatar.rs index cb6ee19..f39a529 100644 --- a/crates/signed_ui/src/pixel_avatar.rs +++ b/crates/signed_ui/src/pixel_avatar.rs @@ -175,9 +175,13 @@ mod tests { } #[test] - fn pattern_is_mirror_symmetric() { + fn pattern_properties() { for seed in 0..50 { let pattern = pattern(seed); + assert!( + count_filled(&pattern) >= MIN_FILLED * 2, + "pattern too sparse for seed {seed}" + ); for row in 0..GRID_SIZE { for col in 0..GRID_SIZE { assert_eq!( @@ -188,31 +192,9 @@ mod tests { } } } - } - - #[test] - fn pattern_has_minimum_fill() { - for seed in 0..50 { - let pattern = pattern(seed); - assert!( - count_filled(&pattern) >= MIN_FILLED * 2, - "pattern too sparse for seed {seed}" - ); - } - } - - #[test] - fn pattern_is_deterministic() { for seed in [0, 1, 42, u64::MAX] { assert_eq!(pattern(seed), pattern(seed)); } assert_ne!(pattern(42), pattern(43)); } - - #[test] - fn fnv1a_is_stable_and_distinct() { - assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325); - assert_eq!(fnv1a(b"repo"), fnv1a(b"repo")); - assert_ne!(fnv1a(b"repo:a"), fnv1a(b"repo:b")); - } } diff --git a/crates/signed_ui/src/setting.rs b/crates/signed_ui/src/setting.rs index 94a539c..8e7f633 100644 --- a/crates/signed_ui/src/setting.rs +++ b/crates/signed_ui/src/setting.rs @@ -21,12 +21,10 @@ impl SelectOption { } } - /// The stored value of this option. pub fn value(&self) -> &SharedString { &self.value } - /// The display label of this option. pub fn label(&self) -> &SharedString { &self.label } diff --git a/crates/signed_ui/src/tree_row.rs b/crates/signed_ui/src/tree_row.rs index 8dd2657..2c7d53d 100644 --- a/crates/signed_ui/src/tree_row.rs +++ b/crates/signed_ui/src/tree_row.rs @@ -35,7 +35,6 @@ where .child(div().text_sm().text_ellipsis().child(item.label.clone())), ) .on_click(move |_event, window, cx| { - // Folders expand/collapse via the tree itself. if is_folder { return; } diff --git a/crates/signed_ui/src/util.rs b/crates/signed_ui/src/util.rs index 3e4d0e5..64d187a 100644 --- a/crates/signed_ui/src/util.rs +++ b/crates/signed_ui/src/util.rs @@ -33,7 +33,6 @@ mod tests { ), "30617:a008...3564d:ngit" ); - // Too short to save space with the ellipsis, left alone. assert_eq!(middle_truncate("short", 10, 10), "short"); } } diff --git a/crates/utils/src/time.rs b/crates/utils/src/time.rs index c2c39b6..230ddf9 100644 --- a/crates/utils/src/time.rs +++ b/crates/utils/src/time.rs @@ -39,10 +39,6 @@ mod tests { assert_eq!(relative_time(now - 3 * 86_400), "3d ago"); assert_eq!(relative_time(now - 60 * 86_400), "2mo ago"); assert_eq!(relative_time(now - 800 * 86_400), "2y ago"); - } - - #[test] - fn clamps_future_timestamps() { - assert_eq!(relative_time(Timestamp::now() + 600), "just now"); + assert_eq!(relative_time(now + 600), "just now"); } } diff --git a/crates/workspace/src/views/commit_diff/mod.rs b/crates/workspace/src/views/commit_diff/mod.rs index e6a3904..6921166 100644 --- a/crates/workspace/src/views/commit_diff/mod.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -25,22 +25,15 @@ use crate::views::repo::helpers::{ DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items, }; -/// Width of the changed-files column. const TREE_WIDTH: f32 = 260.; -/// Tree and per-file diff body, shared by the commit diff and compare views. pub struct DiffPane { - /// Loaded diff, `None` until [`Self::set_diff`] is called. diff: Option, - /// Changed-files explorer state. tree_state: Entity, - /// Path of the file whose diff is shown in the detail column. selected_file: Option, /// Rows of the selected file's diff, hunk headers and lines. rows: Vec, - /// Per-row heights of [`Self::rows`]. item_sizes: Rc>>, - /// Virtual list state of the diff rows. scroll_handle: VirtualListScrollHandle, } @@ -56,12 +49,10 @@ impl DiffPane { } } - /// The loaded diff, for stats and badges in the host's header. pub fn diff(&self) -> Option<&CommitDiff> { self.diff.as_ref() } - /// Replace the diff and rebuild the tree and the selected file's rows. pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context) { let mut paths: Vec = diff .files @@ -86,9 +77,6 @@ impl DiffPane { } } - /// Forget the diff, e.g. when the compared branches changed. - /// - /// Clears the tree, the selection and the diff rows. pub fn clear(&mut self, cx: &mut Context) { self.diff = None; self.selected_file = None; @@ -99,14 +87,12 @@ impl DiffPane { }); } - /// Show the diff of the file at `path`, selected in the tree. fn select_file(&mut self, path: &str, cx: &mut Context) { self.selected_file = Some(path.into()); self.set_diff_rows(path); cx.notify(); } - /// Rebuild the virtual list state for `path` and scroll back to the top. fn set_diff_rows(&mut self, path: &str) { let Some(diff) = self.diff.as_ref() else { return; @@ -119,7 +105,6 @@ impl DiffPane { self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); } - /// One row of the changed-files tree, icon and name, indented by depth. fn render_tree_item( ix: usize, entry: &TreeEntry, @@ -136,7 +121,6 @@ impl DiffPane { }) } - /// Left column showing the changed-files tree. fn render_tree_column(&self, cx: &mut Context) -> AnyElement { let tree_state = self.tree_state.clone(); let view = cx.entity().downgrade(); @@ -166,7 +150,6 @@ impl DiffPane { .into_any_element() } - /// Right column, header of the selected file plus its diff. fn render_detail_column(&self, cx: &mut Context) -> AnyElement { let Some(diff) = self.diff.as_ref() else { return placeholder("No changes", cx); @@ -184,7 +167,6 @@ impl DiffPane { self.render_file_diff(file, cx.entity(), cx) } - /// The diff of one file, with a header showing status and stats. fn render_file_diff(&self, file: &FileDiff, view: Entity, cx: &App) -> AnyElement { let status_label = match file.status { DiffStatus::Added => "A", @@ -311,19 +293,14 @@ impl Render for DiffPane { } } -/// Detail panel showing the diff of one commit. pub struct CommitDiffView { focus_handle: FocusHandle, - /// Local clone the commit lives in. worktree: PathBuf, - /// Display name of the repository the commit belongs to. repo_name: SharedString, - /// The commit being shown in the header and tab title. commit: FileCommit, /// The diff is being computed on a background task. loading: bool, error: Option, - /// Changed-files explorer and per-file diff, also used by the new PR panel's compare view. pane: Entity, } @@ -359,7 +336,6 @@ impl CommitDiffView { } } - /// Load the commit diff and the full commit metadata. fn load(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; @@ -407,7 +383,6 @@ impl CommitDiffView { task.detach(); } - /// Header with the commit id, summary, author/time and overall change stats. fn render_header(&self, cx: &mut Context) -> AnyElement { let commit = &self.commit; let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| { diff --git a/crates/workspace/src/views/dialog_state.rs b/crates/workspace/src/views/dialog_state.rs index b303490..2eda596 100644 --- a/crates/workspace/src/views/dialog_state.rs +++ b/crates/workspace/src/views/dialog_state.rs @@ -2,8 +2,7 @@ use gpui::prelude::*; use gpui::{AnyElement, App, SharedString, div}; use gpui_component::ActiveTheme; -/// Progress of an async dialog action: a busy flag disabling the form, -/// and an error line shown under it. +/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it. #[derive(Debug, Default)] pub struct DialogProgress { pub busy: bool, @@ -11,13 +10,13 @@ pub struct DialogProgress { } impl DialogProgress { - /// An action started, disable the form and clear the previous error. + /// Marks an action as started, disabling the form and clearing the previous error. pub fn begin(&mut self) { self.busy = true; self.error = None; } - /// An action failed, re-enable the form and surface `message`. + /// Marks an action as failed, enabling the form and showing `message`. pub fn fail(&mut self, message: impl Into) { self.busy = false; self.error = Some(message.into()); diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 172a26c..8ccc501 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -21,20 +21,13 @@ use utils::relative_time; use super::{RepoItem, open_repo_item}; -/// Delay between a refresh request and the actual re-query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); - -/// Extra list rows measured above and below the visible area. const LIST_OVERDRAW: Pixels = px(400.); - -/// Maximum number of sub-activity lines shown under a thread row. const MAX_SUB_ACTIVITIES: usize = 5; -/// A repository's slice of the inbox: the threads that belong to it. struct InboxSection { - /// Repository the section groups, `None` for items without one. + /// `None` for items without a repository. address: Option, - /// Number of threads with an unread event. unread: usize, /// Indices into the threads, newest activity first. entries: Vec, @@ -54,15 +47,11 @@ pub struct InboxView { dock_area: WeakEntity, /// One row per thread, merging notifications and own activity, newest first. threads: Arc>, - /// The threads grouped by repository, newest first. sections: Arc>, - /// The flattened repository headers and rows of the list. rows: Arc>, - /// Number of non-archived threads with an unread event. unread_count: usize, /// Copy of the global read state the current lists were derived with. state: InboxReadState, - /// Set once the global state has been read for the current user. state_loaded: bool, refresh: RefreshGate, list: ListState, @@ -118,7 +107,6 @@ impl InboxView { } } - /// Mark every known notification read. pub fn mark_all_read(&mut self, cx: &mut Context) { let Some(me) = Backend::global(cx).read(cx).current_user() else { return; @@ -136,7 +124,6 @@ impl InboxView { inbox.update(cx, |inbox, cx| inbox.mark_all_read(&all, me, cx)); } - /// Re-derive from the global state when it is loaded or changes. pub fn sync_state(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let inbox = backend.read(cx).inbox(); @@ -170,7 +157,6 @@ impl InboxView { } } - /// Handle a backend event that can change the derived sections. fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context) { match event { BackendEvent::NostrUpdate(updates) => { @@ -192,7 +178,6 @@ impl InboxView { } } - /// One-shot initial load, no debounce. fn refresh_initial(&mut self, cx: &mut Context) { debug_assert!(!self.refresh.debouncing()); if self.refresh.running() { @@ -202,7 +187,6 @@ impl InboxView { self.run_refresh(cx); } - /// Re-query the local database. fn refresh(&mut self, cx: &mut Context) { if !self.state_loaded { return; @@ -218,7 +202,6 @@ impl InboxView { })); } - /// One query and apply cycle, the debounced entry point. fn run_refresh(&mut self, cx: &mut Context) { self.refresh.begin(); @@ -264,7 +247,6 @@ impl InboxView { })); } - /// Recompute the unread and archived flags from the current state. fn regroup(&mut self, cx: &mut Context) { let mut items = (*self.threads).clone(); @@ -277,7 +259,6 @@ impl InboxView { self.rebuild(cx); } - /// Regroup the current threads by repository and flatten them into rows. fn rebuild(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let repo_list = RepoListStore::global(cx); @@ -308,7 +289,6 @@ impl InboxView { self.rows = Arc::new(rows); } - /// Group the threads into one section per repository. fn group_sections(&self) -> Vec { let mut by_repo: HashMap, InboxSection> = HashMap::new(); @@ -349,7 +329,6 @@ impl InboxView { sections } - /// Flatten the sections into the list of repository headers and their rows. fn flatten_rows(&self, sections: &[InboxSection]) -> Vec { let mut rows = Vec::new(); @@ -369,7 +348,6 @@ impl InboxView { rows } - /// Forget everything derived for the current user. fn clear(&mut self) { self.threads = Arc::new(Vec::new()); self.sections = Arc::new(Vec::new()); @@ -445,7 +423,6 @@ impl InboxView { } } -/// Display name of the repository at `addr`, from the announcement store. fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { let repo_list = RepoListStore::global(cx); let addr = addr?; @@ -457,7 +434,6 @@ fn repo_name(addr: Option<&RepoAddr>, cx: &App) -> Option { .map(|announcement| announcement.name().map(SharedString::from)) } -/// Header of a repository section. fn repo_header(section: &InboxSection, cx: &App) -> AnyElement { let name = repo_name(section.address.as_ref(), cx).unwrap_or_else(|| SharedString::from("Untitled")); @@ -481,7 +457,6 @@ fn repo_header(section: &InboxSection, cx: &App) -> AnyElement { .into_any_element() } -/// Placeholder under a repository header that has nothing to show. fn empty_section_row(cx: &App) -> AnyElement { h_flex() .h_12() @@ -600,7 +575,6 @@ fn sub_activity(event: &Event, me: Option, cx: &App) -> AnyElement { .into_any_element() } -/// Phrase describing an activity event, read as `[name] [phrase]`. fn activity_phrase(kind: Kind) -> &'static str { if kind == COVER_NOTE_KIND { return "added a note"; @@ -620,7 +594,6 @@ fn activity_phrase(kind: Kind) -> &'static str { } } -/// Centered muted icon and message filling its container. fn empty_state(icon: impl IconNamed, message: &str, cx: &App) -> AnyElement { v_flex() .w_full() diff --git a/crates/workspace/src/views/issues/detail.rs b/crates/workspace/src/views/issues/detail.rs index c80a69a..a714407 100644 --- a/crates/workspace/src/views/issues/detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -15,13 +15,10 @@ use utils::relative_time; use crate::views::repo::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; -/// Detail panel of a single issue. pub struct IssueDetailView { focus_handle: FocusHandle, - /// Repo store holding the issues and their statuses. store: Entity, issue_id: EventId, - /// Input state of the comment textarea. comment_input: Entity, _subscription: Subscription, } diff --git a/crates/workspace/src/views/issues/mod.rs b/crates/workspace/src/views/issues/mod.rs index 311487e..a3741ed 100644 --- a/crates/workspace/src/views/issues/mod.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -25,22 +25,16 @@ pub(super) mod detail; use self::detail::IssueDetailView; -/// Height of one issue row in the virtual list. const ISSUE_ROW_HEIGHT: f32 = 73.; -/// Status filter of the issues list, chosen via the header's filter buttons. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IssueFilter { - /// Every issue, regardless of status. All, - /// Issues whose resolved status is [`RepoStatus::Open`]. Open, - /// Issues whose resolved status is [`RepoStatus::Closed`]. Closed, } impl IssueFilter { - /// Whether an issue with `status` is included by this filter. fn matches(self, status: RepoStatus) -> bool { match self { Self::All => true, @@ -52,28 +46,17 @@ impl IssueFilter { pub struct IssuesView { focus_handle: FocusHandle, - /// Dock area the issue detail panel is opened in. dock_area: WeakEntity, - /// Repo store holding the issues and their statuses. store: Entity, - /// Display name of the repository, for the panel title. repo_name: SharedString, - /// Filter selected in the header filter buttons. filter: IssueFilter, - /// Per-row heights of the virtual list. item_sizes: Rc>>, - /// Indices into the store's `issues` matching [`Self::filter`]. visible_issues: Vec, - /// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`]. counts: (usize, usize, usize), - /// Filter [`Self::visible_issues`] was last rebuilt for. - /// - /// A filter change notifies even when the visible rows are unchanged, - /// e.g. switching between two empty filters. + // A filter change notifies even when the visible rows are unchanged, + // e.g. switching between two empty filters. synced_filter: IssueFilter, - /// Virtual list state of the issues list. scroll_handle: VirtualListScrollHandle, - /// Rebuild the rows and re-render when the store's data changes. _subscription: Subscription, } @@ -235,7 +218,6 @@ impl IssuesView { } fn render_header(&self, cx: &mut Context) -> AnyElement { - // Counts of the last list rebuild. let (total, open, closed) = self.counts; h_flex() @@ -293,7 +275,6 @@ impl IssuesView { } } -/// Open the new issue dialog, a title and a content input. pub(super) fn open_new_issue_dialog(store: Entity, window: &mut Window, cx: &mut App) { let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title")); let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue...")); diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index 5e7e526..93b8508 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -33,14 +33,11 @@ use utils::{relative_time, relative_time_secs}; use crate::views::commit_diff::{CommitDiffView, DiffPane}; use crate::views::repo::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; -/// Height of one commit row in the commits tab's virtual list. const ROW_HEIGHT: f32 = 37.; /// Shown once the store's first pass is applied and the root PR is still absent. const NOT_FOUND: &str = "Pull request not found"; -/// Root PR inputs one diff load is keyed to. -/// /// A store refresh re-binds the panel, and reloads only when these change. #[derive(Clone, PartialEq, Eq)] struct PrBinding { @@ -56,22 +53,16 @@ struct PrBinding { /// Detail panel of a single pull request. pub struct PullRequestDetailView { focus_handle: FocusHandle, - /// Dock area where new panels, e.g. commit diffs, are added. dock_area: WeakEntity, - /// Repo store holding the PR, its status and comments. store: Entity, - /// Event id of the root PR event, kind 1618. - /// Updates are revisions. + /// Event id of the root PR event, kind 1618. Updates are revisions. pr_id: EventId, - /// Input state of the comment textarea. comment_input: Entity, - /// Display name of the repository, for panels opened from here. repo_name: SharedString, /// Local clone the PR's git changes come from. worktree: Option, - /// Root PR's content, shown as plain text. description: SharedString, - /// Tip commit of the PR, the latest update's `c` tag or the root's. + /// Tip commit of the PR, from the latest update's `c` tag or the root. current_commit: Option, /// Commits of the patch series, in patch order, oldest first. commits: Vec, @@ -82,17 +73,13 @@ pub struct PullRequestDetailView { bound: Option, /// Generation of the in-flight diff load. Stale results are discarded. load_generation: u64, - /// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits. + /// 0 = Discussion, 1 = Files, 2 = Commits. active_tab: usize, - /// Changed-files explorer and per-file diff, like the commit and compare views. pane: Entity, - /// Per-row heights of the commits tab's virtual list, built when the patch series loads. commit_item_sizes: Rc>>, - /// Virtual list state of the commits tab. commit_scroll_handle: VirtualListScrollHandle, - /// Re-render when the store's first pass or a later refresh lands. - /// Item panels are cached by the dock, so without this observer a panel - /// opened before the store loaded would stay on its placeholder. + /// The dock caches item panels, so without this observer a panel opened + /// before the store loaded would stay on its placeholder. _subscription: Subscription, } @@ -523,9 +510,6 @@ impl PullRequestDetailView { .into_any_element() } - /// Full-height Commits tab. - /// - /// Every commit of the patch series, or a status message while loading or empty. fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { if self.loading { return v_flex() @@ -573,9 +557,6 @@ impl PullRequestDetailView { .into_any_element() } - /// One row of the commits tab, id, summary, author and time. - /// - /// Clicking a row opens the commit's diff in the bottom dock. fn render_commit_row( &self, ix: usize, @@ -628,7 +609,6 @@ impl PullRequestDetailView { .into_any_element() } - /// Always-visible header with a status badge and title, like the issue panel. fn render_header(&self, cx: &mut Context) -> AnyElement { let current_commit = self.current_commit.clone(); let (title, status, branch, author) = { @@ -730,7 +710,6 @@ impl PullRequestDetailView { } } -/// Open the update pull request dialog. fn open_update_pull_request_dialog( store: Entity, root: Event, @@ -791,7 +770,6 @@ fn open_update_pull_request_dialog( }); } -/// The `c` tag of a PR event, the commit the proposal points at. /// One-line commit metadata for the commits list. /// /// Author and relative time, whichever is available. @@ -849,26 +827,3 @@ impl Render for PullRequestDetailView { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - const COMMIT_HEX: &str = "1111111111111111111111111111111111111111"; - - #[test] - fn commit_meta_combines_author_and_time() { - let commit = |author: &str, time: i64| FileCommit { - id: COMMIT_HEX.into(), - summary: "summary".into(), - description: None, - author: author.into(), - time, - }; - - assert_eq!(commit_meta(&commit("Alice", 0)), "Alice"); - assert_eq!(commit_meta(&commit("", 0)), ""); - assert!(!commit_meta(&commit("", 1_000_000)).is_empty()); - assert!(!commit_meta(&commit("Alice", 1_000_000)).is_empty()); - } -} diff --git a/crates/workspace/src/views/pull_requests/mod.rs b/crates/workspace/src/views/pull_requests/mod.rs index 4af7ce9..a1a4949 100644 --- a/crates/workspace/src/views/pull_requests/mod.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -27,26 +27,18 @@ use self::new::open_new_pull_panel; use super::send_patch::open_send_patch_panel; use crate::views::repo::RepoAction; -/// Height of one pull request row in the virtual list. const ROW_HEIGHT: f32 = 73.; -/// Status filter of the pull request list, chosen via the header's filter buttons. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PullRequestFilter { - /// Every pull request, regardless of status. All, - /// Pull requests whose resolved status is [`RepoStatus::Open`]. Open, - /// Pull requests whose resolved status is [`RepoStatus::Closed`]. Closed, - /// Pull requests whose resolved status is [`RepoStatus::Draft`]. Draft, - /// Pull requests whose resolved status is [`RepoStatus::Applied`]. Merged, } impl PullRequestFilter { - /// Whether a pull request with `status` is included by this filter. fn matches(self, status: RepoStatus) -> bool { match self { Self::All => true, @@ -60,28 +52,19 @@ impl PullRequestFilter { pub struct PullRequestsView { focus_handle: FocusHandle, - /// Dock area the detail panels are added to. dock_area: WeakEntity, - /// Repo store holding the pull requests and their statuses. store: Entity, - /// Display name of the repository, for the panel title. repo_name: SharedString, - /// Filter selected in the header filter buttons. filter: PullRequestFilter, - /// Per-row heights of the virtual list. item_sizes: Rc>>, /// Indices into the store's `pull_requests` matching [`Self::filter`]. visible_prs: Vec, /// Header counts `(total, open, closed, draft, merged)`. counts: (usize, usize, usize, usize, usize), - /// Filter [`Self::visible_prs`] was last rebuilt for. - /// - /// A filter change notifies even when the visible rows are unchanged, - /// e.g. switching between two empty filters. + // A filter change notifies even when the visible rows are unchanged, + // e.g. switching between two empty filters. synced_filter: PullRequestFilter, - /// Virtual list state of the pull requests list. scroll_handle: VirtualListScrollHandle, - /// Rebuild the rows and re-render when the store's data changes. _subscription: Subscription, } @@ -117,7 +100,6 @@ impl PullRequestsView { } } - /// Rebuild the visible rows, header counts and virtual-list sizes. fn rebuild(&mut self, cx: &mut Context) { let filter = self.filter; @@ -165,7 +147,6 @@ impl PullRequestsView { cx.notify(); } - /// Open the detail panel of `pr_id` in the dock area. fn open_pull_request_detail( &mut self, pr_id: EventId, @@ -191,9 +172,6 @@ impl PullRequestsView { }); } - /// Render one row of the pull request list. - /// - /// `ix` is the row index, `pr_ix` the index in the store's `pull_requests`. fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context) -> AnyElement { let pr = &self.store.read(cx).pull_requests[pr_ix]; let pr_id = pr.id; @@ -258,7 +236,6 @@ impl PullRequestsView { } fn render_header(&self, cx: &mut Context) -> AnyElement { - // Counts of the last list rebuild. let (total, open, closed, draft, merged) = self.counts; h_flex() @@ -397,8 +374,6 @@ impl Render for PullRequestsView { let scroll_handle = self.scroll_handle.clone(); let view = cx.entity().clone(); - // Non-fatal warnings and errors of the last action, like creating or updating a PR. - // Shown as dismissible banners above the list. let (last_error, last_warning) = { let store = self.store.read(cx); (store.last_error.clone(), store.last_warning.clone()) diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 06f19ea..5a5d006 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -32,20 +32,15 @@ use signed_ui::{CountBadge, placeholder}; use crate::views::commit_diff::{CommitDiffView, DiffPane}; use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row, ref_selector_trigger}; -/// The new pull request panel of a repository. pub struct NewPullRequestView { focus_handle: FocusHandle, - /// Dock area the panel lives in, commit diffs are opened there. dock_area: WeakEntity, - /// Store of the target repository, source of the announced HEAD default. store: Entity, - /// Display name of the repository, for the panel title. repo_name: SharedString, /// The user's local checkout. repo_path: Option, - /// Branches of the checkout, backing both selectors in checkout mode. + /// Backs both selectors in checkout mode. branches: Vec, - /// Fork-backed compare state. fork: Option, /// Selected base branch, the PR target, stored as a short name. base: SharedString, @@ -53,33 +48,27 @@ pub struct NewPullRequestView { compare: SharedString, base_select: Entity>>, compare_select: Entity>>, - /// Title input, required. subject: Entity, - /// Description input, optional. description: Entity, /// Merge base of the selected branches, `None` until the compare loads. merge_base: Option, /// Commits in `merge_base..compare`, newest first. commits: Option>, - /// The compare is being computed. loading: bool, - /// Error of the last compare or submit attempt. error: Option, /// A submit, patch generation and publish, is in flight. submitting: bool, /// Bumped on every branch switch, stale compare results are discarded. compare_generation: u64, - /// Active tab, 0 = Files and 1 = Commits. + /// 0 = Files, 1 = Commits. active_tab: usize, /// The compare diff, the Files tab body. pane: Entity, - /// Virtual list state of the Commits tab. scroll_handle: VirtualListScrollHandle, item_sizes: Rc>>, _subscriptions: Vec, } -/// A fork-backed compare. struct ForkCompare { /// Fork announcement the compare branch is imported from. announcement: Announcement, @@ -90,20 +79,15 @@ struct ForkCompare { } impl ForkCompare { - /// The full ref of the base branch `name` in the mirror. fn base_ref(name: &str) -> String { format!("refs/remotes/origin/{name}") } - /// The full ref of the compare branch `name` in the mirror. fn compare_ref(&self, name: &str) -> String { format!("refs/fork/{}/{}", self.namespace, name) } } -/// The display name of an announcement. -/// -/// Its human-readable name, falling back to the repository id. fn fork_display_name(announcement: &Announcement) -> SharedString { announcement .name @@ -112,7 +96,6 @@ fn fork_display_name(announcement: &Announcement) -> SharedString { .unwrap_or_else(|| SharedString::from(announcement.id.clone())) } -/// A short label of a fork's owner for the source picker, a hex prefix. fn shorten_owner(owner: &PublicKey) -> String { let hex = owner.to_hex(); hex.chars().take(10).collect() @@ -131,9 +114,6 @@ fn truncate_label(label: &str) -> SharedString { SharedString::from(label) } -/// The compare-source menu entry of one local checkout folder. -/// -/// Applies the folder directly, no picker. fn checkout_source_item( view: WeakEntity, path: PathBuf, @@ -162,7 +142,6 @@ fn checkout_source_item( }) } -/// The compare-source menu entry prompting for an arbitrary folder. fn choose_folder_source_item(view: WeakEntity) -> PopupMenuItem { PopupMenuItem::element(move |_window, cx| { source_row( @@ -179,9 +158,6 @@ fn choose_folder_source_item(view: WeakEntity) -> PopupMenuI }) } -/// The compare-source menu entry of one announced fork. -/// -/// Imports its branches into the target's mirror and switches the panel to fork mode. fn fork_source_item( view: WeakEntity, announcement: Announcement, @@ -207,7 +183,6 @@ fn fork_source_item( }) } -/// One row of the compare-source menu, icon, title and a muted subtitle. fn source_row(icon: impl Into, title: T, subtitle: T, cx: &App) -> AnyElement where T: Into, @@ -350,7 +325,6 @@ impl NewPullRequestView { } } - /// Whether a compare source, a checkout or a fork, is applied. fn has_source(&self) -> bool { self.repo_path.is_some() || self.fork.is_some() } @@ -387,7 +361,6 @@ impl NewPullRequestView { } } - /// Prompt for a local checkout. fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context) { let prompt = cx.prompt_for_paths(PathPromptOptions { files: false, @@ -418,9 +391,7 @@ impl NewPullRequestView { task.detach(); } - /// Apply `path` as the local checkout, no picker. - /// - /// Branches and current branch are read off the UI thread, then applied. + /// Branches and the current branch are read off the UI thread, then applied. fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { let path = path.to_string_lossy().to_string(); @@ -449,7 +420,6 @@ impl NewPullRequestView { task.detach(); } - /// Apply a picked checkout, filling the selectors and loading the compare. fn apply_checkout( &mut self, path: String, @@ -527,8 +497,6 @@ impl NewPullRequestView { self.reload_compare(window, cx); } - /// The base repository of the panel, its address and announced EUC. - /// /// Used to find fork candidates. `None` while the repository is not announced. fn base_repo(&self, cx: &App) -> Option<(RepoAddr, Option)> { let store = self.store.read(cx); @@ -552,7 +520,6 @@ impl NewPullRequestView { .collect() } - /// Compare against an announced fork. fn choose_fork( &mut self, announcement: Announcement, @@ -607,9 +574,6 @@ impl NewPullRequestView { let clone_urls = clone_urls.clone(); let mirror_path = mirror_path.clone(); async move { - // The fork and base must share history for a merge-base to exist. - // The target's mirror is the object store both sides land in. - // `ensure_clone` fetches `origin` when the mirror already exists. cache.ensure_clone(&base, &base_clone_urls)?; // Prune stale imports of any fork. @@ -678,7 +642,6 @@ impl NewPullRequestView { task.detach(); } - /// Apply an imported fork, filling the selectors and loading the compare. #[allow(clippy::too_many_arguments)] fn apply_fork( &mut self, @@ -783,7 +746,6 @@ impl NewPullRequestView { self.reload_compare(window, cx); } - /// Recompute `merge_base..compare` of the selected branches on a background task. fn reload_compare(&mut self, window: &mut Window, cx: &mut Context) { let Some(repo_path) = self.work_path() else { return; @@ -877,7 +839,6 @@ impl NewPullRequestView { task.detach(); } - /// Publish the pull request. fn submit(&mut self, window: &mut Window, cx: &mut Context) { if self.submitting || self.loading { return; @@ -959,7 +920,6 @@ impl NewPullRequestView { task.detach(); } - /// Open the diff of `commit_id`, from the Commits tab, in a new panel. fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { let Some(repo_path) = self.work_path() else { return; @@ -1117,7 +1077,6 @@ impl NewPullRequestView { .into_any_element() } - /// The source picker's trigger, a truncated label of the applied source. fn source_trigger(&self) -> SharedString { match &self.fork { Some(fork) => truncate_label(&fork_display_name(&fork.announcement)), @@ -1128,7 +1087,6 @@ impl NewPullRequestView { } } - /// Build the compare-source menu. fn source_menu( &self, cx: &Context, @@ -1185,7 +1143,6 @@ impl NewPullRequestView { } } - /// The title and description inputs. fn render_inputs(&self, _cx: &mut Context) -> AnyElement { v_flex() .px_4() @@ -1196,7 +1153,6 @@ impl NewPullRequestView { .into_any_element() } - /// The Files/Commits tab bar, mirroring the repository panel's. fn render_tabs(&self, cx: &mut Context) -> AnyElement { let files = self.pane.read(cx).diff().map_or(0, |diff| diff.files.len()); let commits = self.commits.as_ref().map_or(0, |commits| commits.len()); @@ -1268,7 +1224,6 @@ impl NewPullRequestView { .into_any_element() } - /// The active tab's body. fn render_content(&self, cx: &mut Context) -> AnyElement { if self.loading { return v_flex() @@ -1354,7 +1309,6 @@ impl NewPullRequestView { } } -/// Open the new pull request panel in the center dock. pub(crate) fn open_new_pull_panel( dock_area: WeakEntity, store: Entity, diff --git a/crates/workspace/src/views/repo/about.rs b/crates/workspace/src/views/repo/about.rs index d9e7568..3b8b7e4 100644 --- a/crates/workspace/src/views/repo/about.rs +++ b/crates/workspace/src/views/repo/about.rs @@ -7,7 +7,6 @@ use signed_core::Announcement; use signed_state::ProfileStore; use signed_ui::{UserAvatar, middle_truncate}; -/// Open the About dialog showing every field of the announcement event. pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, cx| { let announcement = announcement.clone(); @@ -21,7 +20,6 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, }); } -/// The announcement's fields as labeled rows. fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { let mut rows: Vec = Vec::new(); @@ -116,7 +114,6 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { v_flex().gap_3().w_full().children(rows).into_any_element() } -/// One info row with a small muted label above the value. fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement { v_flex() .gap_1() @@ -132,7 +129,6 @@ fn row(label: &'static str, value: AnyElement, cx: &App) -> AnyElement { .into_any_element() } -/// Plain text value, wrapping within the dialog. fn text(value: T) -> AnyElement where T: Into, @@ -147,7 +143,6 @@ where .into_any_element() } -/// A mono-spaced value with a copy button, for hex identifiers. fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement { h_flex() .gap_2() @@ -164,10 +159,6 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement { .into_any_element() } -/// One row per maintainer with avatar and display name. -/// The display name falls back to a shortened npub. -/// -/// A copy button copies the full pubkey. fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { let profile_store = ProfileStore::global(cx); v_flex() @@ -197,9 +188,6 @@ fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { .into_any_element() } -/// One row per item of a multi-value tag. -/// -/// The value is truncated to a single line, with a copy button for the full value. fn list(id: &'static str, items: impl IntoIterator, cx: &App) -> AnyElement { v_flex() .gap_2() diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs index ee04c28..8682bf2 100644 --- a/crates/workspace/src/views/repo/actions.rs +++ b/crates/workspace/src/views/repo/actions.rs @@ -18,7 +18,6 @@ use crate::views::pull_requests::detail::PullRequestDetailView; use crate::views::repo::init_dialog; impl RepoDetailView { - /// Re-push the repository's refs to its announced grasp servers. pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { self.error = None; cx.notify(); @@ -28,7 +27,6 @@ impl RepoDetailView { .detach(); } - /// Push the unpushed commits of the local checkout at `path`. pub(super) fn push_unpushed_checkout( &mut self, path: PathBuf, @@ -71,7 +69,6 @@ impl RepoDetailView { .detach(); } - /// Open the issues list panel in the dock area. pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { if self.store.read(cx).addr().is_none() { return; @@ -89,7 +86,6 @@ impl RepoDetailView { }); } - /// Open the pull requests list panel in the dock area. pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { if self.store.read(cx).addr().is_none() { return; @@ -124,7 +120,7 @@ impl RepoDetailView { open_repo_panel(&self.dock_area, &addr, None, window, &mut *cx); } - /// Open the dialog guiding the user through publishing the local repository to NIP-34. + /// Open the dialog that publishes the local repository to NIP-34. pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { let Some(local_path) = self.store.read(cx).path.clone() else { return; @@ -176,17 +172,8 @@ pub(crate) enum RepoItem { Patch, } -/// Open the detail panel of `item` in `addr`'s repository, in the dock's center. -/// /// The repository store is built here, not taken from a `RepoDetailView`, so the /// item panel is the only panel docked. -/// -/// `hint` is an announcement already in hand for `addr`, e.g. the inbox row the -/// item was clicked from. The store resolves the repository from the local -/// database on its own, so an entry point with only the address works too. -/// -/// A patch opens nothing: patches are only consumed inside a pull request's -/// detail panel, and have no panel of their own. pub(crate) fn open_repo_item( dock_area: &WeakEntity, addr: &RepoAddr, diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index f686233..ee9e1c7 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -67,9 +67,6 @@ impl RepoDetailView { }) } - /// The ready-to-push banner of an owned repository. - /// - /// A local checkout has unpushed commits, with a Push action and a dismiss control. pub(super) fn render_push_banner(&self, cx: &Context) -> Option { let status = self.push_suggestion(cx)?; let key = (status.path.clone(), status.branch.clone()); @@ -155,7 +152,6 @@ impl RepoDetailView { ) } - /// Warning after a push that only some grasp servers accepted. pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { let store = self.store.read(cx); let warning = store.last_push_warning.clone()?; @@ -220,7 +216,6 @@ impl RepoDetailView { ) } - /// The ready-to-contribute banner of the repository panel. pub(super) fn render_ready_banner(&self, cx: &Context) -> Option { let status = self.ready_suggestion(cx)?; let key = (status.path.clone(), status.branch.clone()); diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs index 152c317..16c8cd4 100644 --- a/crates/workspace/src/views/repo/files.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -15,38 +15,28 @@ use signed_ui::{placeholder, tree_row}; use super::RepoDetailView; use crate::views::repo::helpers::{code_language, is_markdown_path}; -/// Width of the file explorer column. const TREE_WIDTH: f32 = 240.; -/// Files larger than this are not previewed. pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; -/// Preview cache caps, a file count and a text byte count. -/// /// The oldest previews are evicted beyond the caps. pub(super) const MAX_PREVIEWED_FILES: usize = 32; pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024; -/// Preview state of a browsed file. pub(super) enum FileContent { - /// Decodable text content. Text(String), - /// Not valid UTF-8. Binary, /// Bigger than [`MAX_PREVIEW_BYTES`]. TooLarge, - /// Reading failed. Failed(String), } -/// A markdown document loaded into a persistent [`TextViewState`]. pub(super) struct MarkdownView { - /// Source path, `None` means the repository README. + /// `None` means the repository README. pub(super) path: Option, pub(super) state: Entity, /// Hash of the source, so the same document is not re-parsed on a refresh. source_hash: u64, } -/// A code file loaded into a persistent [`InputState`]. pub(super) struct CodeView { /// Source path, relative to the worktree root. pub(super) path: SharedString, @@ -55,8 +45,6 @@ pub(super) struct CodeView { source_hash: u64, } -/// Hash of a preview's source text. -/// /// Two loads of the same document produce the same hash, so the persistent /// markdown/editor state can be kept instead of rebuilt, which would re-parse /// and flash the pane. @@ -68,7 +56,6 @@ fn source_hash(text: &str) -> u64 { hasher.finish() } -/// Spinner shown while a document is being loaded/parsed. fn preview_spinner() -> AnyElement { v_flex() .size_full() @@ -79,7 +66,6 @@ fn preview_spinner() -> AnyElement { } impl RepoDetailView { - /// One row of the file tree with icon and name, indented by depth. fn render_tree_item( ix: usize, entry: &TreeEntry, @@ -96,7 +82,6 @@ impl RepoDetailView { }) } - /// Left column showing the file tree. pub(super) fn render_tree_column( tree_state: Entity, view: WeakEntity, @@ -117,7 +102,6 @@ impl RepoDetailView { ))) } - /// Right column, README, selected file preview or status text. pub(super) fn render_content_column( &self, pane_title: SharedString, @@ -230,7 +214,6 @@ impl RepoDetailView { .child(div().id("repo-content").flex_1().min_h_0().child(body)) } - /// Load `text` into the persistent markdown TextView state. pub(super) fn set_markdown( &mut self, path: Option, @@ -254,9 +237,6 @@ impl RepoDetailView { }); } - /// The persistent markdown TextView for `path`, where `None` is the README. - /// - /// Shows a spinner while the document is being loaded or parsed. fn markdown_element(&self, path: Option<&str>, _cx: &mut Context) -> AnyElement { let Some(md) = &self.md else { return preview_spinner(); @@ -279,9 +259,6 @@ impl RepoDetailView { .into_any_element() } - /// Load `text` into the persistent code editor state for `path`. - /// - /// Code editor mode makes the Input render it read-only and highlighted. pub(super) fn set_code( &mut self, path: SharedString, @@ -312,7 +289,6 @@ impl RepoDetailView { }); } - /// The persistent code editor for `path`, or a spinner while the file loads or parses. fn code_element(&self, path: &str, _cx: &mut Context) -> AnyElement { let Some(code) = &self.code else { return preview_spinner(); @@ -332,7 +308,6 @@ impl RepoDetailView { } impl RepoDetailView { - /// Preview the file at `path`, relative to the worktree root. fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { self.selected_file = Some(path.into()); @@ -455,7 +430,6 @@ impl RepoDetailView { task.detach(); } - /// Drop the cached preview, editor and commit state of `path`. pub(super) fn drop_preview_of(&mut self, path: &str) { if let Some(FileContent::Text(text)) = self.files.remove(path) { self.preview_bytes -= text.len(); diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs index 87912cf..f30e060 100644 --- a/crates/workspace/src/views/repo/header.rs +++ b/crates/workspace/src/views/repo/header.rs @@ -24,8 +24,6 @@ use crate::views::repo::helpers::{ShareTargets, ref_selector_trigger}; use crate::views::send_patch::open_send_patch_panel; impl RepoDetailView { - /// The NIP-34 header, actions and issues/PR counts. - /// Or the local header with an Init button for an unpublished repository. pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { if self.store.read(cx).addr().is_none() { return self.render_local_header(cx); @@ -411,8 +409,6 @@ impl RepoDetailView { .into_any_element() } - /// Header for a local, not yet published, repository. - /// The directory name and path with an Init button instead of the NIP-34 actions. fn render_local_header(&self, cx: &mut Context) -> AnyElement { let name = self.display_name(cx); let path = self @@ -476,8 +472,6 @@ impl RepoDetailView { .into_any_element() } - /// The tab row shared by both header variants. - /// Files and Commits tabs, the HEAD commit button and the branch/tag selectors. fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { let commits_count = self.all_commits.as_ref().map(|list| list.total); let worktree_empty = self.switching_ref || self.worktree.is_none(); @@ -658,7 +652,6 @@ impl RepoDetailView { } } -/// The `nostr://...` clone URL of an announcement, NIP-34. fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { let owner = announcement.owner; let user = nip05 @@ -676,10 +669,6 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt SharedString::from(url) } -/// The forked-from row of the detail header. -/// -/// Clickable link to the upstream repository when the `u` tag references a NIP-34 repo. -/// Plain text when it only carries a git URL. fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { let upstream = announcement.upstream.as_ref()?; diff --git a/crates/workspace/src/views/repo/helpers.rs b/crates/workspace/src/views/repo/helpers.rs index bf95cd3..e29e82a 100644 --- a/crates/workspace/src/views/repo/helpers.rs +++ b/crates/workspace/src/views/repo/helpers.rs @@ -23,7 +23,6 @@ use utils::{relative_time, relative_time_secs}; pub(crate) struct TreeItemSeed { /// Path of the node, relative to the worktree root. id: String, - /// File or directory name. label: String, children: Vec, } @@ -200,9 +199,6 @@ impl ShareTargets { } } - /// The share dropdown menu, one row per target. - /// - /// Each shows a compact label, the copy button and row click copy the full value. pub(crate) fn menu(&self, menu: PopupMenu) -> PopupMenu { menu.min_w(px(340.)) .item(menu_copy_row( @@ -232,7 +228,6 @@ impl ShareTargets { } } -/// Shorten an naddr link to `/naddr1...[last tail chars]`. fn truncate_naddr_link(url: &str, tail: usize) -> String { let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { return url.to_string(); @@ -243,14 +238,9 @@ fn truncate_naddr_link(url: &str, tail: usize) -> String { format!("{}...{}", &url[..end], &url[url.len() - tail..]) } -/// Width of one line-number gutter in a diff row. pub(crate) const GUTTER_WIDTH: f32 = 44.; -/// Height of one row in a virtual diff list. pub(crate) const DIFF_ROW_HEIGHT: f32 = 20.; -/// One row of a virtual diff list, a hunk header or a line of a hunk. -/// -/// Shared by the commit diff and pull request diff viewers. #[derive(Clone, Copy)] pub(crate) enum DiffRow { Hunk { @@ -259,11 +249,12 @@ pub(crate) enum DiffRow { new_start: u32, new_lines: u32, }, - /// Line `line` of hunk `hunk` of the selected file's diff. - Line { hunk: usize, line: usize }, + Line { + hunk: usize, + line: usize, + }, } -/// The rows of `file`'s diff, one header row per hunk then its lines. pub(crate) fn diff_rows(file: &FileDiff) -> Vec { let mut rows = Vec::new(); for (hunk_ix, hunk) in file.hunks.iter().enumerate() { @@ -281,7 +272,6 @@ pub(crate) fn diff_rows(file: &FileDiff) -> Vec { rows } -/// One row of the virtual diff list, a hunk header or a single line. pub(crate) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { match row { DiffRow::Hunk { @@ -308,9 +298,6 @@ pub(crate) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> Any } } -/// One diff line, old and new line numbers in the gutters. -/// -/// The content is tinted by kind, addition, deletion or context. pub(crate) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { let bg = match line.kind { DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), @@ -358,7 +345,6 @@ pub(crate) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { .into_any_element() } -/// Find a tree item by id, searching into nested children. pub(crate) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { let id = id?; items.iter().find_map(|item| { @@ -370,21 +356,16 @@ pub(crate) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<& }) } -/// The root issue events of a repo store, for the shared detail sections. pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] { &store.issues } -/// The root pull request events of a repo store, for the shared detail sections. pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] { &store.pull_requests } -/// The trigger body of the branch/tag selectors. -/// -/// The kind icon, the selection or placeholder, and the caret. -/// `Combobox` replaces its default trigger entirely, -/// the only way to show an icon inside it. +/// The kind icon, the selection or placeholder, and the caret. `Combobox` +/// replaces its default trigger entirely, the only way to show an icon inside it. pub(crate) fn ref_selector_trigger( ctx: &ComboboxTriggerContext>, icon: CustomIconName, @@ -418,7 +399,6 @@ pub(crate) fn ref_selector_trigger( .into_any_element() } -/// Section heading of a detail sidebar, shared by the issue and PR panels. pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement { div() .text_xs() @@ -428,7 +408,6 @@ pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement { .into_any_element() } -/// Right sidebar with participants and labels of a root event, issue or PR. pub(crate) fn sidebar_section( store: &Entity, id: EventId, @@ -511,7 +490,6 @@ pub(crate) fn sidebar_section( .into_any_element() } -/// The comments on a root event, issue or PR, one card per comment. pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { let store = store.read(cx); let comments: Vec<&Event> = store.comments_of(&root).collect(); @@ -559,8 +537,6 @@ pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &Ap .into_any_element() } -/// The comment form posting to an issue or PR root event. -/// /// `roots` selects the root's list within the store, issues or pull requests. pub(crate) fn comment_form( store: &Entity, @@ -621,11 +597,8 @@ pub(crate) fn comment_form( .into_any_element() } -/// Height of one commit row in a commit virtual list. pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.; -/// One commit row of a virtual list, shared by the commits tab and the -/// new-pull-request commit picker. pub(crate) fn commit_row( ix: usize, commit: &FileCommit, @@ -745,45 +718,4 @@ mod tests { assert_eq!(items[0].children.len(), 2); assert_eq!(items[1].label, "b"); } - - #[test] - fn tree_seeds_convert_to_tree_items() { - let entries = vec![ - PathBuf::from("src"), - PathBuf::from("src/main.rs"), - PathBuf::from("README.md"), - ]; - - let items: Vec = tree_items(build_tree_items(&entries), false); - assert_eq!(items.len(), 2); - assert_eq!(items[0].label, "src"); - assert_eq!(items[0].children.len(), 1); - assert_eq!(items[0].children[0].label, "main.rs"); - } - - #[test] - fn code_language_maps_extensions_and_names() { - assert_eq!(code_language("src/main.rs"), Some("rust")); - assert_eq!(code_language("Cargo.toml"), Some("toml")); - assert_eq!(code_language("app.js"), Some("javascript")); - assert_eq!(code_language("index.tsx"), Some("tsx")); - assert_eq!(code_language("Makefile"), Some("make")); - assert_eq!(code_language("CMakeLists.txt"), Some("cmake")); - assert_eq!(code_language("data.csv"), None); - assert_eq!(code_language("LICENSE"), None); - assert_eq!(code_language("README.md"), None); - } - - #[test] - fn naddr_link_keeps_url_and_tail() { - assert_eq!( - truncate_naddr_link("https://gitworkshop.dev/naddr1qqqxyzabc1234", 4), - "https://gitworkshop.dev/naddr1...1234" - ); - // Without the naddr1 prefix, unchanged. - assert_eq!( - truncate_naddr_link("https://example.com/x", 4), - "https://example.com/x" - ); - } } diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs index 9438c0f..f6fad59 100644 --- a/crates/workspace/src/views/repo/history.rs +++ b/crates/workspace/src/views/repo/history.rs @@ -105,8 +105,6 @@ impl RepoDetailView { } impl RepoDetailView { - /// Queue `path` for the per-file commit query. - /// Requests are batched into one history walk, see [`Self::load_commits`]. pub(super) fn load_commit(&mut self, path: &str, cx: &mut Context) { if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { return; @@ -117,10 +115,6 @@ impl RepoDetailView { } } - /// Walk history once for every queued path on a background task. - /// Cache the latest commit touching each path in [`Self::commits`]. - /// That feeds the file header in the content column. - /// Batching shares one walk across paths queued while the previous walk ran. fn load_commits(&mut self, cx: &mut Context) { if self.pending_commits.is_empty() || self.loading_commits { return; @@ -167,9 +161,6 @@ impl RepoDetailView { task.detach(); } - /// Walk all commits reachable from HEAD on a background task. - /// For the Commits tab and its total-count badge. - /// [`CommitList`] caps the list, only the newest commits are materialized. pub(super) fn load_all_commits(&mut self, cx: &mut Context) { if self.loading_all_commits || self.all_commits.is_some() { return; @@ -209,7 +200,6 @@ impl RepoDetailView { task.detach(); } - /// Open a new panel showing the diff of `commit_id`. pub(super) fn open_commit_diff( &mut self, commit_id: &str, diff --git a/crates/workspace/src/views/repo/init_dialog.rs b/crates/workspace/src/views/repo/init_dialog.rs index 02083bf..afcb4e3 100644 --- a/crates/workspace/src/views/repo/init_dialog.rs +++ b/crates/workspace/src/views/repo/init_dialog.rs @@ -19,10 +19,8 @@ use crate::views::sidebar::grasp_servers::{ GraspServersState, grasp_servers_field, load_user_grasp_servers, }; -/// Shared state for the Init dialog, so async results can be rendered. pub type InitRepoState = DialogProgress; -/// Open the Init dialog for the local repository at `local_path`. pub fn open( local_path: PathBuf, view: WeakEntity, @@ -51,7 +49,6 @@ pub fn open( .placeholder("Short description") }); - // Load the user's grasp servers. load_user_grasp_servers(grasp_state.clone(), window, cx); window.open_dialog(cx, move |dialog, _window, _cx| { @@ -146,9 +143,6 @@ pub fn open( }); } -/// Run the init flow. -/// -/// Closes the dialog and switches the repository into NIP-34 mode on success. fn init_repository( local_path: PathBuf, inputs: (Entity, Entity), diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs index d3a8266..e2c1111 100644 --- a/crates/workspace/src/views/repo/loading.rs +++ b/crates/workspace/src/views/repo/loading.rs @@ -15,7 +15,6 @@ use crate::views::repo::helpers::{ TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items, }; -/// Everything loaded from the local clone for the explorer. struct RepoData { tree: Vec, /// Relative paths of the worktree entries, for [`RepoDetailView::worktree_paths`]. @@ -32,8 +31,8 @@ struct RepoData { 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 announced repository's clone, if any, loads first without touching + /// the network. pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; @@ -110,7 +109,6 @@ impl RepoDetailView { let disk = disk.await; let had_clone = matches!(&disk, Ok(Some(_))); - // No local clone yet, so clone from the network then load. let data = match disk { Ok(Some(data)) => Ok(data), Ok(None) => { @@ -247,7 +245,6 @@ impl RepoDetailView { task.detach(); } - /// Apply the loaded repository data. fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { log::debug!("repo detail: apply_repo_data"); let RepoData { @@ -273,8 +270,6 @@ impl RepoDetailView { state.set_items(tree_items(tree, false), cx); }); - // Populate the branch/tag selectors with the local refs. - // Select the branch HEAD points to. let branches: Vec = branches.into_iter().map(Into::into).collect(); let tags: Vec = tags.into_iter().map(Into::into).collect(); @@ -338,7 +333,6 @@ impl RepoDetailView { true } - /// Clone the repository into a user-chosen folder outside the cache. pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { let store = self.store.clone(); @@ -404,9 +398,6 @@ impl RepoDetailView { } } -/// Read the worktree state of `repo`, no network. -/// -/// Entries, README, refs and HEAD commit. fn load_repo_data(repo: &Repository) -> Result { let entries = signed_git::worktree_entries(repo)?; let tree = build_tree_items(&entries); diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 8a59f45..dc5d6bb 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -33,121 +33,84 @@ pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel}; use self::files::{CodeView, FileContent, MarkdownView}; -/// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] enum RefKind { - /// A local branch `refs/heads/*`, HEAD stays attached. + /// HEAD stays attached. Branch, - /// A tag `refs/tags/*`, HEAD becomes detached. + /// HEAD becomes detached. Tag, } -/// Header actions dispatched by the dropdown menus of the header buttons. -/// `pub(crate)` because the pull-request list panel shares this action set. -/// It offers the New-PR and Send-patch actions in its own dropdown. +/// Header actions dispatched by the header dropdown menus. +/// +/// `pub(crate)` because the pull-request list panel shares this action set, +/// offering the New-PR and Send-patch actions in its own dropdown. #[derive(Clone, Action, PartialEq, Eq)] #[action(namespace = repo, no_json)] pub(crate) enum RepoAction { - /// Open the new issue dialog. NewIssue, - /// Open the new pull request dialog. NewPR, - /// Open the send patch panel. SendPatch, - /// Open the about dialog. About, - /// Re-push the repository to its grasp servers. Push, - /// Delete the repository from nostr, owner only. Delete, } -/// Detail view of a repository, header, stats and metadata. -/// -/// A file explorer with README preview, cloned from the announcement's `clone` URLs. pub struct RepoDetailView { focus_handle: FocusHandle, - /// Dock area the detail view lives in. - /// - /// New panels, commit diffs, are added there. dock_area: WeakEntity, - /// Per-repository store, holding the local path and the announcement, - /// issues, PRs and statuses. The single identity of both modes. store: Entity, - /// The initial explorer load has been started. - /// /// A repository opened by address alone starts without an announcement; the /// store observer starts the load once the first one lands. repo_started: bool, - /// File explorer state, the worktree of the local clone. tree_state: Entity, - /// Root of the local clone, for reading files on demand. worktree: Option, - /// Sorted relative paths of the tree currently shown. - /// /// A background refresh that did not change the tree skips rebuilding it, /// see [`Self::catch_up_worktree`], so a fetch that learned nothing new /// does not flash the explorer. worktree_paths: Vec, - /// Markdown document currently in the preview pane, README or a file. md: Option, - /// Code file currently in the preview pane. code: Option, readme_name: Option, - /// Currently previewed file, a relative path, and its contents. selected_file: Option, files: HashMap, - /// Paths of cached previews, oldest first. - /// Feeds the eviction caps in [`Self::evict_previews`]. + /// Paths of cached previews, oldest first. Feeds the eviction caps in + /// [`Self::evict_previews`]. file_order: VecDeque, - /// Total text bytes held by [`Self::files`]. preview_bytes: usize, - /// Reads in flight, to avoid duplicate loads. loading_files: HashSet, /// Latest commit touching a previewed file or the README, keyed by path. commits: HashMap, - /// Paths queued for the next batched commit query, see [`Self::load_commits`]. pending_commits: Vec, - /// A batched commit query is in flight. loading_commits: bool, - /// Active header tab, 0 = Files tree, 1 = Commits. + /// 0 = Files tree, 1 = Commits. active_tab: usize, - /// Commits reachable from HEAD, newest first. - /// `None` until the walk finishes or fails. - /// [`CommitList`] caps the list, `total` feeds the tab badge. + /// Commits reachable from HEAD, newest first. `None` until the walk + /// finishes or fails. `total` feeds the tab badge. all_commits: Option, - /// Commit walk in flight. loading_all_commits: bool, - /// Virtual list state of the Commits tab. scroll_handle: VirtualListScrollHandle, item_sizes: Rc>>, - /// A clone/fetch is in flight. loading: bool, error: Option, - /// Commit HEAD currently points to, shown in the header button. head_commit: Option, - /// Branch selector in the header, local branches, searchable. branch_select: Entity>>, - /// Tag selector in the header, tags, searchable. tag_select: Entity>>, /// Branch names currently in `branch_select`, for cheap no-op detection. ref_branches: Vec, /// Tag names currently in `tag_select`, for cheap no-op detection. ref_tags: Vec, - /// A branch/tag switch is in flight, checkout plus explorer reload. switching_ref: bool, - /// Bumped on every branch/tag switch. /// In-flight loads with an older generation are discarded when they complete. ref_generation: u64, - /// Subscriptions keeping the selectors' confirm events alive. _subscriptions: Vec, /// `(path, branch)` ready-suggestions dismissed by the user, per panel. banner_dismissed: HashSet<(PathBuf, String)>, - /// The announced HEAD the ready statuses were last requested with. - /// Whether they were requested at all. - /// Re-requested only when the HEAD, the base default, changes. - /// e.g. when the store's first refresh lands. + /// Whether the ready statuses were requested at all. ready_requested: bool, + /// The announced HEAD they were last requested with. Re-requested only when + /// the HEAD, the base default, changes, e.g. when the store's first refresh + /// lands. ready_head: Option, /// The global checkouts store's ready-to-contribute statuses of this /// repository, last seen when they drove a render. @@ -155,14 +118,10 @@ pub struct RepoDetailView { /// The store notifies on any recompute pass; the observer re-renders this /// panel only when these slices changed. ready_statuses: Vec, - /// The global checkouts store's ready-to-push statuses of this repository, - /// last seen when they drove a render. push_statuses: Vec, } impl RepoDetailView { - /// Open a repository by address. - /// /// `hint` is an announcement already in hand. It seeds the store's relays /// and the explorer's clone URLs; without it the panel waits for the store /// to load the announcement from the local database. @@ -177,7 +136,6 @@ impl RepoDetailView { Self::new_common(dock_area, store, window, cx) } - /// Open a local repository discovered by the scan. pub fn new_local( dock_area: WeakEntity, local_path: PathBuf, @@ -188,9 +146,6 @@ impl RepoDetailView { Self::new_common(dock_area, store, window, cx) } - /// Shared construction. - /// - /// File explorer state, ref selectors and the deferred repository load. fn new_common( dock_area: WeakEntity, store: Entity, @@ -306,8 +261,8 @@ impl RepoDetailView { self.store.read(cx).announcement.as_ref() } - /// Display name, the announcement's name or ID for announced repositories. - /// The directory name for local ones. + /// The announcement's name or ID for announced repositories, the directory + /// name for local ones. fn display_name(&self, cx: &App) -> SharedString { let store = self.store.read(cx); diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs index 8983484..b6fc1e1 100644 --- a/crates/workspace/src/views/repo/refs.rs +++ b/crates/workspace/src/views/repo/refs.rs @@ -10,8 +10,6 @@ use super::{RefKind, RepoDetailView}; use crate::views::repo::helpers::{build_tree_items, sorted_worktree_paths, tree_items}; impl RepoDetailView { - /// Check out `name`, a branch or tag picked in the header. - /// Refresh the explorer once the switch completes. pub(super) fn switch_ref( &mut self, kind: RefKind, @@ -81,7 +79,6 @@ impl RepoDetailView { task.detach(); } - /// Restore a selector to `previous`, or clear it after a failed switch. fn restore_selection( &self, select: &Entity>>, @@ -95,10 +92,6 @@ impl RepoDetailView { }); } - /// Refresh the file explorer, preview pane and commit list after a successful switch. - /// The selectors were already updated by [`Self::switch_ref`]. - /// [`Self::switching_ref`] stays set until this reload finishes. - /// A second switch cannot interleave. fn reload_worktree(&mut self, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs index 4653b22..b67bb06 100644 --- a/crates/workspace/src/views/repo/store.rs +++ b/crates/workspace/src/views/repo/store.rs @@ -26,8 +26,6 @@ impl RepoDetailView { cx.notify(); } - /// Observe the repository's store and re-render on its refreshes. - /// Start the explorer once the store has an announcement. pub(super) fn attach_store( &mut self, store: &Entity, @@ -82,7 +80,6 @@ impl RepoDetailView { }); } - /// The ready-to-contribute and ready-to-push statuses of this repository. pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { let Some(addr) = self.store.read(cx).addr().cloned() else { return false; diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index b3a6593..ed61ab1 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -24,18 +24,13 @@ use super::open_repo_panel; const COLUMNS: usize = 2; const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.; -/// How many of the newest repositories the `Recent` sort shows. const RECENT_COUNT: usize = 10; -/// Sort of the explore list, chosen via the header's filter buttons. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum RepoFilter { - /// Every repository in the store's default order, newest first. All, #[default] - /// Repositories ranked by total issues + pull requests + commits. Popular, - /// The [`RECENT_COUNT`] newest repositories. Recent, } @@ -100,23 +95,18 @@ impl RepoFilter { } } -/// Browse all announced repositories. pub struct RepoListView { store: Entity, dock_area: WeakEntity, focus_handle: FocusHandle, scroll_handle: VirtualListScrollHandle, - /// Sort selected in the header filter buttons. filter: RepoFilter, - /// Per-row heights of the virtual list. item_sizes: Rc>>, /// Number of rows [`Self::item_sizes`] was built for, the filtered repo count. repo_len: usize, /// Indices matching [`Self::filter`] into the store's `announcements`. visible: Vec, - /// Search box filtering repositories by name. search: Entity, - /// Rebuilds the visible slice as the search text changes. _search_subscription: Subscription, _subscription: Subscription, } @@ -129,7 +119,6 @@ impl RepoListView { ) -> Self { let store = RepoListStore::global(cx); - // Live search over repository names let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); let search_subscription = cx.subscribe(&search, |this, _search, event, cx| { if matches!(event, InputEvent::Change) { @@ -162,9 +151,6 @@ impl RepoListView { } } - /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store. - /// - /// Uses the store contents, [`Self::filter`] and the search query. fn rebuild_rows(&mut self, cx: &mut Context) { let filter = self.filter; let query = self.search.read(cx).value(); diff --git a/crates/workspace/src/views/send_patch.rs b/crates/workspace/src/views/send_patch.rs index 719d02c..ff5958e 100644 --- a/crates/workspace/src/views/send_patch.rs +++ b/crates/workspace/src/views/send_patch.rs @@ -13,19 +13,12 @@ use signed_state::RepoStore; pub struct SendPatchView { focus_handle: FocusHandle, - /// Dock area the panel lives in. dock_area: WeakEntity, - /// Store of the target repository. store: Entity, - /// Display name of the repository, for the panel title. repo_name: SharedString, - /// Title input, required. subject: Entity, - /// Description input, optional. description: Entity, - /// The pasted `git format-patch` output, required. patch: Entity, - /// A submit is in flight. submitting: bool, /// Error of the last submit attempt, it keeps the panel open. error: Option, @@ -61,7 +54,6 @@ impl SendPatchView { } } - /// Publish the pull request from the pasted patch. fn submit(&mut self, window: &mut Window, cx: &mut Context) { if self.submitting { return; diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index 9353446..45e9722 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -17,10 +17,9 @@ use super::super::open_repo_panel; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; use crate::views::dialog_state::{DialogProgress, error_row}; -/// Shared state for the Create Repository dialog, so async results can be rendered. +/// Progress of the create-repository flow, so async results can be rendered. pub type CreateRepoState = DialogProgress; -/// Open the Create Repository dialog. pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) { let settings = SettingsStore::global(cx); let default_folder = settings @@ -151,7 +150,6 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) }); } -/// Pick the repository's storage folder with the platform's native folder picker. fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mut App) { let handle = window.window_handle(); let folder_input = folder_input.clone(); @@ -186,8 +184,6 @@ fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mu .detach(); } -/// Run the create-repository flow. -/// /// Opens the new working copy and the repository panel on success. #[allow(clippy::too_many_arguments)] fn create_repository( @@ -250,7 +246,6 @@ fn create_repository( .detach(); } -/// Open the newly created repository in the dock's center. fn open_repo( dock_area: WeakEntity, announcement: Announcement, diff --git a/crates/workspace/src/views/sidebar/grasp_servers.rs b/crates/workspace/src/views/sidebar/grasp_servers.rs index cd5a2a2..84cd0cf 100644 --- a/crates/workspace/src/views/sidebar/grasp_servers.rs +++ b/crates/workspace/src/views/sidebar/grasp_servers.rs @@ -11,17 +11,16 @@ use signed_state::Backend; /// State of the grasp-server section of a publish dialog, so async results can be rendered. #[derive(Default)] pub struct GraspServersState { - /// The user's grasp list of kind `10317` is being loaded. + /// Set while the user's kind `10317` grasp list loads. pub loading_servers: bool, pub grasp_servers: Vec, - /// Whether the grasp server section is shown. Defaults to shown. pub servers_enabled: bool, - /// Error of the last grasp-server edit, an invalid relay URL for example. + /// Error from the last grasp-server edit, such as an invalid relay URL. pub error: Option, } impl GraspServersState { - /// Defaults used until the user's grasp list loads, which replaces them when non-empty. + /// Defaults used until the user's grasp list loads and replaces them. /// /// Persisted settings supply the defaults, an empty list falls back to the built-ins. pub fn new_default(settings: &GraspServersSettings) -> Self { @@ -137,7 +136,6 @@ pub fn grasp_servers_field( })) } -/// One grasp server row, the host in a tag plus a remove button. fn render_server_row( ix: usize, relay: &RelayUrl, @@ -176,7 +174,7 @@ fn render_server_row( ) } -/// The bare host of a grasp server, defaults are entered without a scheme. +/// Shows only the host, since grasp servers are entered without a scheme. fn display_server(relay: &RelayUrl) -> SharedString { relay .domain() @@ -184,7 +182,7 @@ fn display_server(relay: &RelayUrl) -> SharedString { .unwrap_or_else(|| SharedString::from(relay.to_string())) } -/// Parse the relay input, accepting a bare host, and append it to the list. +/// Accepts a bare host as well as a full URL. fn add_relay( state: &Entity, input: &Entity, @@ -220,9 +218,9 @@ fn add_relay( } } -/// Load the user's grasp list of kind `10317` from the local database. +/// Loads the user's kind `10317` grasp list from the local database. /// -/// It replaces the defaults when it lists any servers. +/// Replaces the defaults when the list is non-empty. pub fn load_user_grasp_servers( state: Entity, window: &mut Window, diff --git a/crates/workspace/src/views/sidebar/import_dialog.rs b/crates/workspace/src/views/sidebar/import_dialog.rs index 992673d..1e71ddc 100644 --- a/crates/workspace/src/views/sidebar/import_dialog.rs +++ b/crates/workspace/src/views/sidebar/import_dialog.rs @@ -1,7 +1,6 @@ use gpui::{App, Window, px}; use gpui_component::WindowExt; -/// Open the Import Identity dialog. pub fn open(window: &mut Window, cx: &mut App) { window.open_dialog(cx, move |dialog, _window, _cx| { dialog.title("Import identity").width(px(400.)) diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index c660e16..6dbb3e9 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -39,15 +39,13 @@ pub struct SidebarPanel { dock_area: WeakEntity, inbox: Option>, explore: Option>, - /// Artwork for the sign-in screen. banner: SharedString, /// The signed-in user's announced repositories, newest first. announcements: Arc>, /// Local repositories found by the scan that are not announced yet. local_repos: Arc>, - /// A local scan is currently running. scanning: bool, - /// Unpushed local commits per announced repository, the row badge counts. + /// Unpushed commit counts per announced repository, shown as row badges. unpushed: HashMap, _subscriptions: Vec, } @@ -80,21 +78,19 @@ impl SidebarPanel { } })); - // The merged list re-derives when announcements or the local scan change. subscriptions.push(cx.observe(&repos, |this, _repos, cx| { if this.refresh(cx) { cx.notify(); } })); - // The local scan re-derives when announcements or the local scan change. subscriptions.push(cx.observe(&local, |this, _local, cx| { if this.refresh(cx) { cx.notify(); } })); - // Push statuses are recomputed in the background; only the badge counts change. + // Push statuses are recomputed in the background, so only the badge counts change. subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { if this.refresh_unpushed(cx) { cx.notify(); @@ -125,8 +121,7 @@ impl SidebarPanel { .map(|user| repo_list.read(cx).announcements_of(user)) .unwrap_or_default(); - // A scanned repository is dropped from the local list - // once the user announces it, so it is not listed twice. + // Drop a scanned repository once the user announces it, so it is not listed twice. let local = LocalReposStore::global(cx); let scanning = local.read(cx).scanning; @@ -162,7 +157,6 @@ impl SidebarPanel { announcements_changed || local_changed || scanning_changed } - /// Recompute the badge counts from the global checkouts store's ready-to-push statuses fn refresh_unpushed(&mut self, cx: &mut Context) -> bool { let checkouts = CheckoutsStore::global(cx); let mut unpushed = HashMap::with_capacity(self.announcements.len()); @@ -183,7 +177,6 @@ impl SidebarPanel { true } - /// Keep the `ready to push` statuses of the announced repositories current. fn request_push_watches(&self, cx: &mut Context) { let checkouts = CheckoutsStore::global(cx); checkouts.update(cx, |checkouts, cx| { @@ -193,7 +186,6 @@ impl SidebarPanel { }); } - /// Open the inbox home panel in the dock area's center. pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context) { if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() { return; @@ -209,7 +201,6 @@ impl SidebarPanel { .ok(); } - /// Open the Explore repository list panel in the dock area's center. pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { if self .explore @@ -230,7 +221,6 @@ impl SidebarPanel { .ok(); } - /// Show the Onboarding dialog. fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context) { let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name")); let pass_input = cx.new(|cx| { @@ -248,12 +238,10 @@ impl SidebarPanel { onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx); } - /// Show the Create Repository dialog. fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context) { create_repo_dialog::open(self.dock_area.clone(), window, cx); } - /// Open a repository's detail view in the dock's center. fn open_repo( &mut self, announcement: &Announcement, @@ -269,8 +257,6 @@ impl SidebarPanel { ); } - /// Open a local repository's detail view in the dock's center. - /// /// The detail view offers to publish it to NIP-34. fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { let detail = @@ -334,7 +320,7 @@ impl SidebarPanel { ), ) .map(|this| { - // Merged list, the user's NIP-34 repositories and local repositories discovered. + // The merged list: NIP-34 repositories first, then discovered local repositories. let total = announcements.len() + local_repos.len(); if total == 0 { @@ -374,7 +360,7 @@ impl SidebarPanel { }) } - /// One row of the merged sidebar list, a NIP-34 or a local repository. + /// Renders row `ix` of the merged list: an announced repository or a local one. fn render_repo_at( &self, announcements: &[Announcement], @@ -403,7 +389,6 @@ impl SidebarPanel { let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let announcement = announcement.clone(); - // Badge with the unpushed commit count of the repository's local checkouts. let unpushed = self .unpushed .get(&announcement.addr()) @@ -433,9 +418,7 @@ impl SidebarPanel { ) } - /// One local repository row. - /// - /// The directory name and a warning suffix, the repo is not yet set up for NIP-34. + /// A local repository that is not yet set up for NIP-34, marked with a warning. fn render_local_row(&self, path: &Path, cx: &mut Context) -> impl IntoElement { let name = path .file_name() @@ -455,12 +438,11 @@ impl SidebarPanel { })) } - /// Show the Import Identity dialog. fn open_import(&mut self, window: &mut Window, cx: &mut Context) { import_dialog::open(window, cx); } - /// Render the user avatar and name in the sidebar, inside the titlebar drag area. + /// The user avatar and name, wired into the titlebar drag area. fn render_user( &self, profile: &Profile, @@ -490,7 +472,7 @@ impl SidebarPanel { ) } - /// Sign-in placeholder shown while logged out. + /// Shown while no identity is signed in. fn render_sign_in(&self, window: &mut Window, cx: &mut Context) -> Div { v_flex() .size_full() diff --git a/crates/workspace/src/views/sidebar/onboarding_dialog.rs b/crates/workspace/src/views/sidebar/onboarding_dialog.rs index 8f32e51..b5fe611 100644 --- a/crates/workspace/src/views/sidebar/onboarding_dialog.rs +++ b/crates/workspace/src/views/sidebar/onboarding_dialog.rs @@ -9,10 +9,9 @@ use signed_state::Backend; use crate::views::dialog_state::{DialogProgress, error_row}; -/// Shared state for the Onboarding dialog, so async results can be rendered. +/// Progress of the onboarding flow, so async results can be rendered. pub type OnboardingState = DialogProgress; -/// Open the Onboarding dialog for creating a new identity. pub fn open( name_input: Entity, pass_input: Entity, diff --git a/crates/workspace/src/views/sidebar/passphrase_dialog.rs b/crates/workspace/src/views/sidebar/passphrase_dialog.rs index a076352..d15453a 100644 --- a/crates/workspace/src/views/sidebar/passphrase_dialog.rs +++ b/crates/workspace/src/views/sidebar/passphrase_dialog.rs @@ -10,16 +10,14 @@ use signed_state::Backend; use crate::views::dialog_state::{DialogProgress, error_row}; -/// Shared state for the passphrase dialog, so async results can be rendered. +/// State of the passphrase dialog, so async results can be rendered. #[derive(Default)] pub struct PassphraseState { - /// Progress of the unlock flow. pub progress: DialogProgress, /// Keeps the Enter-to-submit subscription alive while the dialog is open. _enter_subscription: Option, } -/// Open the dialog asking for the passphrase that protects the stored identity. pub fn open(window: &mut Window, cx: &mut App) { let pass_input = cx.new(|cx| { InputState::new(window, cx) @@ -30,7 +28,7 @@ pub fn open(window: &mut Window, cx: &mut App) { let handle = window.window_handle(); let state = cx.new(|_| PassphraseState::default()); - // Enter in the passphrase field submits, same as the Unlock button. + // Enter in the passphrase field submits, like the Unlock button. let enter_pass_input = pass_input.clone(); let enter_state = state.clone(); let enter_subscription = cx.subscribe(&pass_input, move |_input, event, cx| { @@ -95,7 +93,6 @@ pub fn open(window: &mut Window, cx: &mut App) { }); } -/// Submit the passphrase to the backend. fn unlock( pass_input: &Entity, state: &Entity, diff --git a/crates/workspace/src/views/sidebar/settings_dialog.rs b/crates/workspace/src/views/sidebar/settings_dialog.rs index b8c13ad..a0822f5 100644 --- a/crates/workspace/src/views/sidebar/settings_dialog.rs +++ b/crates/workspace/src/views/sidebar/settings_dialog.rs @@ -22,7 +22,7 @@ use nostr::prelude::RelayUrl; use settings::{AppearanceMode, Settings, SettingsStore}; use signed_ui::{SelectOption, setting_block, setting_row}; -/// The index of `value` in `options`, for seeding a [`SelectState`]. +/// Looks up the option index used to seed a [`SelectState`]. fn selected_index(options: &[SelectOption], value: &str) -> Option { options .iter() @@ -30,7 +30,7 @@ fn selected_index(options: &[SelectOption], value: &str) -> Option { .map(|row| IndexPath::default().row(row)) } -/// The light and dark themes registered in the theme registry. +/// Registered themes split into light and dark options, light first. fn theme_options(cx: &App) -> (Vec, Vec) { let registry = ThemeRegistry::global(cx); let mut light = Vec::new(); @@ -48,7 +48,7 @@ fn theme_options(cx: &App) -> (Vec, Vec) { (light, dark) } -/// Stateful controls of the settings dialog, created once when it opens. +/// Created once when the dialog opens, so control state survives re-renders. struct SettingsControls { appearance: Entity>>, light_theme: Entity>>, @@ -58,9 +58,9 @@ struct SettingsControls { radius: Entity, radius_lg: Entity, grasp_server_input: Entity, - /// The effective default create-repository folder, shown in the disabled input. + /// The effective create-repository folder, shown in a disabled input. default_folder: Entity, - /// Keeps the control subscriptions alive for the dialog's lifetime. + /// Keeps the control subscriptions alive while the dialog is open. _subscriptions: Vec, } @@ -264,7 +264,6 @@ impl SettingsControls { } } -/// Open the Settings dialog. pub fn open(window: &mut Window, cx: &mut App) { let controls = Rc::new(SettingsControls::new(window, cx)); @@ -278,8 +277,6 @@ pub fn open(window: &mut Window, cx: &mut App) { }); } -/// The settings content, one section per related setting. -/// Sections are divided by horizontal separator lines. fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement { let store = SettingsStore::global(cx); let settings = store.read(cx).settings().clone(); @@ -297,7 +294,6 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement .child(repositories_section(&settings, controls, cx)) } -/// How the app picks its appearance. fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement { v_flex().w_full().gap_3().child(setting_row( cx, @@ -307,7 +303,6 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement )) } -/// Theme configuration, the registry theme names plus tweaks the app customizes at startup. fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement { v_flex() .gap_3() @@ -378,7 +373,7 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> )) } -/// Default grasp servers offered until the user publishes a kind `10317` grasp list. +/// Default grasp servers, used until the user's kind `10317` grasp list loads. fn grasp_servers_section( settings: &Settings, controls: &SettingsControls, @@ -394,7 +389,6 @@ fn grasp_servers_section( )) } -/// The editable list of default grasp servers plus an add-relay input. /// Styled like the grasp-server section of the publish dialogs. fn grasp_server_editor( servers: &[String], @@ -459,7 +453,7 @@ fn grasp_server_editor( ) } -/// The bare host of a grasp server, defaults are entered without a scheme. +/// Shows only the host, since grasp servers are entered without a scheme. /// Matches how the publish dialogs display servers. fn display_server(server: &str) -> SharedString { RelayUrl::parse(server) @@ -469,7 +463,6 @@ fn display_server(server: &str) -> SharedString { .unwrap_or_else(|| SharedString::from(server.to_owned())) } -/// Local repository scanning and the create-repository dialog default folder. fn repositories_section( settings: &Settings, controls: &SettingsControls, @@ -494,7 +487,6 @@ fn repositories_section( )) } -/// The editable list of scan directories plus an add-directory button. /// Styled like the grasp-server list. fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement { v_flex() @@ -547,7 +539,6 @@ fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement { ) } -/// The default-folder selector, a disabled input plus a picker button. /// Matches the create-repository dialog. fn folder_selector(controls: &SettingsControls) -> impl IntoElement { let default_folder = controls.default_folder.clone(); @@ -571,8 +562,7 @@ fn folder_selector(controls: &SettingsControls) -> impl IntoElement { ) } -/// Parse the server input and append it to the default grasp servers. -/// A bare host is accepted. +/// Accepts a bare host as well as a full URL. fn add_server(input: &Entity, window: &mut Window, cx: &mut App) { let value = input.read(cx).value().trim().to_owned(); if value.is_empty() { @@ -604,7 +594,6 @@ fn add_server(input: &Entity, window: &mut Window, cx: &mut App) { input.update(cx, |input, cx| input.set_value("", window, cx)); } -/// Prompt for directories to add to the local-repository scan. fn add_scan_path(cx: &mut App) { let prompt = cx.prompt_for_paths(PathPromptOptions { files: false, @@ -641,8 +630,7 @@ fn add_scan_path(cx: &mut App) { .detach(); } -/// Prompt for the Create Repository dialog's default folder. -/// Remember it in the settings and show it in the disabled input. +/// Persists the choice and reflects it in the disabled input. fn choose_default_folder(default_folder: &Entity, window: &mut Window, cx: &mut App) { let handle = window.window_handle(); let default_folder = default_folder.clone(); @@ -676,9 +664,7 @@ fn choose_default_folder(default_folder: &Entity, window: &mut Windo .detach(); } -/// Wire a number input to the settings. -/// Step actions clamp and persist the value. -/// Typed changes parse, clamp and persist. +/// Step actions clamp and persist the value; typed changes parse, clamp and persist. fn wire_number_input( state: &Entity, subscriptions: &mut Vec, @@ -751,7 +737,6 @@ fn wire_number_input( })); } -/// Apply the persisted appearance to the live theme. fn apply_appearance(appearance: AppearanceMode, cx: &mut App) { match appearance { AppearanceMode::System => Theme::sync_system_appearance(None, cx), @@ -760,7 +745,6 @@ fn apply_appearance(appearance: AppearanceMode, cx: &mut App) { } } -/// Re-apply the persisted theme configuration to the live theme. fn apply_theme(cx: &mut App) { let store = SettingsStore::global(cx); let settings = store.read(cx).settings().theme.clone(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 6a0e209..825b873 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -29,7 +29,6 @@ impl Workspace { let mut subscriptions = vec![]; - // Sync the system appearance if the appearance mode is set to system. if settings.read(cx).settings().appearance == AppearanceMode::System { subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| { Theme::sync_system_appearance(Some(window), cx); @@ -77,7 +76,6 @@ impl Workspace { passphrase_dialog::open(window, cx); } - // Open the sidebar and explore panel. this.dock.update(cx, |dock_area, cx| { dock_area.set_dock( DockPlacement::Left, @@ -88,7 +86,6 @@ impl Workspace { dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx); }); - // Open the explore panel. weak_sidebar .update(cx, |this, cx| { this.open_explore(window, cx); @@ -114,9 +111,7 @@ impl Render for Workspace { .size_full() .relative() .child(self.dock.clone()) - // Notifications .children(notification_layer) - // Modals .children(dialog_layer) } } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 74d6b96..0270973 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -17,14 +17,11 @@ fn main() { gpui_component::init(cx); theme::init(cx); - // Load the persisted settings before applying the theme. - // Stored appearance and theme settings then take effect at startup. + // The persisted settings must load before the theme is applied. let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx)); SettingsStore::set_global(store.clone(), cx); let settings = store.read(cx).settings().clone(); - // Register the built-in Signed theme, light and dark variants. - // The stored appearance then selects the active theme. let registry = ThemeRegistry::global_mut(cx); for (name, content) in Assets.themes() { if let Err(err) = registry.load_themes_from_str(&content) { @@ -72,7 +69,6 @@ fn main() { AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx), } - // Connects relays and restores the session. std::fs::create_dir_all(paths::nostr_dir()).ok(); std::fs::create_dir_all(paths::repos_dir()).ok(); signed_state::init( diff --git a/docs/backend-rearchitecture.md b/docs/backend-rearchitecture.md deleted file mode 100644 index 966fc2d..0000000 --- a/docs/backend-rearchitecture.md +++ /dev/null @@ -1,1384 +0,0 @@ -# Backend re-architecture: findings and outcome - -This is a follow-up to an initial architecture review. It re-checks every claim -against the **actual `nostr`/`nostr-sdk` source pinned by `Cargo.lock`** -(`rev 0c6fad2ac8ce934747096953f6dba355e3532614`, checked out locally at -`~/.cargo/git/checkouts/nostr-9dff06fa64f758da/0c6fad2/{nostr,nostr-sdk}/src`) -and the actual **GPUI source pinned by `Cargo.lock`** -(`git+https://github.com/zed-industries/zed#1870e269ad88802147f2baec3086abb67d17260a`, -checked out at `~/.cargo/git/checkouts/zed-a70e2ad075855582/1870e26/crates/{gpui,scheduler}/src`), -not from general knowledge of either. Every API claim below cites the file it -was verified against. - -Scope: `crates/signed_nostr`, `crates/signed_state`, `crates/signed_core`, -`crates/signed_git`, and `crates/workspace` (the actual call sites of the -backend, audited for business-logic flaws and redundant conversions). - -**Status: complete.** All 14 items of the plan are implemented and verified; -the compact record is the **Outcome** section at the end. Sections 1–17 are -kept as the analysis each change was based on — they describe the code as it -was *before* the change, so read them as rationale, not as current -documentation. - -## Summary of the ask - -1. Never call `fetch_events`. Bootstrap only via `subscribe`/`sync` (negentropy), read from `client.database()`. -2. Collapse the multiple "send an event" functions into direct `nostr-sdk` calls, no house wrappers. -3. Verify every API claim against the locally checked-out SDK/GPUI source. -4. Remove unnecessary logic (relay add/connect round trips, the fetch/sync dedup cache, unbounded task lists). -5. Re-evaluate `signed_git`'s dependence on `gix` — how much of it duplicates functionality `gix` (or another crate) already provides. -6. Check for unnecessary `cx.notify()` / over-broad re-renders vs. partial re-render. -7. Audit `crates/workspace` (the real UI call sites) for business-logic flaws of the same shape as `create_repository`, and for unnecessary string/type conversions and clones. - -Each is addressed below with concrete file:line references and a verified replacement. - ---- - -## 1. `fetch_events` — one call site, and it should go too - -``` -grep -rn "fetch_events" crates/ -crates/signed_state/src/backend.rs:1065 -``` - -The **only** use in the whole workspace is `Backend::bootstrap_user` -(`crates/signed_state/src/backend.rs:1059-1086`): - -```rust -fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { - let client = self.client.clone(); - self.push_task(cx.spawn(async move |this, cx| { - let result = async { - let events: Vec = client - .fetch_events(filters::grasp_list(public_key)) - .await? - .into_iter() - .collect(); - for url in latest_grasp_list_servers(events) { - client.add_relay(url.as_str()).await.ok(); - } - client.connect().await; - Ok::<_, Error>(()) - }.await; - ... - })); -} -``` - -Verified against `nostr-sdk/src/client/mod.rs:963-1018` (doc comment on -`Client::fetch_events`): it's explicitly the "buffer events, return a `Vec`" -sibling of `stream_events`, both explicitly documented as **short-lived** -subscriptions for one-off reads — the SDK's own guidance ("for long-lived -subscriptions use `Client::subscribe`") doesn't forbid `fetch_events` -outright, but the project rule you want is stricter: never bypass the -database. That's achievable here too, because `client.sync` degrades -gracefully to a plain fetch-and-store when the local DB has nothing yet. - -**Replacement** — sync against the bootstrap relays (same relays already -used for every other bootstrap query, see `BOOTSTRAP_RELAYS`, -`backend.rs:28-33`) and then read the result out of the database, exactly -like every other store in this codebase already does: - -```rust -// Also drops push_task/tasks in favor of .detach() — see §6. -fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context) { - let client = self.client.clone(); - cx.spawn(async move |this, cx| { - let result = async { - client - .sync(filters::grasp_list(public_key)) - .with(BOOTSTRAP_RELAYS) - .await?; - - let events = client.database().query(filters::grasp_list(public_key)).await?; - for url in latest_grasp_list_servers(events) { - client.add_relay(url).and_connect().await.ok(); // see §8 - } - Ok::<_, Error>(()) - }.await; - ... - }) - .detach(); -} -``` - -Verified against `nostr-sdk/src/client/api/sync.rs:1-32` and -`nostr-sdk/src/client/mod.rs:1020-1030` (`Client::sync` doc: "Performs a -negentropy-based reconciliation between the local database and one or more -relays" — this is exactly a bootstrap-and-store operation, no separate -"first fetch" step needed). No other code changes: `filters::grasp_list` and -`latest_grasp_list_servers` are unaffected. - -This also removes the last inconsistency in the codebase between "how we get -data:" everywhere else is sync-then-query; now it's sync-then-query -everywhere, no exceptions. - ---- - -## 2. The "send an event" functions — there are 8, there should be roughly 2 - -Grep for anything that ends up calling `client.send_event`: - -| Function | File:line | What it adds over `client.send_event` | -|---|---|---| -| `Backend::send` | `backend.rs:1308-1322` | signs with the current signer, then calls `broadcast_event` | -| `Backend::publish_event` | `backend.rs:1325-1332` | calls `broadcast_event` on an already-signed event | -| `Backend::publish_task` | `backend.rs:1335-1360` | wraps a future, emits `BackendEvent::Published`/`Error` | -| `Backend::send_fire_and_forget` | `backend.rs:1363-1375` | calls `send`, drops the result except logging | -| `Backend::retract_events` | `backend.rs:1378-1398` | hand-builds NIP-09 tags, calls `send` | -| `broadcast_event` (free fn) | `backend.rs:1404-1418` | calls `client.send_event`, turns "0 relays accepted" into an `Err` | -| `stage_event_on_relay` | `backend.rs:1768-1795` | calls `client.send_event(..).to([relay])`, same 0-accept-is-Err logic, different error type (`String`) | -| `RepoStore::send` | `repo.rs:1334-1344` | calls `Backend::send`, tracks `last_error` — **but several `RepoStore` methods bypass it** and call `Backend::send`/`Backend::publish_event` directly (`repo.rs:803`, `repo.rs:935`), so error surfacing is inconsistent across `RepoStore` methods | - -That's 8 layers for what the SDK already does in one call. Verified against -`nostr-sdk/src/client/api/send_event.rs:119-350`: - -- `client.send_event(&event)` **already** verifies the signature, saves the - event to the local database (`save_into_database`, default `true`), and - broadcasts — all before you touch anything (`send_event.rs:337-345`). -- Zero-relay-accepted is *not* an error from the SDK's point of view — it - returns `Ok` with `output.success` empty and `output.failed` populated. - Turning that into an app-level error is legitimate domain logic (the repo - already gets this right), it just doesn't need 3 separate functions - (`broadcast_event`, `stage_event_on_relay`, and the implicit success check - buried in `RepoStore::send`) doing the same "empty success ⇒ error" check. - -### Recommended shape: one helper, and direct SDK calls everywhere else - -Keep exactly **one** small helper because the "empty success ⇒ Err" rule is -real, repeated, app-specific policy (the SDK intentionally leaves that -decision to the caller): - -```rust -/// The event was accepted by at least one relay, or a descriptive error otherwise. -async fn require_relay_accepted(output: SendEventOutput) -> Result { - if output.success.is_empty() && !output.failed.is_empty() { - let reasons = output.failed.values().cloned().collect::>().join(", "); - bail!("event not accepted by any relay: {reasons}"); - } - Ok(event) -} -``` - -Then delete `Backend::send`, `Backend::publish_event`, -`Backend::send_fire_and_forget`, `broadcast_event`, and `RepoStore::send`. -Call `client.send_event(...)` **directly** at each call site, exactly like -`stage_event_on_relay` already does for the GRASP staging path — that -function is the one place in the codebase that already follows this -pattern (`.to([relay.clone()])`, explicit target, no extra wrapper beyond -the accept-check). Generalize *that* pattern instead of routing everything -through `Backend`. - -```rust -// A GPUI call site, e.g. RepoStore::open_issue, today: -self.send(builder, cx); - -// direct SDK call instead. No task list to push into and prune either — -// see §6, `.detach()` is the right default here. -let signer = Backend::global(cx).read(cx).signer(); -let client = Backend::global(cx).read(cx).client(); -cx.spawn(async move |this, cx| { - let event = builder.finalize_async(&signer).await?; - let output = client.send_event(&event).await?; - let event = require_relay_accepted(output, event).await?; - this.update(cx, |this, cx| { /* apply + cx.notify() */ }) -}) -.detach(); -``` - -`Backend` still owns the `Client`/`UniversalSigner` (a real, load-bearing -type — see §4 for why it must stay), but it should expose them -(`Backend::client()`/`Backend::signer()`, both already exist, -`backend.rs:1089-1096`) rather than mediate every publish through 4 layers -of wrapper. Emitting `BackendEvent::Published` for cross-store invalidation -(e.g. so `RepoListStore` refreshes when a new announcement lands) is the one -piece of `publish_task` worth keeping — but it can be a single `fn` taking -`&Event` that any call site invokes after its own `send_event`, not the -thing that *does* the sending. - -### `Backend::retract_events` — use the SDK's own NIP-09 builder, one deletion event per target - -`nostr` already ships `EventDeletionRequest` (verified in -`nostr/src/nips/nip09.rs:15-92`), which implements `IntoEventBuilder` exactly -like `GitRepositoryAnnouncement`/`GitIssue`/etc. already used elsewhere in -this codebase. Today's code hand-builds the tags for **one** deletion event -covering every target, plus a `k` tag per target: - -```rust -// today, backend.rs:1378-1398 -let mut tags: Vec = Vec::with_capacity(events.len() * 2); -for event in events { - tags.push(Tag::event(event.id)); - tags.push(Tag::parse(["k", &event.kind.to_string()]).expect("valid kind tag")); -} -let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx); -``` - -Per direction from the team: no `k` tag, and each event gets its own -deletion event rather than one deletion event listing multiple `e` tags. -`EventDeletionRequest` (`nip09.rs:15-92`) supports exactly that shape -already — call `.id(event.id)` once per event and send each independently: - -```rust -async fn retract_event(client: &Client, signer: &UniversalSigner, event: &Event) -> Result<(), Error> { - let builder = EventDeletionRequest::new().id(event.id).into_event_builder(); - let deletion = builder.finalize_async(signer).await?; - client.send_event(&deletion).await?; - Ok(()) -} - -fn retract_events(&mut self, events: &[Event], cx: &mut Context) { - let client = self.client.clone(); - let signer = self.signer.clone(); - - for event in events.to_vec() { - let client = client.clone(); - let signer = signer.clone(); - - cx.spawn(async move |_this, _cx| { - if let Err(e) = retract_event(&client, &signer, &event).await { - log::warn!("failed to retract event {}: {e}", event.id); - } - }) - .detach(); - } -} -``` - -No hand-rolled tag construction, no batching multiple targets into one -event, no `k` tag, and no task list to maintain (§6). Each deletion is -independent: a relay rejecting or dropping one doesn't affect the others. - ---- - -## 3. Remove the fetch/sync dedup cache — it duplicates state that already exists elsewhere - -`Backend` carries: - -```rust -recent_fetches: HashMap, // backend.rs:86 -const FETCH_DEDUP_WINDOW: Duration = ...; // backend.rs:39 -fn fetch_recently_started(&mut self, fingerprint: u64) -> bool { ... } // backend.rs:1178-1186 -fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 { ... } // backend.rs:1423-1433 -``` - -used at 3 call sites (`connect_repo_relays`, `sync_bootstrap`, and -indirectly wherever those are called), e.g.: - -```rust -pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context) { - let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter)); - if self.fetch_recently_started(fingerprint) { - log::debug!("skipping duplicate bootstrap sync"); - return; - } - ... -} -``` - -This is a generic "have I already asked for this filter recently" -cache, sorting + hashing relay lists and filters, pruning on a 5-minute -window, and un-inserting on error so a failed sync can retry immediately. -It exists purely to avoid redundant `sync`/`subscribe` calls — but every -call site that calls into `Backend::sync_bootstrap`/`connect_repo_relays` -**already has its own, more precise state for exactly this purpose**: - -- `RepoStore` tracks `repo_relays: HashSet` (`repo.rs:77`) — "have - I already connected+fetched this repo's relays" — and `root_fetches: - HashSet` (`repo.rs:81`) for per-root fetches. -- `RepoListStore` and `CheckoutsStore` each already run every refresh - through `RefreshGate` (`refresh.rs`), which itself exists to coalesce - bursts of refresh requests — that's the same "don't do this again right - now" idea, at the right granularity (per-store, per-purpose), not a - generic cross-cutting cache keyed by a hash of relays+filters. - -The `Backend`-level cache is solving the same problem a second time, at a -coarser and more error-prone granularity (a hash collision or an -order-sensitivity bug silently drops a legitimate sync; the 5-minute window -is a magic number with no connection to how often any of the 3 call sites -actually fire). Delete `recent_fetches`, `fetch_recently_started`, -`fetch_fingerprint`, `FETCH_DEDUP_WINDOW`, and `DefaultHasher`/`Hash`/`Hasher` -imports they pull in. Let each caller guard itself the way `RepoStore` -already does for `repo_relays`: - -```rust -// RepoStore, once per repo — this pattern already exists (repo.rs:189ish), -// just needs to also gate the *bootstrap* sync calls the same way instead -// of relying on a Backend-side cache. -if self.repo_relays.insert(relay.clone()) { - backend.update(cx, |backend, cx| backend.connect_repo_relays(vec![relay], filters, cx)); -} -``` - -`sync_bootstrap` for repo-independent filters (announcements, deletions) is -called from exactly one place today (`RepoListStore::subscribe_remote`, -`repo_list.rs:144-153`), on store construction — i.e., once per app -session. It does not need a dedup cache at all; if you're worried about a -second `RepoListStore` instance ever existing, that's a `Global`-uniqueness -invariant, not something to paper over with a fingerprint cache. - ---- - -## 4. Gossip is enabled, and stays enabled — but today's git-domain sends should bypass it explicitly - -Per team direction: gossip is a deliberate, load-bearing choice for this -client (it's not fully wired up to a feature yet, but it's not incidental -configuration either). `.gossip(...)` stays in `signed_nostr::backend::with_database` -(`crates/signed_nostr/src/backend.rs:31-51`). This section is scoped down -to what falls out of that: how the currently-implemented send paths -interact with gossip being on, verified against the SDK source. - -```rust -let client = ClientBuilder::default() - .database(database) - .authenticator(authenticator) - .gossip(NostrGossipMemory::unbounded()) - .gossip_config(GossipConfig::default().no_background_refresh()) - ... - .build(); -``` - -Verified against `nostr-sdk/src/client/api/send_event.rs:337-388` and the -doc comment on `Client::send_event` (`client/mod.rs:1097-1130`): **when no -explicit target is set** (no `.to()`/`.broadcast()`/`.to_nip17()`/`.to_nip65()`), -and gossip is configured, `send_event` resolves the destination via the -gossip engine (NIP-65 relay discovery for the event's author + tagged -pubkeys), not simply "every relay you `add_relay`'d". Every one of the 8 -send-paths in §2 calls `client.send_event(&event)` with **no explicit -target** — meaning every one of them is going through gossip-based relay -resolution today, on top of the relays this app added on purpose -(`BOOTSTRAP_RELAYS`, the repo's own `relays` tag, GRASP servers). - -That happens not to lose anything today, because `gossip_prepare_urls` -(`send_event.rs:229-320`) *also* unions in `client.pool().write_relay_urls()` -at the end — so events still reach every WRITE relay in the pool, gossip -only adds more relays on top. But it's not free: every plain `send_event` -call (opening an issue, commenting, reacting to a PR) does gossip -relay-list resolution — potentially a network round trip to fetch a NIP-65 -list — for events whose target set is already fully determined by the -repo's own `relays` tag or the bootstrap relay list, and where the extra -NIP-65 relays gossip adds are not places NIP-34 consumers are expected to -look. - -**Recommendation:** keep `.gossip(...)` configured (it's wanted for -whatever's next — NIP-17 DMs, NIP-65 profile/relay-list features, etc.), -but make the git-domain sends that already have a well-defined target -explicit about it, the same way `stage_event_on_relay` already is -(`.to([relay.clone()])`, `backend.rs:1768-1795`): - -- Repository-scoped events (announcements, state, issues, PRs, patches, - comments, statuses, deletions) know their target relays already (the - repo's `relays` tag, or `BOOTSTRAP_RELAYS` for repo-independent - discovery events) — send them with `.broadcast()` or `.to(relays)` so - they don't pay for gossip resolution and don't silently depend on the - sender's NIP-65 list being fresh. -- Anything that *should* use gossip once it exists (e.g. a future NIP-17 - DM, or explicit NIP-65 profile publishing) keeps the default routing, or - calls `.to_nip17()`/`.to_nip65()` explicitly. - -This is a small, additive change (one `.broadcast()`/`.to(...)` call per -send site as part of the §2 consolidation), not a removal — do it while -touching each call site for the send-path cleanup below, so gossip stays -fully available for the features that are meant to use it, while today's -repo/issue/PR/patch traffic stays deterministic about where it goes. - ---- - -## 5. `signed_git` vs `gix` — split verdict, not "throw it all out" - -`crates/signed_git/src/lib.rs` is 4118 lines. Checked the actual `gix` -version pinned (`gix = "0.87.1"`, feature set in the root `Cargo.toml`) and -its `gix-diff 0.67.1` dependency against what `signed_git` hand-rolls. - -### Already correct, idiomatic `gix` usage — keep as-is - -`tree_diff` (`signed_git/src/lib.rs:1468-1583`) generates commit-to-commit -diffs by calling `repo.diff_tree_to_tree(...)`, then -`gix::diff::blob::diff_with_slider_heuristics(...)`, then feeding the result -through `gix::diff::blob::UnifiedDiff::new(&diff, &input, collector, ..)` -where `collector` implements gix's own `ConsumeHunk` trait -(`signed_git/src/lib.rs:1963-2027`, matching `gix-diff-0.67.1/src/blob/unified_diff/mod.rs:70-84` -exactly). This *is* the documented, intended way to consume `gix`'s diff -engine — there is no simpler API to fall back to, and no unnecessary -reimplementation here. Same for the porcelain wrappers around `gix::Repository` -for refs, branches, tags, worktree checkout, etc. — that's inherent surface -area for a git-porcelain layer, not bloat. - -### Real duplication — the `git format-patch` text parser - -The other ~700 lines (`parse_diff_section`, `parse_hunk`, `hunk_header`, -`header_paths`, `diff_line_path`, `take_quoted`, `unquote_path`, -`strip_patch_prefix`, `name_from_address`, `signed_git/src/lib.rs:1660-2027`) -are a hand-rolled parser for **already-rendered** `git format-patch`/unified -diff text — this is necessary because a NIP-34 patch event's content *is* -the raw text output of `git format-patch`, arriving over Nostr with no -backing git objects to hand to `gix`'s diff engine. `gix-diff` only -*generates* unified diffs from git objects; it has no facility to *parse* -unified-diff text back into structured hunks, so this isn't a case of -"gix already does this and we reimplemented it." - -However, a maintained crate already exists for exactly this parsing job: -[`diffy`](https://docs.rs/diffy)'s `PatchSet` module -(`diffy::patch_set::PatchSet::parse(text, ParseOptions::gitdiff())`) -explicitly parses "the output of `git diff` or `git format-patch`", -supporting `diff --git` headers, extended headers (`new file mode`, -`deleted file mode`, etc.), rename/copy detection via `rename from`/`rename -to`/`copy from`/`copy to`, and binary-file detection — i.e., the exact -feature list `signed_git`'s hand-rolled parser reimplements -(`FileDiff::status` has `Renamed`/`Copied`/`Added`/`Deleted`/`Modified` -variants, `signed_git/src/lib.rs:1373-1379`; binary detection at -`signed_git/src/lib.rs:1395`). - -**Recommendation:** spike replacing `patch_diffs`/`parse_diff_section`/ -`parse_hunk`/`unquote_path`/etc. with `diffy::patch_set::PatchSet`, mapping -its `FileOperation`/`Hunk` types onto this codebase's existing `FileDiff`/ -`DiffHunk` (which downstream UI code already depends on, so keep those -public types and only replace the parsing internals). This is the single -biggest concrete size reduction available in the whole backend — a ~700 -line hand-rolled parser (plus ~1300 lines of tests for it, -`signed_git/src/lib.rs:3681-4053` and surrounding) collapses to a thin -adapter over a well-tested crate. Budget a spike first: `diffy`'s renamed -path handling and quoted-path unescaping need to be checked against this -project's test fixtures (`signed_git/src/lib.rs:3917-3962`, -octal-escaped/non-ASCII quoted paths) before committing to the swap. - -**Outcome of the spike: the swap is sound, and was committed.** Both specific -risks flagged above checked out: - -- **Renamed paths.** `diffy` produces `FileOperation::Rename { from, to }` from - the `rename from`/`rename to` extended headers, and those paths are *not* - `a/`/`b/`-prefixed, unlike `Create`/`Delete`/`Modify`, which come from the - `---`/`+++` lines *with* the prefix. The adapter therefore calls - `FileOperation::strip_prefix(1)` (git's `-p1`) only for the non-rename - variants — exactly the split `FileOperation`'s own doc comment and - `diffy`'s `examples/apply.rs` describe. This is the one place the two APIs - differ in shape, and the one place a naive port would have broken. -- **Quoted-path unescaping.** `diffy` decodes git's full C-style quoting — - named escapes *and* 3-digit octal — via `escaped_filename`, and rejects - non-UTF-8 in the `str` variant with `InvalidUtf8Path`. That matches the old - `gix::quote::ansi_c::undo` + `String::from_utf8` behavior exactly, error - case included. - -Two encoding details had to be matched rather than assumed: - -- `HunkRange::start()`/`len()` are the **literal hunk-header numbers** - (`@@ -1,3 +1,3 @@` → `start == 1`), not 0-based indices — `diffy`'s own - `diff/mod.rs` adds 1 when *building* a range from an index. So - `old_start`/`new_start`/`old_lines`/`new_lines` map across directly, and the - `@@ -0,0 +1 @@` empty-range case falls out for free. -- `Line`'s text **keeps** the trailing newline and has already had the - `+`/`-`/` ` prefix stripped. The adapter re-derives `DiffLine.old`/`new` by - counting from the hunk header (context advances both, deletion only old, - insertion only new, same as before) and strips the line ending the way - `str::lines` does. - -One deliberate behavior difference: `PatchSet` yields a single -`Err("no valid patches found")` for input containing no patch at all, where a -patch with no `diff --git` section used to yield an empty file list. -`patch_diffs` now short-circuits to an empty `CommitDiff` when no line starts -with `diff --git ` — the same guard `diffy`'s internal `find_gitdiff_start` -uses — so `empty_or_unparseable_patch_yields_no_files` still holds. - -Note: the earlier estimate above ("~700 line hand-rolled parser") was too -high; the parser itself was 370 lines, and the ~1300 lines of tests for it -remain, now serving as the fixture-by-fixture verification for the -crate-backed implementation. - ---- - -## 6. Remove the `tasks: Vec>` + `push_task` boilerplate — use `Task::detach()` - -Verified against the actual pinned GPUI revision -(`~/.cargo/git/checkouts/zed-a70e2ad075855582/1870e26/crates/scheduler/src/executor.rs:375-573` -and `crates/gpui/src/executor.rs:32-63`). - -Six different stores carry the exact same field and method, copy-pasted: - -```rust -tasks: Vec>>, - -fn push_task(&mut self, task: Task>) { - self.tasks.retain(|task| !task.is_ready()); - self.tasks.push(task); -} -``` - -at `backend.rs:87-91,164-169`, `checkouts.rs:106-110,180-185`, -`local_repos.rs:13-23`, `profile.rs:72-80,136-141`, `repo.rs:82-86` (plus an -inlined copy of the same retain-then-push at `repo.rs:236-240` and -`repo.rs:373-377`), and `repo_list.rs:56-60,137-142`. - -`Task`'s own doc comment (`scheduler/src/executor.rs:375-380`) says exactly -what this boilerplate exists to avoid: "If you drop a task it will be -cancelled immediately. Calling `Task::detach` allows the task to continue -running, but with no way to return a value." `Task::detach(self)` -(`executor.rs:552-559`) does precisely that, and `TaskExt::detach_and_log_err` -(`gpui/src/executor.rs:35-61`, already referenced in this project's own -`.rules` file) additionally logs an `Err` without any manual `match`. None -of these stores' spawned tasks need cancel-on-drop semantics: every -continuation already does `this.update(cx, ...).ok()` or propagates through -`?`, so if the owning entity is gone by the time the task finishes, the -update is a harmless no-op — exactly the "tolerate the entity being gone" -pattern already used everywhere in this codebase (see the `.ok()` calls -throughout `backend.rs`). Storing the task and pruning it on every push -buys nothing here; `.detach()` (or `.detach_and_log_err(cx)` where the -continuation only logs on failure) replaces both the field and the method: - -```rust -// today -self.push_task(cx.spawn(async move |this, cx| { - if let Err(e) = task.await { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string()))).ok(); - } - Ok(()) -})); - -// replacement — no field, no prune, no manual match -cx.spawn(async move |this, cx| { - if let Err(e) = task.await { - this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string()))).ok(); - } -}) -.detach(); -``` - -Delete the `tasks` field and `push_task` method from all six stores, and -change every `self.push_task(cx.spawn(...))` call to `cx.spawn(...).detach()` -(or `.detach_and_log_err(cx)` when the closure's only job is to log the -error). The one place that must **not** just detach is `push_repo_from`'s -returned `Task>` (`backend.rs:815-902`) — that -task is deliberately returned to the UI caller (so the panel can `.await` -it and show a spinner) and already isn't stored in a `tasks` list today, so -it's unaffected by this cleanup. - -`crates/workspace` has the same pattern too, and there it's a real bug, not -just style — see §14. - ---- - -## 7. Render granularity / `cx.notify()` audit - -Checked every `cx.notify()` call in `signed_state` (18 call sites) and how -`workspace` views consume each store. Overall this is **already -well-partitioned**, not a smell: - -- Every panel (`IssuesView`, `PullRequestsView`, `IssueDetailView`, - `CommitDiffView`, `RepoDetailView`, `PullRequestDetailView`, - `NewPullRequestView`, `DiffPane`) is its own `Entity`/`Render` impl — - `cx.notify()` on a store only invalidates the views actually observing - that store's `Entity`, not a monolithic root view. -- `IssuesView`/`PullRequestsView` already memoize derived rows behind a - `(store.version(), filter)` cache key (`issues.rs:68-72`, `323-333`; - `pull_requests.rs:75-79`, `331-341`), and both use - `VirtualListScrollHandle` for virtualization — so a store `notify()` - doesn't force rebuilding or laying out off-screen rows. -- `sync_bootstrap`'s per-percent progress `cx.notify()` - (`backend.rs:1257-1264`) is already throttled to *distinct percentage - points* (`if progress.current > 0 && percent != last_percent`, - `backend.rs:1254`), and nothing in `workspace` reads `Backend::sync_progress()` - directly (`grep -rn "sync_progress()" crates/workspace` → no matches), so - this never drives a visible re-render on its own. - -### One real waste found: `RepoListStore` re-queries the DB on every sync tick - -`RepoListStore`'s backend subscription (`repo_list.rs:76-109`) treats -`BackendEvent::SyncProgress { .. }` as relevant on its own: - -```rust -BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, -``` - -Every distinct percentage tick of the bootstrap announcements/deletions -sync calls `this.refresh(cx)`, which is debounced 300ms -(`REFRESH_DEBOUNCE`, `repo_list.rs:16`) and coalesced by `RefreshGate` — so -it's not literally one DB round-trip per percent, but it is several -(bounded by sync duration / 300ms) full re-scans of announcements + -deletions + state events + activity + counts (`run_refresh`, -`repo_list.rs:184-294`) while a single sync is still in flight, instead of -one at the end. This is a deliberate trade-off for progressive reveal (the -repo list fills in live instead of jumping once at 100%), so it's not a -bug, but if that progressive reveal isn't a feature you actually want, -dropping `SyncProgress` from the "relevant" match (keep only `Synced`) removes -several redundant background-thread DB scans per sync for free. Worth a -product decision, not just a code fix. - -No other store subscribes to `SyncProgress` (`RepoStore`, `CheckoutsStore` -do not — checked their subscription callbacks), so this is fully isolated -to `RepoListStore`. - ---- - -## 8. Relay add/connect: stop round-tripping through strings, stop reconnecting the whole pool - -Flagged example (`backend.rs:1071-1074`): - -```rust -for url in latest_grasp_list_servers(events) { - client.add_relay(url.as_str()).await.ok(); -} -client.connect().await; -``` - -Two separate problems, both verified against `nostr-sdk/src/client/url.rs:40-50` -and `nostr-sdk/src/client/api/connect.rs:1-49`: - -1. **`.as_str()` is a pointless round trip.** `latest_grasp_list_servers` - already returns `RelayUrl` values (parsed, validated). `RelayUrlArg` - (what `add_relay` actually accepts) has a direct `impl From` - and `impl From<&RelayUrl>` (`client/url.rs:40-50`) — passing the - `RelayUrl` itself skips a second `RelayUrl::parse` that `.as_str()` - forces (`client/url.rs:26,35`, the `String` variant of `RelayUrlArg` - re-parses on `try_into_relay_url`). Just pass `url`, not `url.as_str()`. -2. **`client.connect()` connects every relay in the pool, not just the one - you added.** Verified in `connect.rs:36-48`: `Client::connect()`'s - `IntoFuture` unconditionally calls `self.client.pool().connect()`, with - no target selection at all — it iterates every relay currently in the - pool. Calling it after adding 1-2 new relays re-issues a connect - attempt to *every* relay already connected too. The `AddRelay` builder - already has the right primitive: `.and_connect()` (`client/api/add.rs:127-132`), - which is threaded straight into `pool.add_relay(url, capabilities, connect, opts)`. - Verified in `pool/mod.rs:157-197` that this is correct **even when the - relay already exists** in the pool: the pool's `add_relay` checks for an - existing entry and, if `connect` is `true`, calls `relay.connect()` on - the existing relay too (`pool/mod.rs:191-194`) — so `.and_connect()` is - never wrong to use, whether the relay is new or already known. - -```rust -// replacement -for url in latest_grasp_list_servers(events) { - client.add_relay(url).and_connect().await.ok(); -} -``` - -The same two problems repeat at every other relay-add call site — fix all -of them the same way: - -- `Backend::bootstrap` (`backend.rs:178-187`): the `BOOTSTRAP_RELAYS` loop - and the `INDEXER_RELAYS` loop (which also sets `.capabilities(...)`, - chain `.and_connect()` onto the same builder) both currently defer to one - trailing `client.connect().await`. -- `connect_repo_relays` (`backend.rs:1446-1449`): today calls `client.add_relay(url).await?;` - then `client.connect_relay(url).await?;` as two separate round trips — - collapse to one `client.add_relay(url).and_connect().await?;`. -- `stage_event_on_relay` (`backend.rs:1772-1782`): same fix, and this one - currently calls the pool-wide `client.connect().await` just to connect - the single relay it's about to stage an event on. - -### Delete the `add_relays` wrapper (`Backend::add_relays`, `backend.rs:1149-1173`) - -Its only two callers (`create_repository`, `backend.rs:505-508`; -`publish_local_repo`, `backend.rs:652-655`) do this today: - -```rust -this.update(cx, |this, cx| { - let urls: Vec = servers.iter().map(ToString::to_string).collect(); - this.add_relays(urls, cx); -})?; -``` - -`servers` is already `Vec` at both call sites — stringifying it -only to have `add_relays` parse it straight back into `RelayUrl` inside -`client.add_relay(&url)` is pure waste, on top of the wrapper itself being -another `cx.spawn` + `push_task` + error-emit layer (§6) around what is, -with the fix above, a two-line loop. Both call sites are already inside a -`cx.spawn(async move |this, cx| ...)` with `client` reachable — inline it: - -```rust -let client = this.update(cx, |this, _cx| this.client.clone())?; -for relay in &servers { - client.add_relay(relay).and_connect().await.ok(); -} -``` - -Delete `Backend::add_relays` entirely once both call sites are inlined. - ---- - -## 9. `create_repository`'s flow is backwards: it inits a mirror, then clones it into the real destination - -This is a real business-logic flaw, not just a style issue. Today -(`backend.rs:437-501`): - -1. `signed_git::init_repository(&path, &name, &description)` — `path` is - `GitCache::repo_path(&addr)`, the app's **internal mirror cache** - location (`crates/signed_git/src/lib.rs:29-33`), not anywhere the user - asked for. This creates a full worktree with an initial commit *there*. -2. `signed_git::clone_repo(&[mirror_url], &destination)` — `destination` is - `folder.join(dir_name)`, the folder the user actually picked. This - clones the mirror just created in step 1 into the real target, via a - `file://` URL (`Url::from_file_path(&path)`, `backend.rs:481-483`). -3. The push (`push_staged_to_grasps`, called with `path` = the **mirror**, - not `destination`) pushes the mirror's objects to the grasp servers. -4. `origin` gets set on *both* the mirror (`backend.rs:463-466`) and the - destination (`backend.rs:492-495`). - -So a brand-new repository gets initialized twice and checked out twice for -what is, at that point, one README and one commit — and the thing that -actually gets pushed (the mirror) isn't the thing the user is left looking -at (the destination). - -Checked `signed_git::init_repository` itself (`signed_git/src/lib.rs:401-477`): -it already creates the target directory (`std::fs::create_dir_all(path)`), -runs `gix::init(path)`, and leaves a fully checked-out worktree with the -README written to disk and the index populated — i.e., it already produces -exactly what step 2's clone is redundantly reproducing. There is no reason -step 1 and step 2 are two different paths. - -Compare with `publish_local_repo` (`backend.rs:599-754`), the sibling flow -for an *existing* local repo: it operates on the user's real folder -directly (`signed_git::worktree_ref_state(&path)`, `root_commit(&path)`) — -no mirror, no extra clone. `create_repository` is the odd one out. - -**The mirror doesn't need to be pre-populated at creation time at all.** -`GitCache::ensure_clone(addr, clone_urls)` (`signed_git/src/lib.rs:45-63`) -already exists precisely to populate the mirror lazily — open it if it's -there, clone it from the announcement's `clone_urls` if it's not — and -it's already what `RepoDetailView::load_repo` calls for every repo, -including the user's own (`workspace/src/views/repo_detail/mod.rs:425-428`). -By the time the UI navigates to the new repo's detail view after -`create_repository` returns, the push has already succeeded, so -`ensure_clone` will clone straight from the just-pushed grasp server — -exactly the same lazy path every other repo already takes. No special -casing needed. - -I checked whether any `workspace` call site compounds this (e.g. by cloning -*again* right after `create_repository` returns) — it doesn't: -`sidebar/create_repo_dialog.rs`'s `create_repository` handler -(`create_repo_dialog.rs:193-222`) just calls `backend.create_repository(...)` -and applies the returned `Announcement`; the flaw is fully contained inside -`Backend::create_repository` itself. - -**Replacement:** initialize directly at `destination`, push from -`destination`, set `origin` once: - -```rust -let commit = signed_git::init_repository(&destination, &name, &description)?; -// ... build the announcement using `commit` as before ... -// push_staged_to_grasps(..., path = &destination, ...) instead of the mirror path -if let Some(base) = servers.first().and_then(grasp_base_url) { - signed_git::set_origin(&destination, &format!("{base}/{owner}/{repo_id}.git"))?; -} -``` - -Delete the mirror `init_repository` call, the `clone_repo` call, the -`Url::from_file_path` mirror-URL construction, and the mirror-side -`ensure_origin` call. This removes a full extra `gix::init` + checkout + -clone from repo creation, and makes `create_repository` consistent with -how `publish_local_repo` already treats the user's working copy as the one -source of truth. - ---- - -## 10. Bootstrap-on-construction should go through `cx.defer`, not run synchronously in `new` - -Verified against the pinned GPUI revision -(`crates/gpui/src/app.rs:1999-2005`, `crates/gpui/src/app/context.rs:296-315`). - -`App::defer(&mut self, f: impl FnOnce(&mut App) + 'static)` — "Schedules -the given function to be run at the end of the current effect cycle, -**allowing entities that are currently on the stack to be returned to the -app**." That's precisely the situation every one of these constructors is -in: `Self` is still being built inside the `cx.new(|cx| ...)` closure when -it reaches out and kicks off real work. `Context::defer_in` also exists -(`app/context.rs:296-315`) but takes a `&Window` — it's for window-bound -views, not the headless global stores below, none of which are constructed -with a `Window` in scope. For these, the applicable API is the window-less -`cx.defer(...)`, reached through `Context`'s `Deref` -(`app/context.rs:25-34`), capturing a `WeakEntity` to get back into -`Self` once deferred: - -```rust -// today, backend.rs:148-160 -let mut this = Self { /* ... */ }; -this.bootstrap(cx); -this - -// replacement -let mut this = Self { /* ... */ }; -let weak = cx.entity().downgrade(); -cx.defer(move |cx| { - weak.update(cx, |this, cx| this.bootstrap(cx)).ok(); -}); -this -``` - -The same pattern — a constructor that calls its own bootstrap-ish method, -or reaches into another entity, before returning `Self` — repeats in every -store: - -| Store | Constructor call site | What it kicks off synchronously | -|---|---|---| -| `Backend` | `backend.rs:159` | `bootstrap(cx)` — adds/connects `BOOTSTRAP_RELAYS`/`INDEXER_RELAYS`, restores the session | -| `RepoListStore` | `repo_list.rs:120-123` | `subscribe_remote(cx)` (negentropy sync against bootstrap relays) + `refresh_initial(cx)` | -| `RepoStore` | `repo.rs:154-159` | `subscribe_remote`, `connect_announced_relays`, `refresh` — each one reaches into the global `Backend` entity | -| `CheckoutsStore` | `checkouts.rs:172-174` | `refresh(cx)` | -| `LocalReposStore` | `local_repos.rs:44` | `rescan(cx)` | -| `ProfileStore` | `profile.rs:119-121` | spawns the batched profile-fetch loop | - -Wrap each of these the same way `Backend::new` is shown above. This isn't -about a currently-observed crash (nothing panics today, because everything -past the initial synchronous field assignment already goes through -`cx.spawn`/`cx.background_spawn`, which only runs later anyway) — it's -about not mixing "construct plain state" with "kick off side effects that -talk to other entities" in the same synchronous call, which is exactly what -`defer` exists to separate, per its own doc comment. - ---- - -## 11. Split independently-observed state into child entities - -`Backend::pushing_repos` (`backend.rs:88`) is `Arc>>` -— it bypasses GPUI's entity system entirely. A view that wants to show "is -repository X currently pushing" has no way to `cx.observe` this; it can -only poll a `Mutex` by hand, and any UI update requires some *other* -notify to happen to piggyback on. Meanwhile every view that only cares -about, say, `current_user` still gets re-invoked on `Backend::notify()` -fired for unrelated reasons (a `sync_progress` tick, a new relay connecting), -because the whole `Backend` is one entity and `cx.notify()` invalidates all -of its observers indiscriminately. - -GPUI's own model is built for exactly this split: an `Entity` works for -any `T: 'static`, not just `Render`-able view state (see the project's own -GPUI notes: "Whenever you need to store application state that -communicates between different parts of your application, you'll want to -use GPUI's entities"). Where a piece of a bigger store's state changes on -its own schedule and has its own, narrower set of observers, pull it out -into a child entity: - -```rust -pub struct Backend { - client: Client, - signer: UniversalSigner, - current_user: Option, - sync_progress: Option<(u64, u64)>, - passphrase_required: bool, - pushing_repos: Entity>, // was Arc>> -} -``` - -A view that only cares whether repo `X` is pushing does -`cx.observe(&backend.read(cx).pushing_repos, |this, pushing, cx| ...)` and -is left alone by every other `Backend` change. `PushGuard` -(`backend.rs:99-110`) becomes a guard that calls -`pushing_repos.update(cx, |set, cx| { set.remove(&addr); cx.notify(); })` -on drop instead of locking a raw `Mutex` — same RAII shape, but now it's a -real, observable GPUI entity instead of a side channel next to the entity -system. Apply the same split to any other `Backend`/store field where the -set of interested observers is a strict subset of the store's full -observer list. - -This principle is also the reason **not** to merge `LocalReposStore` and -`RepoListStore` into one entity — see §13. - ---- - -## 12. One debounce at the source, not one per store - -Flagged example — the notification pump (`backend.rs:126-146`): - -```rust -let mut notifications = pump_client.notifications(); -while let Some(notification) = notifications.next().await { - let ClientNotification::Event { event, .. } = notification else { continue }; - let update = Update::from_event(&event); - if this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(update))).is_err() { - break; - } -} -``` - -Every single relay-delivered event is emitted as its own -`BackendEvent::NostrUpdate`, immediately. During a negentropy sync -(exactly the bursty case §6/§7 already discuss), this can be hundreds of -emits in a short window. Four different stores (`RepoStore`, -`RepoListStore`, `CheckoutsStore`, and transitively `ProfileStore`) each -subscribe to `Backend` and independently run their own `RefreshGate` -debounce/coalesce dance in response — the same burst gets debounced four -times, once per listener, instead of once at the point it actually enters -the system. - -Centralize it: batch what the pump itself emits, and let each store react -to a batch instead of a stream of singles. The pump already owns the one -place where the burst originates, so it's the natural place to coalesce: - -```rust -let pump = cx.spawn(async move |this, cx| { - let mut notifications = pump_client.notifications(); - let mut pending: Vec = Vec::new(); - - loop { - let next = cx.background_executor().timer(PUMP_DEBOUNCE).fuse(); - futures::select_biased! { - notification = notifications.next() => { - let Some(notification) = notification else { break }; - let ClientNotification::Event { event, .. } = notification else { continue }; - pending.push(Update::from_event(&event)); - } - _ = next => { - if pending.is_empty() { continue; } - let batch = std::mem::take(&mut pending); - if this.update(cx, |_, cx| cx.emit(BackendEvent::NostrUpdate(batch))).is_err() { - break; - } - } - } - } - Ok(()) -}); -``` - -(Sketch — the real version needs `BackendEvent::NostrUpdate` to carry -`Vec` instead of `Update`, and every subscriber's relevance check — -`RepoStore`, `RepoListStore`, `CheckoutsStore`, `ProfileStore` — to check -"does *any* update in the batch match" instead of one `Update`. That's a -mechanical change to four match arms.) - -This doesn't make each store's own `RefreshGate` fully redundant: -`Published`/`Synced`/`SyncProgress` events are emitted directly by -whichever method triggered them (a local `send`, a sync completing), not -through the pump, and can still arrive close together independently of -relay traffic. But those are one-off, user-triggered events, not the -hundred-events-in-a-burst case — so once the pump absorbs the dominant -source of bursts, each store's debounce window can likely shrink -significantly (or, for stores that only ever see one trigger at a time in -practice, be dropped in favor of "fold into the in-flight run" without a -timer at all). Worth measuring after the pump-side batching lands, rather -than speculatively resizing four timers up front. - ---- - -## 13. `local_repos.rs` + `repo_list.rs`: merge the files, not the entities - -These two are structurally near-identical: both hold an `Arc>` -snapshot, refresh it in the background on a trigger, swap it in with -`cx.notify()`, and carry their own `Global` wrapper + `global()`/`set_global()` -pair + `tasks`/`push_task` boilerplate (§6). That similarity is real and -worth collapsing — but checked who actually reads each one before deciding -*how*: - -``` -grep -rn "RepoListStore::global" crates/ → 9 call sites -grep -rn "LocalReposStore::global" crates/ → 5 call sites -``` - -Only **two** places read both together: `CheckoutsStore::new`/`run_refresh` -(`checkouts.rs:126-136`, `checkouts.rs:339-344`) and `SidebarPanel::new`/`refresh` -(`sidebar/mod.rs:55-65`, `sidebar/mod.rs:128-142`). Everywhere else reads -exactly one: - -- `RepoListStore` alone: `RepoStore::action_announcement` (`repo.rs:1071-1078`), - `RepoDetailView::open_upstream` (×2, `mod.rs:1009-1013`, `1034-1044`), - `RepoDetailView::fork_row` (`mod.rs:2596-2606`), - `NewPullRequestView::fork_candidates` (`new_pull_request.rs:569-577`), - `RepoListView::new` (`views/repo_list.rs:111-121`). -- `LocalReposStore` alone: `RepoDetailView::apply_announcement` - (`mod.rs:1875-1877`), `SidebarPanel::render_repos`'s rescan button - (`sidebar/mod.rs:306-309`). - -Given that, collapsing them into **one `Entity`** (one struct holding both -`Vec`s, one `cx.notify()` for both) would make every one of those ~12 -single-store readers pay for the other store's unrelated refreshes — -exactly what §11 says not to do. Wrapping them in a parent that holds two -child entities (`RepoDirectory { local: Entity, remote: -Entity }`) avoids that specific problem, but then every one of -those same ~12 call sites has to change from `RepoListStore::global(cx)` to -`RepoDirectory::global(cx).read(cx).remote` — an extra hop added everywhere, -in exchange for saving exactly one `Global` wrapper struct. Not a good -trade for a codebase this size. - -**Recommendation:** merge the two **files** into one module -(e.g. `repos.rs`), keeping `LocalReposStore` and `RepoListStore` as two -fully independent structs, each still its own `Entity`/`Global` exactly as -today — same public API, same `global()`/`set_global()` pairs, zero -call-site churn. The merge is justified purely as "these are the app's two -repo-listing stores, they belong next to each other," per the project's own -`.rules` guidance to avoid many small files for closely related logic — -not as a reason to share a notify cycle between two things with almost -entirely disjoint observers. - ---- - -## 14. `crates/workspace` has the same task-list pattern as §6 — and there it's an actual bug - -§6 covers `signed_state`'s 6 stores, where the unpruned-`Vec` pattern -is a style/complexity concern with no observed failure, because -`push_task` always pruned before pushing. `crates/workspace` has the exact -same field-and-push shape in 4 views, but **most of it never prunes**: - -``` -grep -rn "tasks.push(task)" crates/workspace/ → 17 call sites -grep -rn "tasks.retain" crates/workspace/ → 1 call site (pull_request_detail.rs:258) -``` - -- `RepoDetailView.tasks` (`mod.rs:178-179`, doc comment: "finished tasks are - pruned on every push" — **this is stale/incorrect**, no `.retain()` - precedes any of its 11 push sites: `mod.rs:384-388`, `532-536`, `650-654`, - `773-777`, `835-839`, `877-881`, `949-953`, `1061-1065`, `1132-1136`, - `1219-1223`, `1315-1319`). -- `NewPullRequestView.tasks` (`new_pull_request.rs:80-84`): 5 push sites, - none pruned (`445-449`, `475-479`, `697-701`, `894-898`, `997-1001`). -- `CommitDiffView` (`diff.rs:407-411`): 1 push site, not pruned. -- `PullRequestDetailView.tasks` (`pull_request_detail.rs:68-72`): the one - correct one — `load` (`pull_request_detail.rs:256-260`) does - `self.tasks.retain(|task| !task.is_ready()); self.tasks.push(task);`. - -So `RepoDetailView.tasks` and `NewPullRequestView.tasks` grow **unbounded** -for as long as the panel stays open: every file preview, ref switch, commit -load, worktree reload, or fork comparison appends one more `Task` that is -never removed. This is a real memory-growth bug, not just a style -preference — a repo detail panel left open through a long session -accumulates one `Task` per interaction, forever. - -Apply the same fix as §6: delete the `tasks` field from all four views and -`.detach()` (or `.detach_and_log_err(cx)`) at every one of the 17 call -sites. Every continuation already tolerates the view being gone -(`this.update_in(cx, ...).ok()`/`?`, same pattern as `signed_state`), so -nothing here needs cancel-on-drop semantics either. Worth noting -`repo_detail/init_dialog.rs`'s `init_repository` (`init_dialog.rs:187-206`) -already does exactly this — `cx.spawn(...).detach()`, no task list at all — -so the fix is bringing the other 4 views in line with a pattern that -already exists once in the same crate. - ---- - -## 15. `Vec` → `Vec` conversion sprawl — fix the 3 `signed_git` signatures, not the 8 call sites - -`Announcement::clone` is `Vec` (`signed_core/src/model.rs`, `Url` being -`nostr`'s re-export of the `url` crate's `Url`, `nostr/src/types/url.rs:15`, -`pub use url::*;`). Every call site that needs to hand those URLs to -`signed_git` first stringifies them: - -``` -grep -rn "\.map(ToString::to_string)\.collect" crates/signed_state crates/workspace -``` - -finds it at `repo.rs:1005-1008` (`merge_pull_request`), `repo.rs:1227` -(`clone_to_folder`), `workspace/repo_detail/mod.rs:397` (`load_repo`), -`new_pull_request.rs:595` and `602` (`choose_fork`, twice — once for the -fork, once for the base), and `pull_request_detail.rs:146-149` and -`738-741` (`load`, `clone_urls_of`). Seven call sites, all producing a -`Vec` that gets handed straight to `signed_git::clone_repo`, -`GitCache::ensure_clone`, or `fetch_repo_refs`. - -The root cause is those three functions' signatures, not the call sites. -Verified in `signed_git/src/lib.rs`: - -```rust -pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> { ... } // lib.rs:125 -pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result<...> // lib.rs:45 -pub fn fetch_repo_refs(repo_path: &Path, urls: &[String], refspec: &str) -> ... // lib.rs:701 -``` - -all three only ever read each URL as `&str` internally, through the shared -`try_each_url(urls: &[String], ...)` helper (`lib.rs:350`), which does -`attempt(url)` where `url: &String` auto-derefs. Checked whether `Url` could -be passed directly instead of allocating a `String` per URL: **yes** — -`url::Url` implements `AsRef` directly (verified in the pinned `url` -crate source, `url-2.5.8/src/lib.rs:2867`). Making the three functions -generic removes the conversion at every call site instead of patching each -one: - -```rust -fn try_each_url, F>(urls: &[U], verb: &str, mut attempt: F) -> Result<()> -where - F: FnMut(&str) -> Result<()>, -{ - for url in urls { - match attempt(url.as_ref()) { /* ... */ } - } - /* ... */ -} - -pub fn clone_repo>(clone_urls: &[U], path: &Path) -> Result<()> { ... } -pub fn ensure_clone>(&self, addr: &RepoAddr, clone_urls: &[U]) -> Result { ... } -pub fn fetch_repo_refs>(repo_path: &Path, urls: &[U], refspec: &str) -> Result<()> { ... } -``` - -After this, every one of the 7 call sites above passes `&announcement.clone` -directly (a `&[Url]`), deleting the `.iter().map(ToString::to_string).collect::>()` -line entirely — no allocation, no `Display`-then-reparse round trip, -7 fewer lines of boilerplate for free. (`about.rs`'s `url.to_string()` calls -for on-screen display, `about.rs:55-103`, are unrelated — that's genuine -`Url → SharedString` rendering, not a `signed_git` call, and stays as-is.) - -The `Vec → Vec` conversions for `add_relays`/`add_relay` -(§8) are a separate root cause (`RelayUrl` doesn't implement `AsRef`, -checked `nostr/src/types/url.rs`) and are already fixed by §8's move to -`RelayUrlArg`'s native `From`/`From<&RelayUrl>` — no further -change needed there. - ---- - -## 16. `.clone()` audit: the dense clusters in `backend.rs` are the correct idiom, not a flaw - -Went through every `.clone()` in `create_repository`, `publish_local_repo`, -and `push_repo_from` (the three functions with the highest clone density) -looking for copies that could be replaced by a reference. All of them are -`Client`/`UniversalSigner`/`PathBuf`/`String`/`RelayUrl` values being moved -into a separate `'static async move` block for `cx.background_spawn`, which -Rust's ownership rules require to own its captures — this is exactly the -shadowing-clone pattern the project's own `.rules` file endorses ("Use -variable shadowing to scope clones in async contexts for clarity, minimizing -the lifetime of borrowed references"). `Client` itself is a cheap `Arc` -handle clone (`Client(Arc)`, verified `nostr-sdk/src/client/mod.rs:74`), -so even the frequent `client.clone()`/`signer.clone()` pairs before each -`background_spawn` are not doing a deep copy. No changes recommended here — -noting this so it's clear the dense clone clusters were checked, not -skipped, and found to be inherent to the async-boundary structure rather -than avoidable duplication. - ---- - -## 17. Business logic that leaked into `crates/workspace` and should move to `signed_core`/`signed_state` - -Direct answer to "can the view side be thinner": yes, and not speculatively — -found one confirmed duplicate, one cluster of misplaced domain parsing, and -one mutating-flow split across the view/store boundary. The test used to -tell "fine to stay in the view" from "should move": read-only git/data -queries that only shape *what one specific view renders* (diffs, commit -lists, tree snapshots — already audited clean in §5/§7) are fine where they -are; anything that **parses a Nostr event's domain tags**, **decides what's -NIP-34-valid/eligible**, or **builds the payload of a mutating operation** -is domain logic and belongs in `signed_core`/`signed_state`, reusable and -testable without GPUI. - -### Confirmed duplicate: `current_commit_of` - -`signed_core/src/model.rs:183-190` (private, used internally by -`pull_request_patches`) and `workspace/repo_detail/pull_request_detail.rs:709-716` -are **the same function, byte-for-byte**: - -```rust -fn current_commit_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), - _ => None, - }) -} -``` - -It was reimplemented in `workspace` because `signed_core`'s copy is private. -Fix: make `signed_core`'s `current_commit_of` `pub fn`, delete -`workspace`'s copy, import the shared one. - -### A whole cluster of NIP-34 tag parsing lives next to it, same shape, same problem - -Still in `pull_request_detail.rs`, zero GPUI/UI dependency in any of them: - -- `merge_base_of(event: &Event) -> Option` (`pull_request_detail.rs:721-729`) -- `clone_urls_of(event: &Event) -> Option>` (`pull_request_detail.rs:734-742`) -- `branch_name_of(event: &Event) -> Option` (`pull_request_detail.rs:745-753`) -- `latest_update<'a>(events: impl Iterator, root: &Event) -> Option<&'a Event>` (`pull_request_detail.rs:756-766`) — - walks a PR's `GitPullRequestUpdate` events to find the newest revision from - the root's author, the exact same *shape* of problem `signed_core::model::pull_request_patches` - already solves for patch series (`model.rs:95-135`, forward/backward - reply-chain walking). - -These all take a plain `&Event` (or an iterator of them) and return plain -data — nothing here needs `Context`/`Window`/`cx`. They belong next to -`Announcement::from_event`, `parse_state`, and `pull_request_patches` in -`signed_core`, as `pub fn`s with their own unit tests (this file's test -module, `pull_request_detail.rs:840+`, already builds fixture events with a -local `signed()`/`pr_root()` helper — `signed_core`'s test module has the -same fixture-building pattern already; the tests move with the functions, -no new test infrastructure needed). - -### A mutating flow split across the view/store boundary: patch generation in `submit` - -`NewPullRequestView::submit` (`new_pull_request.rs:900-1000`) does this -before calling into the store: - -```rust -let patch = cx.background_spawn({ - /* ... */ - async move { format_patch_between(Path::new(&repo_path), &merge_base, &compare_ref) } -}).await; - -let patch = match patch { - Ok(patch) if !patch.is_empty() => patch, - Ok(_) => { /* "No commits between the branches to propose" */ return Ok(()); } - Err(error) => { /* "Failed to generate the patch: {error}" */ return Ok(()); } -}; - -store.update(cx, |store, cx| { - store.open_pull_request(/* subject, description, branch_name, patch, ... */) -}); -``` - -`RepoStore::open_pull_request` (`repo.rs:554-558`) and `update_pull_request` -(`repo.rs:829-833`) both already take a ready-made `patch: String` — a -reasonable, uniform boundary in general (it's also exactly right for -`pull_request_detail.rs`'s "update PR" dialog, `pull_request_detail.rs:648-700`, -where the patch is literally pasted by the user into a textarea, no git -involved). But for the "compare two branches" flow, *generating* that patch -text — calling `signed_git::format_patch_between`, deciding empty-diff is -an error, and wording that error — is exactly the same kind of "turn git -state into the payload of a Nostr publish" work `Backend::create_repository`/ -`publish_local_repo` already do internally (`worktree_ref_state`, -`root_commit`), just for a different event kind. It shouldn't be the one -case where that responsibility sits in the view instead of the store. - -**Recommendation:** give `RepoStore` (or a free function in `signed_state` -it calls) a method that takes the two refs instead of a ready-made patch, -e.g. `RepoStore::open_pull_request_from_refs(repo_path, base_ref, compare_ref, -subject, description, draft, cx) -> Task>`, which does -the `format_patch_between` + empty-check + `open_pull_request` sequence -internally and returns one descriptive error on failure. `submit` shrinks to -gathering the text-field values and calling it, then closing the panel — -no `signed_git` import needed in `new_pull_request.rs` at all for this path. - -### Borderline, worth doing while touching the same file: `fork_candidates`/`fork_namespace` - -`fork_candidates` (`new_pull_request.rs:117-135`) filters/partitions -`&[Announcement]` into "own" vs. "others" fork sources using the -already-domain `Announcement::is_fork_of` predicate (`signed_core/src/model.rs:281-285`, -correctly reused, not reimplemented) — it's pure data transformation with no -GPUI dependency, and has its own private unit tests in `new_pull_request.rs` -building fixture announcements, again duplicating test-fixture machinery -`signed_core`'s own test module already has. `fork_namespace` -(`new_pull_request.rs:108-114`, formats the `refs/fork//` -namespace string) is the same shape — small, but it's the one place that -convention is decided, and it pairs naturally with `signed_git`'s ref-naming -conventions. Both are safe, low-risk moves to `signed_core`: unlike -`fork_display_name`/`shorten_owner`/`truncate_label`/the `*_source_item` -builders in the same file (genuine presentation logic — `SharedString` -truncation, `PopupMenuItem` construction — correctly left where they are), -these two don't touch a single GPUI type. - -### What's already thin and should stay exactly where it is - -For contrast, checked `RepoDetailView`'s git-touching methods -(`load_repo`, `load_commits`, `switch_ref`, `reload_worktree`, -`catch_up_worktree`, `push_unpushed_checkout`) and `NewPullRequestView::reload_compare` -(`new_pull_request.rs:808-897`, computing `merge_base`/commit -list/diff purely to populate the compare pane): these call `signed_git` -directly too, but only to compute **read-only data this one view renders** -— nothing here is parsed from a Nostr event, decides NIP-34 eligibility, or -builds a publish payload. Moving these into `signed_state` would just add -an indirection layer with no reuse benefit, contradicting "keep it simple." -Same verdict as `create_repo_dialog.rs`'s and `init_dialog.rs`'s handlers -(§9): they already do nothing but gather form input and call one `Backend` -method. - ---- - -## Outcome - -All 14 items below are implemented and verified, listed in the order they were -done — mechanical removals first, the largest diff (§2) and the riskiest swap -(§5) last. - -1. **Delete the fetch/sync dedup cache** (§3). `recent_fetches`, - `fetch_recently_started`, `fetch_fingerprint` and `FETCH_DEDUP_WINDOW` are - gone, along with the `DefaultHasher`/`Hash`/`Hasher`/`Instant` imports they - needed. Each call site keeps its own guard. -2. **Remove the `tasks: Vec>` + `push_task` boilerplate** on both - sides (§6, §14) — all six `signed_state` stores and all four - `crates/workspace` views, 17 push sites, most never pruned. In the views - this was a real unbounded-growth bug, not just style. -3. **Fix the relay add/connect calls** (§8). No `.as_str()`/`ToString` round - trips; `add_relay(url).and_connect()` instead of a separate, pool-wide - `connect()`; `Backend::add_relays` deleted. -4. **Fix `bootstrap_user`** (§1) to sync via negentropy and query the local - database. `grep -rn "fetch_events" crates/` now returns nothing. -5. **Generalize the three `signed_git` URL-list signatures** to - `U: AsRef` (§15) and drop the seven - `.iter().map(ToString::to_string).collect()` call sites, which now pass - `&[Url]` straight through. -6. **Fix `create_repository`'s init/clone ordering** (§9). It initializes and - pushes directly at the destination; no mirror, no `clone_repo`, no double - `origin`. -7. **Merge `local_repos.rs` and `repo_list.rs`** into - `signed_state/src/repos.rs` (§13), keeping both stores independent - `Entity`/`Global`s — no call-site change beyond `use` paths. -8. **Route construction-time bootstrap through `cx.defer`** in all six stores - (§10), with a failed weak upgrade logged rather than silently dropped. -9. **Consolidate the send paths** (§2, §4). The eight layers (`Backend::send`, - `publish_event`, `publish_task`, `send_fire_and_forget`, `broadcast_event`, - `RepoStore::send`, …) collapsed to direct - `client.send_event(&event).broadcast()` calls plus one - `require_relay_accepted` helper. `retract_events` now sends one NIP-09 - deletion per target, with no `k` tag. The rewrite also fixed the - inconsistent error surfacing this section flagged: the three call sites - with divergent control flow now set `last_error` on failure like every - other `RepoStore` mutation. -10. **Split `pushing_repos` into a child entity** (§11). It is now an - observable `Entity>`; the old `PushGuard` and the - `Arc`/`Mutex` around it are gone. -11. **Centralize the notification-pump debounce** (§12). The pump batches into - a single `BackendEvent::NostrUpdate(Vec)` behind a 200 ms window, - and its three subscribers iterate the batch. -12. **Drop progressive reveal** (§7). `RepoListStore` refreshes once per - completed sync instead of several times mid-sync. -13. **Replace the hand-rolled `git format-patch` parser with - `diffy::patch_set`** (§5). 370 lines to 216, no test changed. -14. **Move the misplaced `workspace` domain logic** (§17) to - `signed_core`/`signed_git`, and give `RepoStore` a refs-in-patch-out - method so `NewPullRequestView::submit` no longer generates patches itself. - -### Deviations, corrections, and findings worth keeping - -Most items landed exactly as planned. These are the ones that did not, plus -the non-obvious findings that were only ever recorded in the per-item status -notes this section replaced: - -- **`RepoStore::publish` was deliberately kept** (§2), a narrow exception to - "delete `RepoStore::send`": its four callers (`open_issue`, `reply`, - `set_status`, `publish_applied_status`) have byte-for-byte identical - sign+send+check+`last_error` post-conditions. The three callers with - genuinely divergent control flow (`open_pull_request`, - `update_pull_request`, `publish_patch_series`) call the SDK inline. -- **`fork_namespace` went to `signed_git`, not `signed_core`** as §17 - sketched. It calls `signed_git::sanitize_path_component`, and `signed_git` - already depends on `signed_core`, so the sketched direction would have been - a circular crate dependency. -- **`pushing_repos` was made observable with `AsyncApp::on_drop`, not a `Drop` - impl** (§11). `Drop::drop(&mut self)` has no `cx`, so it cannot update a GPUI - entity; Zed's own codebase hits the same wall and falls back to a raw - `Mutex` (`crates/project/src/project.rs`, `RemotelyCreatedModelGuard`). -- **Calling `cx.entity()` before the entity is registered is safe** (§10) — - what makes the deferred bootstrap sound. `App::new`'s `cx.entities.reserve()` - bumps the ref count before `build_entity` runs - (`app/entity_map.rs:114-117`), and the deferred closure only runs after - `cx.new`'s `insert_entity`. -- **Gossip stays enabled** in `ClientBuilder` for future NIP-17/NIP-65 work; - every git-domain send bypasses it explicitly with `.broadcast()` (§4). -- **`pool.sync()` requires the relays to already be in the pool** - (`pool/mod.rs:679-693`), which is why `Backend::bootstrap` adds - `BOOTSTRAP_RELAYS` before `sync_bootstrap_only`/`bootstrap_user` run — the - precondition §8's change relies on. -- **`pushing_repos` has no readers today** (§11): it is `push_repo_from`'s - internal re-entrancy guard. The UI-facing "is pushing" indicator is the - pre-existing, already-observable `RepoStore::pushing` boolean. -- **`patch_diffs` short-circuits on input with no `diff --git ` line** (§5), - because `PatchSet` yields `Err("no valid patches found")` for input holding - no patch at all, where the old parser returned an empty list. -- **A `let _ =` on a `WeakEntity::update` in `ProfileStore::handle_requests` - became `.ok()`** (§12), found while touching that file. It now follows the - project's error-handling rule. -- **Two type-inference anchors had to be re-added by hand**, a recurring cost - of both §6/§14 and §15: removing a `Vec>` field and going generic - over `AsRef` both strip the anchor from call sites with untyped `&[]` - literals, fixed with explicit `Task>` and - `&[] as &[String]` annotations. -- **Not covered by tests:** the deleted send paths (§2) and the - `create_repository` fix (§9) need a live relay or a live GRASP server, so - they were verified by compilation plus a line-by-line diff against the old - control flow. A manual create-repository-then-open-detail-view pass is still - the recommended pre-ship check for §9. - -Verification: `cargo check --workspace`, `cargo clippy --workspace ---all-targets` and `cargo test --workspace` pass — 167 tests, 0 failures — -re-run after each item landed rather than once at the end. Test counts moved -between crates as functions moved (`signed_core` 41 → 48, `signed_git` -67 → 68, `workspace` 14 → 7); no coverage was lost. - -Everything **not** listed above (per-repo/per-list `Entity` stores, the -`RefreshGate` debounce/coalesce pattern, `Nip34Tag`/`Coordinate`/`Filter` -usage in `signed_core`, the GRASP push-retry state machine in -`push_staged_to_grasps`, the `UniversalSigner` abstraction, and the dense -`.clone()` clusters audited in §16) was checked and already matches "use the -SDK directly, no unnecessary wrapper" — those were left alone. diff --git a/docs/inbox-plan.md b/docs/inbox-plan.md deleted file mode 100644 index a0e51ed..0000000 --- a/docs/inbox-plan.md +++ /dev/null @@ -1,973 +0,0 @@ -# Inbox (home screen) implementation plan - -Ported from GitWorkshop's home screen, the `Dashboard` rendered at route `/` for a logged-in user. - -> **Correction to the first draft.** The first draft assumed the inbox was the `/notifications` -> page. It is not. GitWorkshop's `Index` route (`src/pages/Index.tsx`) renders `` when -> an account is active, and that home screen is the inbox. - -> **Status.** Phases 0-4 are implemented and green on `feat/inbox`, then the screen was redesigned to -> group **threads by repository** and to merge notifications with own activity into one row per thread -> (see the repository-grouping and thread-merge notes in §7). -> `cargo test -p signed_core` (69), `cargo test -p signed_state` (24), -> `cargo test -p workspace` (7), `cargo test -p dock` (1), `cargo clippy -p workspace --all-targets` -> clean, `cargo check --workspace --all-targets` succeeds. -> Phase 5 is not started. This document reflects the implementation as it stands: the Phase 1 -> refactors, the §4.3 split of the inbox into a thin global `Inbox` and a panel-owned derivation, the -> Phase 4 click-through, and the repository-grouped thread list. The Phase 3 bottom-dock sub-views were -> removed before the redesign; their implementation notes in §7 are historical. - -## 1. What the GitWorkshop home screen is - -`Index.tsx`: - -```tsx -if (account) return ; -return ; -``` - -`Dashboard.tsx` layout: - -- Desktop: two columns. - - **Left column**: `GreetingHeader`, `NotificationsPanel`, `RecentActivitySection`. - - **Right column**: `MyRepositoriesPanel`, `AccessiblePrivateRepositoriesPanel`, - `FollowedReposPanel`. -- Mobile: a single column in a different order. - -The panel that gives the screen its inbox identity is `NotificationsPanel`: - -- heading **Notifications** with a bell icon and an unread count badge, -- a **Mark all read** action and a **View all** link to `/notifications`, -- a compact list of the first 5 **non-archived** notification items, -- the empty state reads **"Your inbox is empty"** (with an `Inbox` icon). - -So in GitWorkshop's vocabulary, "inbox" is the non-archived activity directed at you, surfaced -inline on the home screen. The home screen also shows your own recent activity and your repositories. - -Data hooks: - -| Section | Hook | What it loads | -|---|---|---| -| Notifications (inbox) | `useNotifications()` | Notification model: grouped thread activity directed at you, read/archived state | -| Continue where you left off | `useUserActivity(pubkey)` | Git activity authored by you: kinds 1621/1617/1618/1111 (git `K`)/1624/1630-1633, newest first, limit 50 | -| My repositories | `useUserRepositories(pubkey)` | Kind 30617 announcements authored by you | -| Followed repositories | `useUserFollowedRepos(pubkey)` | Repos you follow | -| Accessible private repositories | `useAccessiblePrivateRepositories()` | Private repos from CI/services | - -## 2. Scope for Signed - -| Priority | Section | Notes | -|---|---|---| -| **P0** | Inbox panel | Activity directed at you and your own activity, **grouped by repository**; unread badge; mark all read; all groups shown | -| **P1** | Click-through | Open the issue/PR detail panel at the relevant thread root | -| **P2 (defer)** | Standalone notifications page, NIP-65 relay discovery, pagination | Web-app concerns | -| **Out of scope** | Greeting header, my repositories, followed repositories, private repositories, pinned repositories, Unread/Archived sub-views | Not needed in Signed | - -Notes: - -- There is **no greeting header**. The screen starts with the inbox panel. -- There is **no My repositories column**. The sidebar already lists the signed-in user's repositories, so the inbox is a single column. -- The Unread/Archived sub-view panels were removed: the panel is a single repository-grouped list instead. - -## 3. The Signed screen - -`InboxView` is a center panel, opened by the sidebar's existing **Inbox** nav item. It is one bordered -card holding a single virtual list. Every row is either a **repository header** or one of that -repository's **threads**, newest first. A thread merges the notifications directed at the user with -the user's own events in the same root, and shows the root's title plus up to five of its most recent -events: -The sections are **all of the user's own repositories**, seeded from `RepoListStore`, plus any other -repository that has threads. Owned repositories with nothing to show render an -empty state ("No activity yet.") under their header, and sort after the ones with activity (newest -announcement first). Threads with no repository address fall into a single "Other repository" section. - -``` -+-------------------------------------------------------------------------+ -| Inbox (3 unread) [Mark all read] | -|-------------------------------------------------------------------------| -| [repo] you/repo-a (2) | -| [icon] Add retry logic (unread dot) | -| [avatar] You opened an issue 3d | -| [avatar] alice commented 2d | -| [icon] Fix flaky test | -| [avatar] You opened a PR 1h | -|-------------------------------------------------------------------------| -| [repo] you/repo-b | -| No activity yet. | -|-------------------------------------------------------------------------| -| [repo] you/repo-c | -| No activity yet. | -+-------------------------------------------------------------------------+ -``` - -The sections are the repositories that actually have threads, ordered by their -newest row. A repository the user owns but that has no items is not shown. Threads with no repository -address fall into a single "Other repository" section. - -## 4. Data layer - -### 4.1 `signed_core`: pure logic - -**`filters.rs`** (extend, next to `activity`/`comments_for`): - -```rust -/// Kinds that notify a user when they tag them directly. -pub const NOTIFICATION_KINDS: [Kind; 9] = [ - Kind::GitIssue, - Kind::GitPullRequest, - Kind::GitPatch, - Kind::GitPullRequestUpdate, - COVER_NOTE_KIND, - Kind::GitStatusOpen, - Kind::GitStatusApplied, - Kind::GitStatusClosed, - Kind::GitStatusDraft, -]; - -/// Comments on our issues/PRs/patches. -pub fn notification_comments(me: PublicKey) -> Filter { - Filter::new() - .kind(Kind::Comment) - .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) - .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) -} - -/// Activity directed at us: comments on our roots, and git events tagging us. -pub fn notifications(me: PublicKey) -> Vec { - vec![ - notification_comments(me), - Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me), - ] -} - -/// Git activity authored by `me`, for "Continue where you left off". -pub fn authored_activity(me: PublicKey) -> Filter { - Filter::new() - .kinds([ACTIVITY_KINDS.as_slice(), &[COVER_NOTE_KIND]].concat()) - .author(me) -} -``` - -`ACTIVITY_KINDS` already exists in this file. All builders use existing SDK APIs -(`Filter::kind/kinds/pubkey/custom_tags`, `SingleLetterTag::{UPPERCASE_P, UPPERCASE_K}`). - -Comments authored by `me` are not all git comments, so the activity query needs a post-filter: -keep kind 1111 only when its uppercase `K` tag is a git root kind (1621/1617/1618/30617), matching -gitworkshop's `isGitComment`. - -**`inbox.rs`** (new file): - -```rust -pub struct InboxItem { - pub root: EventId, - /// The root event itself, when known locally; drives the row title. - pub root_event: Option, - pub root_kind: Option, - pub address: Option, - /// Notification events directed at the user, newest first. - pub events: Vec, - /// The user's own events in the same thread, newest first. - pub own_events: Vec, - /// Unread event ids, oldest first. - pub unread_ids: Vec, - pub archived: bool, -} - -impl InboxItem { - /// Title of the thread root; falls back to the newest event it has. - pub fn title(&self) -> String; - /// Kind of the thread root; falls back to the newest event it has. - pub fn kind(&self) -> Option; - pub fn latest_activity(&self) -> Timestamp; - /// Up to `limit` most recent events of the thread, oldest first. - pub fn timeline(&self, limit: usize) -> Vec; - pub fn is_unread(&self) -> bool; - pub fn apply_state(&mut self, state: &InboxReadState); -} - -/// The thread root of a notification event, or `None` if it isn't git-related. -pub fn notification_root( - event: &Event, - lookup: &impl Fn(EventId) -> Option, -) -> Option; - -/// Group the notifications directed at the user together with the user's own -/// events into one item per thread, newest activity first. -pub fn group( - events: impl IntoIterator, - own: impl IntoIterator, - me: PublicKey, - state: &InboxReadState, - lookup: &impl Fn(EventId) -> Option, -) -> Vec; -``` - -Root resolution, ported from `getNotificationRootId`: - -- issue (1621) / PR (1618): itself -- patch (1617): its `e` parent patch, else itself -- NIP-22 comment (1111): uppercase `E` root pointer (SDK `nip22::extract_root`) -- PR update (1619): uppercase `E` -- statuses (1630-1633) / cover note (1624): NIP-10 root `e` -- notification events authored by `me` are dropped; the user's own events are kept in - `own_events` instead, never in `events` -- `unread_ids` and `archived` are derived from `events` only, so the user's own activity is never - unread and a thread with only own events is never archived - -Read/archive state, the compact high-water-mark model: - -```rust -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct InboxReadState { - #[serde(default)] pub read_before: Timestamp, - #[serde(default)] pub read_ids: HashSet, - #[serde(default)] pub archived_before: Timestamp, - #[serde(default)] pub archived_ids: HashSet, -} - -impl InboxReadState { - pub fn is_read(&self, event: &Event) -> bool; - pub fn is_archived(&self, event: &Event) -> bool; - pub fn mark_read(&mut self, event: &Event); - pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey); - /// Move the cutoff to `min(oldest unread - 1, now - 3 days)` and prune ids. - pub fn advance_read(&mut self, all: &[Event], me: PublicKey); - pub fn advance_archived(&mut self, all: &[Event], me: PublicKey); -} -``` - -`activity_subject` in `model.rs` already gives an issue/PR title from the `subject` tag or first -line; reuse it for the home screen rows. - -### 4.2 Persistence: NIP-78 in the local database, never published - -Read state is a normal NIP-78 (kind `30078`, `Kind::ApplicationSpecificData`) addressable event -**written to LMDB only**. It is never broadcast to a relay, so the read state stays on this device. - -It is signed with a **random keypair**, never the user's signer. The event is local application -storage, so its author carries no identity; this avoids a signing round-trip and does not depend on -the signer type. The `d` tag identifies the owning user, so state does not leak across identities -when the signed-in key changes. - -```rust -/// d tag identifying the inbox read/archive state event of `me`. -fn inbox_state_d_tag(me: PublicKey) -> String { - format!("signed-inbox-state:{}", me.to_hex()) -} - -/// Newest stored read state for `me`. -async fn load_state(client: &Client, me: PublicKey) -> Result, Error> { - // No author filter: the signing key is random per save. - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(inbox_state_d_tag(me)); - - let events = client.database().query(filter).await?; - - let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else { - return Ok(None); - }; - - match serde_json::from_str(&event.content) { - Ok(state) => Ok(Some(state)), - Err(error) => { - log::warn!("ignoring unreadable inbox state {}: {error}", event.id); - Ok(None) - } - } -} - -/// Sign with a fresh random key and store locally. -async fn save_state(client: &Client, me: PublicKey, state: &InboxReadState) -> Result<(), Error> { - let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) - .tags([Tag::identifier(inbox_state_d_tag(me))]) - .finalize(&Keys::generate())?; // synchronous: random key, no user signer - // Local-only: no `send_event`, no broadcast. The event lives in LMDB. - client.database().save_event(&event).await?; - Ok(()) -} -``` - -A fresh random key is generated on every save, so each save writes a new event rather than -replacing the previous one. LMDB only auto-replaces an addressable event when the incoming event -has the **same pubkey**, so old copies accumulate. Nothing prunes them; `load_state` reads the -newest by `created_at`, so the behavior is correct. This is a deliberate trade for not caching a -key in the store (see §4.3). An earlier implementation deleted the previous event by tracking its -id across saves; that was removed as more derived state than it was worth. -`NostrDatabase::{save_event, query}` and `Client::database()` are existing SDK APIs. - -### 4.3 Data layer: a thin global `Inbox`, a panel-owned derivation - -The inbox is split in two, because the expensive derivation is only needed while the home screen is -open. - -**`Inbox`** is a child `Entity` owned by `Backend` (`inbox: Entity`) and is -deliberately thin: it owns only the read/archive state that must outlive the panel and the NIP-78 -load/save. - -```rust -// backend.rs -pub struct Backend { - ... - inbox: Entity, -} - -// inbox.rs -#[derive(Default)] -pub struct Inbox { - state: InboxReadState, - loaded: bool, -} - -impl Inbox { - pub fn state(&self) -> &InboxReadState; - pub fn is_loaded(&self) -> bool; - pub fn mark_read(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); - pub fn mark_archived(&mut self, group: &[Event], all: &[Event], me: PublicKey, cx); - pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx); - pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx); - pub(crate) fn reset(&mut self, cx); -} - -// inbox.rs (signed_state) -/// One item per thread, notifications and own activity merged. -pub async fn query_inbox( - client: &Client, - me: PublicKey, - state: &InboxReadState, -) -> Result<(Vec, usize), Error>; -``` - -**The panel owns the derivation.** `InboxView` itself holds the derived lists, the copy of the read -state they were computed with, and the refresh coalescing. There is no separate store entity: the -panel is the only consumer, so an `Entity` would add an `update` indirection and a -forwarding subscription without buying any sharing. The panel's own `unread_count` feeds its header -badge only; there is no global count and no sidebar badge. - -```rust -pub struct InboxView { - focus_handle: FocusHandle, - dock_area: WeakEntity, - threads: Arc>, // one row per thread, merged - sections: Arc>, // grouped by repository - rows: Arc>, // flattened list - unread_count: usize, - state: InboxReadState, - state_loaded: bool, - refresh: RefreshGate, - list: ListState, - _subscriptions: Vec, -} - -impl InboxView { - pub fn new(dock_area: WeakEntity, cx: &mut Context); // cx.defer(… sync_state) - pub fn sync_state(&mut self, cx); // observes the global Inbox - pub fn mark_all_read(&mut self, cx); - fn handle_backend_event(&mut self, event: &BackendEvent, cx); - fn refresh(&mut self, cx); - fn run_refresh(&mut self, cx); - fn regroup(&mut self, cx); // re-apply read state - fn rebuild(&mut self, cx); // group by repository, seed owned, flatten - fn clear(&mut self); -} -``` - -The panel owns three subscriptions that carry logic: it observes the global `Inbox` -(`InboxView::sync_state`), subscribes to `Backend` (`InboxView::handle_backend_event`), and observes -`RepoListStore` to rebuild when the user's own repositories load. Re-rendering itself needs no -subscription: GPUI invalidates a window for every entity it read during render, so the panel tracks -`RepoListStore` and `ProfileStore` just by reading them in `render`. The panel does not write back -to the global. - -**`signed_state::query_inbox`.** The database work stays in `signed_state`, so the UI crate never -queries LMDB directly. `query_inbox` returns the grouped notifications, the user's own git -activity and the unread count; the panel applies the results on the main thread. `RefreshGate` is -re-exported for the panel's debounce. - -```rust -pub async fn query_inbox( - client: &Client, - me: PublicKey, - state: &InboxReadState, -) -> Result<(Vec, Vec, usize), Error>; -``` - -**Lifespan.** `Inbox` is created with the backend but idles until the user has a signer. The -derived lists live only as long as the panel. Nothing is wired from the `desktop` crate and -`signed_state::init` gains no parameters. - -**No sidebar badge.** The sidebar's inbox nav item has no unread suffix (an earlier global count -derivation was removed with it). The unread count lives entirely in the panel, which shows it in -its header and per repository section. The trade-off is that the count is only current while the -panel is open, which is acceptable now that nothing outside it displays one. - -The dependency chain is `Backend` → `Inbox` and `InboxView` → `query_inbox`. - -`Backend` owns the inbox lifecycle (`sync_inbox`); the panel subscribes to `Backend` directly for -its lists. `BackendEvent::SignerChanged` and `SignerRequired` are still emitted and must stay: -`CheckoutsStore` and `SidebarPanel` consume them. They no longer drive the inbox's activation -directly. - -`InboxView::handle_backend_event` refreshes on: - -- `NostrUpdate(updates)`: when any update kind is in `NOTIFICATION_KINDS`, is `Kind::Comment`, or is - a deletion (`EventDeletion` / `RequestToVanish`). -- `Synced` / `Published`. -- everything else: ignored. - -**Signer lifecycle: `Backend::sync_inbox`.** `Backend` owns the wiring and calls `sync_inbox` from the -three real signer transitions: `create_identity`, `set_signer` (nsec, bunker and passphrase restore) -and `logout`. It starts the subscriptions and repo-relay connects, then calls `Inbox::activate` or -`Inbox::reset`. The client is passed into `activate`, so the global inbox never reads `Backend` -while `sync_inbox` is mid-update: - -```rust -fn sync_inbox(&mut self, cx: &mut Context) { - let me = self.current_user; - - if let Some(me) = me { - self.subscribe_bootstrap(filters::notifications(me), cx); - self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); - - let relays: HashSet = RepoListStore::global(cx) - .read(cx) - .announcements_of(&me) - .into_iter() - .flat_map(|announcement| announcement.relays) - .collect(); - - if !relays.is_empty() { - let relays: Vec = relays.into_iter().collect(); - self.connect_repo_relays(relays.clone(), filters::notifications(me), cx); - self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx); - } - } - - let client = self.client.clone(); - self.inbox.update(cx, |inbox, cx| match me { - Some(me) => inbox.activate(me, client, cx), - None => inbox.reset(cx), - }); -} -``` - -The repo relays are read from `RepoListStore::global(cx).read(cx).announcements_of(&me)` at call -time and never cached. (NIP-65 outbox relay discovery is deferred; Signed does not fetch kind -10002 yet.) `Inbox::activate` and `Inbox::reset` are `pub(crate)`; `Inbox` has no `subscribe_remote` -/ `connect_own_repo_relays`. - -**Activation** clears the state and loads the NIP-78 state from LMDB. The panel clears its own -lists and in-flight refresh when it sees the unloaded state, then refreshes once it is loaded: - -```rust -pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context) { - // state = default; state_loaded = false; cx.notify(); - // spawn load_state(client, me), then set state and state_loaded = true -} -``` - -`reset` performs the same clearing without a state load, and is used on logout. - -Reading the state event needs no signer at all (the `d` tag carries the identity); activation is -still gated on the signer because the fetch filters need the user's pubkey. - -**Fetch** reuses `Backend::subscribe_bootstrap` and `Backend::connect_repo_relays` through -`Backend::sync_inbox` (see above). The query the panel runs is intentionally the offline-first cache -read, not a wait on the network; see the note below. - -**Refresh** (`InboxView::run_refresh`, mirrors `RepoListStore::run_refresh`): - -- `cx.background_spawn`: query the notification filters and the activity filter from - `client.database()`. -- Query `filters::deletions()`, build `Deletions`, skip deleted events. -- Build `HashMap` for root walking; group the notification events with - `inbox::group`. -- Filter the activity events: keep issues/PRs/patches/statuses/cover notes, and comments only when - their `K` tag is a git kind; sort newest first. -- Cross back to the main thread: guard on `Backend::global(cx).read(cx).current_user() == - Some(me)`; if the signer changed while the query ran, `refresh.abort()` instead of applying, so a - previous user's results never land. Then set `notifications`, `activity`, `unread_count`, rebuild the - repository sections (`rebuild`), `cx.notify()`, `refresh.finish()`. - -`InboxView::sync_state` reacts to the global `Inbox`: while the state is not loaded it clears the -lists, on the first load it runs the initial refresh, and on a state change (a mark action) it -re-derives the flags (`InboxItem::apply_state`). - -**Fetch vs. the immediate query.** `subscribe_bootstrap` / `connect_repo_relays` return immediately, -so the query that follows them reads the local cache rather than waiting for the relays. That is -deliberate offline-first behavior: cached content appears at once on a warm start and with no -network, instead of blocking the home screen on the network. The gap is closed by the SDK, not by -timing: received events are written to LMDB and surfaced as `ClientNotification::Event`, so -`Backend`'s pump batches them into `BackendEvent::NostrUpdate` and the store refreshes. This was -reviewed and left as-is. - -**Actions**: `mark_all_read()` lives on the panel, which passes every known notification event to the -global `Inbox`. The global marks them, advances the cutoffs against *all* notification events to bound -the id sets, saves the state to LMDB (signed with a fresh random key, see 4.2), and notifies. The -panel then re-derives and publishes the unread count. - -**Repository names need no new store**: `RepoListStore` already holds every announcement and -`repo_name` resolves an address to a display name. - -### 4.4 `Cargo.toml` - -- `signed_core`: add `serde.workspace` for the `InboxReadState` derives. -- `signed_state`: add `serde_json.workspace` for the NIP-78 content. - -## 5. UI - -### 5.1 `InboxView` center panel - -`crates/workspace/src/views/inbox.rs`, a `BasePanel` + `Panel` + `Render`, like `RepoListView`. -It owns the derived lists directly, so `cx.notify()` from an update re-renders it. The panel is one -bordered card (`flex_1`, `min_h_0`) with a header bar and a scrolling body. The body is a single -`gpui::list` virtual list (`ListState` + `ListAlignment::Top`, 400px overdraw) with a -`vertical_scrollbar`; the panel itself does not scroll, so the list gets a definite viewport height. -The list count is reset from `render` whenever the rendered row count changes. - -- **Header**: the unread count badge and **Mark all read**. -- **Body**: the flattened repository-grouped rows. A repository header is a muted bar with a git icon, - the repository name (or "Other repository" when the address is unknown) and its unread badge. Rows - under it show the actor avatar, a kind icon, the subject, the kind label, a relative time, and an - unread dot (the subject is semibold while unread). A repository with nothing to show renders - "No activity yet."; the panel-level "You're all caught up." empty state appears only when there are - no sections at all (no owned repositories and no items). - -No greeting header, and no **My repositories** column - the sidebar already lists the user's -repositories. - -### 5.2 Grouping by repository - -The grouping is panel-owned derivation, done once per data change in `InboxView::rebuild` (called -from `run_refresh` and `regroup`), never per frame: - -```rust -struct InboxSection { - address: Option, // repository, None for items without one - unread: usize, // unread notification groups - entries: Vec, // newest first - latest: Timestamp, // orders the sections -} - -enum InboxEntry { // indices into the panel's own lists - Notification(usize), - Activity(usize), -} - -enum InboxRow { // the flattened list - Repo(usize), - Entry(usize, usize), - Empty, // "No activity yet." under an empty section -} -``` - -The section list and the flattened rows are stored as `Arc`s and cloned into the `gpui::list` -closure, which indexes the panel's `notifications` / `activity` lists - no per-frame deep copies. -Notification groups carry their repository in `InboxItem::address`; activity events carry it in a -`GitRepoAnnouncement` `a` tag (`repo_address`). Archived notification groups are left out. - -`rebuild` also seeds a section for every repository in `announcements_of(me)`. The panel observes -`RepoListStore` so a repository that loads after the last refresh still appears (its own empty -section, or with items if any arrived); this is the one logic subscription beyond the `Inbox` and -`Backend` ones. Repository names are resolved per render through `repo_name` -> `RepoListStore`, so a -late announcement still labels its section without re-deriving the grouping. - -### 5.3 Sidebar - -In `views/sidebar/mod.rs`: - -- Add `inbox: Option>` (mirrors `explore`). -- Add `fn open_inbox(&mut self, window, cx)` that returns when the panel is already open, else adds - a center panel (same shape as `open_explore`; there is no dock API to focus an existing tab). - `InboxView::new` takes the sidebar's `WeakEntity` so the panel can open a repo for a row. -- Point the existing nav item at it: - - ```rust - NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) - .on_click(cx.listener(|this, _ev, window, cx| this.open_inbox(window, cx))), - ``` - -- No unread badge. The nav item carries no suffix, and the sidebar does not observe the global - `Inbox`. The unread count lives in the panel only. - -### 5.4 Click-through (P1) - -The detail panels need a `Window`, and GPUI's `Entity::update_in` only exists on a `VisualContext`, -which a synchronous `App` + `Window` pair is not - so the entry point is a free function rather than -a `RepoDetailView::open_item` method. In `repo_detail/mod.rs`: - -```rust -pub(crate) enum RepoItem { - Issue(EventId), - PullRequest(EventId), - Patch, -} - -pub(crate) fn open_repo_item( - dock_area: &WeakEntity, - announcement: &Announcement, - item: RepoItem, - window: &mut Window, - cx: &mut App, -) { /* build the RepoStore here, then a new IssueDetailView / PullRequestDetailView, added to the center */ } -``` - -- `open_repo_item` builds its own `RepoStore` from `announcement` (a private `repo_store` helper calls - `RepoStore::new(addr, relays, cx)`), so the item panel is the **only** panel docked. An earlier - version opened `RepoDetailView` first and reused its store via `RepoDetailView::store()`; that - docked the repository panel too, which surfaced the repository load state (a `not found` error for - an announced repo with no local worktree) and left two center tabs. `RepoDetailView::store()` was - removed with it. -- `views/mod.rs` re-exports `RepoItem` and `open_repo_item`. -- `InboxView::open_item` resolves `item.address` to an `Announcement` from `RepoListStore`, and calls - `open_repo_item` with the root id and kind. The detail panel renders a "not found" placeholder - until the store's fetch lands, then re-renders. -- The item panel is added to the center group and activated. - -Patches have no detail view in Signed (they are only consumed inside `PullRequestDetailView`), so a -patch-root click opens nothing. `RepoItem::Patch` carries no id for that reason. A group whose root is -not an issue/PR/patch, or whose repository is not in `RepoListStore`, opens nothing. - -## 6. File-by-file change list - -| File | Change | -|---|---| -| `crates/signed_core/Cargo.toml` | add `serde` | -| `crates/signed_core/src/filters.rs` | `NOTIFICATION_KINDS`, `notification_comments`, `notifications`, `authored_activity`, `is_git_activity`, `deletions` | -| `crates/signed_core/src/inbox.rs` | **new**: `InboxItem` (root event, notifications, own events), `notification_root`, `group`, `InboxReadState`, tests | -| `crates/signed_core/src/lib.rs` | `mod inbox;` and re-exports | -| `crates/signed_state/Cargo.toml` | add `serde_json` | -| `crates/signed_state/src/inbox.rs` | thin global `Inbox` (NIP-78 read state, mark actions) and `query_inbox` (query, merge notifications + activity into threads) | -| `crates/signed_state/src/backend.rs` | `inbox: Entity` field, construction, `inbox()` accessor, `sync_inbox`, `RepoListStore` import | -| `crates/signed_state/src/refresh.rs` | doc comment lists `Inbox` among the `RefreshGate` users | -| `crates/signed_state/src/lib.rs` | `mod inbox;`, re-export `Inbox` and `query_inbox`; re-export `RefreshGate` (no global install) | -| `crates/dock/src/lib.rs` | `add_bottom_panel` helper (currently unused; left over from the removed sub-views) | -| `crates/workspace/src/views/inbox.rs` | `InboxView` home panel owning the threads, the repository grouping, and the thread click-through | -| `crates/workspace/src/views/mod.rs` | `mod inbox; pub use inbox::InboxView;`; re-export `RepoItem`, `open_repo_item`, `open_repo_panel` | -| `crates/workspace/src/views/sidebar/mod.rs` | `inbox` field, `open_inbox`, nav wiring | -| `crates/workspace/src/views/repo_detail/mod.rs` | `RepoItem`, `open_repo_item` (builds its own `RepoStore` via the private `repo_store` helper) | - -No changes to `desktop` or `signed_nostr`. `signed_state::init` gains no parameters; `Backend::sync_inbox` -activates the `Inbox` child entity at each signer transition. - -## 7. Phasing - -1. **Phase 0 - pure logic**: `signed_core` filters and `inbox.rs` plus tests. **DONE.** - Implemented as `filters::{NOTIFICATION_KINDS, notification_comments, notifications, authored_activity, is_git_activity}` - and `inbox::{InboxItem, notification_root, group, InboxReadState}`. Two deviations from the sketch: - the cutoff methods take an explicit `now: Timestamp` so the pure logic stays deterministic and testable, - and `authored_activity` results must pass through `is_git_activity` before display (comments on - non-git roots are matched by the filter). `cargo test -p signed_core` passes (66 tests at the - end of Phase 0; 68 after the two Phase 1 additions). -2. **Phase 1 - store**: `Inbox` child entity, activated by `Backend::sync_inbox` once a signer - exists; both queries, unread count, and NIP-78 load/save to LMDB. **DONE.** See the - implementation notes below. -3. **Phase 2 - screen**: `InboxView` (inbox + activity) and the sidebar nav item. - **DONE.** See the implementation notes below. -4. **Phase 3 - sub-views**: `add_bottom_panel` and `InboxFilterView` for Unread / Archived. - **Done, then reverted.** The sub-views were removed before the repository-grouping redesign; the - notes below are historical. -5. **Phase 4 - click-through**: `open_item` and announcement lookup. **DONE.** See the implementation - notes below. -6. **Phase 5 (optional)**: standalone notifications page, NIP-65 relays, pagination, patch detail - view. - -Each phase compiles and is usable on its own. - -### Phase 1 implementation notes - -Files: `crates/signed_state/{Cargo.toml, src/inbox.rs, src/lib.rs, src/backend.rs, src/refresh.rs}` -and two additions to `crates/signed_core/src/inbox.rs`. - -- `Inbox` is a child entity of `Backend` (`inbox: Entity`), created in `Backend::new` and - reached via `Backend::inbox()`. Nothing in `desktop` is wired and `signed_state::init` gains no - parameters. The dependency is strictly one-way: `Inbox` holds no `Backend` handle. -- `Backend::emit` is the single funnel for every `BackendEvent`. It updates the inbox through - `cx.defer` and then emits to the other subscribers. The defer is required: every emit site runs - inside `Backend::update`, and the inbox handlers read `Backend`, so a synchronous call panics on - a re-entrant entity access. -- The signer lifecycle lives in `Backend::sync_inbox`, called from `create_identity`, `set_signer` - and `logout`. It starts the subscriptions and repo-relay connects, then defers `inbox.activate` - / `inbox.reset`. `SignerChanged` / `SignerRequired` are still emitted for `CheckoutsStore` and - `SidebarPanel`, but no longer drive the inbox. -- `Inbox` mirrors `RepoListStore`: `RefreshGate` coalescing, `cx.background_spawn` for the - database work, plain data applied on the main thread, refresh-on-`NostrUpdate`/`Synced`/`Published`. -- Added `state_loaded: bool`, not in the sketch. Groups are derived from the read state, so a refresh - before the stored state is read would briefly mark everything unread. The first refresh is chained - after `load_state`, and later `refresh` calls are ignored until `state_loaded` is set. -- Account switches are guarded. `activate` and `reset` both replace `self.refresh` with a fresh - `RefreshGate`, dropping any in-flight or pending run of the previous user, and the apply step of - `run_refresh` aborts instead of applying when `Backend::current_user()` no longer matches the - user the query was started for. -- Two additions to `signed_core::inbox` that Phase 1 needs: `InboxReadState::mark_archived` (mirrors - `mark_read`) and `InboxItem::apply_state` (recomputes `unread_ids`/`archived`; `group` now uses it). - Both are covered by tests. -- The thread-root lookup is built by walking every `e`/`E` ancestor transitively (`fetch_notifications`) - rather than a single hop, because a patch series chains through parent patches. Only the notification - events are grouped; ancestors are used solely as the lookup, so a root authored by someone else is - not mistaken for a notification. -- The read/archive state event is written to LMDB only (`database().save_event`), signed with a fresh - `Keys::generate()` on each save and never published. Filtering is by `d` tag only, no author, so the - random key is irrelevant across sessions. `d` tag uses `me.to_hex()` rather than `Display`. -- Actions: `mark_read(root)`, `mark_archived(root)`, `mark_all_read()`. Each marks the group, advances - the relevant cutoffs against **all** notification events (matching GitWorkshop's use of `allEvents`), - re-derives the groups locally so the UI updates immediately, then persists in the background. -- The global `Inbox` keeps no derived state. The signing key is generated per save, the current user is read - from `Backend::current_user()` where needed, and the relays of the user's own repositories are - queried from `RepoListStore` in `Backend::sync_inbox` rather than cached. There is no prune logic - either: the newest state event is selected by `created_at`. -- `Inbox::activate` / `Inbox::reset` are `pub(crate)`; the former `subscribe_remote` and - `connect_own_repo_relays` methods were deleted once their work moved into `Backend::sync_inbox`. -- `cargo test -p signed_core` passes (68 tests), `cargo test -p signed_state` passes (24 tests); - `cargo clippy -p signed_state --all-targets` is clean; `cargo check --workspace` succeeds. - -### Phase 2 implementation notes - -Files: `crates/workspace/src/views/{inbox.rs, mod.rs, sidebar/mod.rs}`. No store changes. - -- `InboxView` is a plain center panel like `RepoListView`; the sidebar holds a - `WeakEntity` so there is no cycle. Re-rendering relies on GPUI's render-time entity - tracking rather than explicit observations. (Phase 2 introduced an `Entity` here; it - was later folded into the panel - see the store-merge note below.) -- The layout is a column of two flexible bordered cards (`gap_4`, `p_4`, each `flex_1`/`min_h_0`), - inbox over activity. Each card is a rounded `v_flex` with a header bar (`section`) and a - `gpui::list` body. There is no **My repositories** column: the sidebar already lists the user's - repositories, so the panel is a single column. -- Notification rows read the newest event of each group for the actor, subject and time, and the - root's kind for the icon. The repo name is resolved from `item.address` through a linear scan of - `RepoListStore::announcements` (`repo_name`); the list is small and this keeps the store unchanged. -- The **Unread** / **Archived** header buttons are intentionally absent: they need - `add_bottom_panel` / `InboxFilterView`, which are Phase 3. The header is only **Mark all read**, - so the panel is fully usable on its own. -- `kind_icon` / `kind_label` map a `Kind` to a `CustomIconName`/`IconName` and a short noun. The - cover note is compared with `==` rather than matched, since `Kind` cannot appear in a pattern arm. -- Sidebar: `open_inbox` mirrors `open_explore` (return if open, else add a center panel); the inbox - nav item is repointed. The screen is still opened by the nav item, not on app startup, matching the - "idle until signer" rule; auto-opening it as the post-login home is a possible follow-up. -- The **My repositories** column (search `InputState`, **New** button, `open_repo_panel` rows) was - removed after Phase 2 as redundant with the sidebar, along with the panel's `dock_area`, - `open_repo` / `open_create_repo` helpers and the `create_repo_dialog` / `open_repo_panel` imports. - `InboxView::new` now takes only `cx`. `create_repo_dialog` is private again. -- `cargo clippy -p workspace --all-targets` is clean and `cargo check --workspace --all-targets` - succeeds. `cargo test -p signed_core` (68) and `cargo test -p signed_state` (24) still pass. - -### Architecture refactor (after Phase 2) - -Phases 0-2 kept all derivation in the global `Inbox`, so every notification and activity query ran -whether or not the home screen was open, and `Backend::emit` carried a deferred side effect just to -feed it. - -- The global `Inbox` is now thin: `state: InboxReadState`, `state_loaded`, plus the NIP-78 load/save - and the mark actions. -- `Backend::emit` is gone. All `BackendEvent`s are emitted with `cx.emit` again, and `sync_inbox` - updates the inbox synchronously, passing the client in so nothing reads `Backend` mid-update. -- The panel became the client-side owner of the derivation, initially through a panel-scoped - `Entity`. -- `signed_core` is unchanged. - -### Store merged into the panel (after Phase 2) - -The `InboxStore` entity was then folded into `InboxView`, since the panel was its only consumer. - -- `InboxView` holds `notifications`, `activity`, `unread_count`, `state`, `state_loaded` and - `RefreshGate` as fields, and the store's methods (`sync_state`, `handle_backend_event`, - `refresh`/`run_refresh`, `regroup`, `publish_unread_count`, `clear`, the mark actions) became panel - methods. The two subscriptions call them directly, with no `update` indirection. -- The database work stayed in `signed_state` as `pub async fn query_inbox(...)`; `RefreshGate` and - `RefreshRequest` are re-exported. The UI crate never queries LMDB directly. -- `mark_read`, `mark_archived` and their `group_events` helper carry a scoped `#[allow(dead_code)]` - until the Phase 3 sub-views wire them up. -- `cargo test -p signed_core` (68), `cargo test -p signed_state` (24) and `cargo test -p workspace` - (7) pass; clippy and `cargo check --workspace --all-targets` are clean. - -Trade-off: the unread count is derived by the panel, so it is only current while the panel is open. -(`publish_unread_count` fed a sidebar badge at the time; both were removed later - see "Sidebar -badge removed" below.) - -### Phase 3 implementation notes - -> Historical: the Unread/Archived sub-views below were later removed; the panel is now a single -> repository-grouped list. Kept for the `add_bottom_panel` / sub-view rationale. - -Files: `crates/dock/src/lib.rs` and `crates/workspace/src/views/{inbox.rs, sidebar/mod.rs}`. No -store changes. - -- `add_bottom_panel` sits next to `add_center_panel` and wraps - `DockArea::add_panel_view(panel, DockPlacement::Bottom, None, ...)`. A new bottom dock starts open, - and the workspace's existing `DockEvent::LayoutChanged` subscription removes an emptied bottom dock, - so a closed sub-view leaves no strip behind. -- `InboxFilterView` is private to `views/inbox.rs`. It holds an `Entity` (strong; the - panel keeps only the weak `filter_view` back, so there is no cycle), the mode, and its own - `ListState`. There is no subscription: it reads the inbox entity during render, which is enough for - GPUI to invalidate the window when the inbox notifies. -- `InboxFilter` is a private two-variant enum with `label()` and `matches(&InboxItem)`. The tab title - comes from `Panel::title`, so switching modes through `set_mode` retitles the same tab instead of - opening a second one. -- `InboxView` regained a `dock_area: WeakEntity` (removed with the My-repositories column) - and takes it in `new`. `open_filter` reuses the existing panel, focuses it, and reopens the bottom - dock when it is collapsed; otherwise it creates and adds the panel. `InboxView::new` is now called - as `InboxView::new(self.dock_area.clone(), cx)` from `SidebarPanel::open_inbox`. -- The three `#[allow(dead_code)]` markers on `mark_read`, `mark_archived` and `group_events` are gone: - Unread rows call `mark_read` on click and `mark_archived` from a trailing ghost icon button - (`Button` + `IconName::FolderClosed`, tooltip "Archive"). The button calls `cx.stop_propagation()` - so it does not also trigger the row's mark-read click. Archived rows are display-only; the read - state has no un-archive operation. -- `notification_row` takes an id `prefix` and returns `Stateful
` rather than `AnyElement`, so - callers can attach a click handler and a trailing action. The inbox list passes `"inbox-row"` and - the sub-view `"inbox-filter-row"`, because the two lists render in the same window and would - otherwise collide on `(str, ix)` ids. -- `cargo clippy -p workspace -p dock --all-targets` is clean, `cargo check --workspace --all-targets` - succeeds, and `cargo test -p signed_core -p signed_state -p workspace` passes (68 / 24 / 7). - -### Phase 4 implementation notes - -Files: `crates/workspace/src/views/{inbox.rs, mod.rs, repo_detail/mod.rs}`. No store changes. - -- `RepoItem { Issue(EventId), PullRequest(EventId), Patch }` and `pub(crate) fn open_repo_item` live - in `repo_detail/mod.rs`, next to `open_repo_panel`. `open_repo_item` builds its own `RepoStore` from - the announcement (private `repo_store` helper), so only the item panel is docked. -- It is a free function, not `RepoDetailView::open_item`: the detail constructors take a `Window`, and - a synchronous `&mut App` + `&mut Window` pair is not a `VisualContext`, so `Entity::update_in` is - not available. `InboxView` already has the window in the list's `on_click`, so it drives the free - function directly. The plan's original `detail.update_in(window, cx, ...)` sketch could not compile. -- `InboxView::open_item` is also a free function (it needs nothing but `dock_area`, which it captures - from the panel) because the `gpui::list` item closure only receives `&mut App`. It resolves - `item.address` through `RepoListStore`, returns silently when the repository is unknown, maps the - root kind to a `RepoItem`, and calls `open_repo_item`. -- Fixed: the first version opened `RepoDetailView` to borrow its store (`RepoDetailView::store()`), - which docked the repository panel alongside the item panel and showed its `not found` load error. - `open_repo_item` now builds the `RepoStore` itself and `RepoDetailView::store()` is gone. -- Only the notification rows are clickable. Activity rows are display-only. The Phase 3 mark-read / - archive row behaviour is gone with the sub-views. -- `RepoItem::Patch` is a unit variant because the id would be unused: patches have no detail panel, so - `open_repo_item` returns before doing anything and nothing is docked. -- `cargo clippy -p workspace --all-targets` is clean, `cargo check --workspace --all-targets` succeeds, - and `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). - -### Repository grouping redesign (after Phase 4) - -Files: `crates/workspace/src/views/inbox.rs`. No store, no `signed_core` changes. - -The two-card layout (notifications over activity) was replaced by a single repository-grouped list. - -- The panel now derives `sections: Vec` and a flattened `rows: Vec` in - `rebuild`, called from `run_refresh` and `regroup`. Both are stored as `Arc`s and cloned into the - `gpui::list` closure, which indexes `notifications` / `activity` - no deep copies per frame and no - data duplicated between the section list and the source lists. -- `InboxSection` groups a repository's non-archived notification groups and the user's own activity, - newest first; sections are ordered by their newest entry. `InboxEntry` holds indices into the - panel's lists; `InboxRow::Repo` / `InboxRow::Entry` / `InboxRow::Empty` is the flattened shape the - list renders. -- All of the user's own repositories are seeded as sections from `RepoListStore::announcements_of`, - so an owned repository with nothing to show gets an empty section ("No activity yet.") and sorts - after the sections with activity. The panel observes `RepoListStore` to rebuild when the user's - repositories load or change. -- Activity is matched to a repository through a `GitRepoAnnouncement` `a` tag (`repo_address`). - Items without an address share the "Other repository" section. -- `notification_row` / `activity_row` no longer render the repository name - the section header does. - That also drops one `RepoListStore` scan per row. -- The single card has one `ListState`; the old `notifications_list` / `activity_list` and the - `render_inbox_panel` / `render_activity_panel` / `section` helpers are gone. `notification_row` still - takes an id prefix so rows stay unique within the list. -- `cargo clippy -p workspace --all-targets` is clean and `cargo test -p signed_core -p signed_state - -p workspace -p dock` passes (68 / 24 / 7 / 1). - -### Sidebar badge removed (after the repository grouping redesign) - -Files: `crates/signed_core/src/filters.rs`, `crates/signed_state/src/{inbox.rs,backend.rs}`, -`crates/workspace/src/views/{inbox.rs,sidebar/mod.rs}`. - -An intermediate change made the sidebar badge live by moving the unread count into the global -`Inbox` (a `refresh_unread_count` driven by `Backend`). That was then reverted along with the badge -itself, so the global is thin again. - -- The sidebar nav item no longer renders a `CountBadge`; `SidebarPanel` lost its `unread` field and - its observe of the global `Inbox`. -- The global `Inbox` no longer stores an `unread_count` and has no `set_unread_count` / - `refresh_unread_count`. `Backend` has no `refresh_inbox_unread` and no per-batch or per-sync count - refresh. `filters::affects_inbox` and the `query_inbox` helper split were reverted with it. -- `InboxView` keeps its local `unread_count` for its header badge and the per-section `unread` for - the repository headers; `publish_unread_count` stays deleted. -- Consequence: the unread count is only current while the panel is open, and there is no unread - indication anywhere else in the app. -- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean, - `cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and - `cargo test -p signed_core -p signed_state -p workspace -p dock` passes (68 / 24 / 7 / 1). - -### Threads merged: notifications + activity (after the sidebar badge removal) - -Files: `crates/signed_core/src/inbox.rs`, `crates/signed_state/src/inbox.rs`, -`crates/workspace/src/views/inbox.rs`. - -Notifications and own activity were two separate row kinds that could describe the same thread. They -are now one item per thread: the notifications directed at the user and the user's own events in that -thread live in the same `InboxItem`. A row shows the thread root's title and up to five of the -thread's most recent events: - -``` -[icon] Add retry logic (unread dot) - [avatar] You opened an issue · 3d - [avatar] alice commented · 2d -``` - -- `InboxItem` gained `root_event: Option` and `own_events: Vec`. `events` keeps only the - notifications (others' events); `own_events` holds the user's own. `unread_ids`/`archived` are - derived from `events` alone, so own activity is never unread and a thread with only own events is - never archived (`apply_state` guards the empty case). -- New methods on `InboxItem`: `title()` (root event's subject, falling back to the newest event), - `kind()` (root kind, same fallback), and `timeline(limit)` (thread events deduplicated by id, - oldest first, always keeping the root event and filling the remaining slots with the most recent - others). -- `group` now takes both `events` (notifications) and `own` (the user's activity) and merges them on - the resolved root. Own events resolve through the same `notification_root`; an unresolved own event - becomes its own root. `query_inbox` returns `(Vec, usize)` - the separate activity list - is gone, and `by_id` is extended with the own events so a comment of ours resolves to its thread. -- The panel holds `threads: Arc>` instead of `notifications` + `activity`. The - `InboxEntry` enum, `entry_time`, `repo_address`, `related_activity`, `notification_row`, - `activity_row` and `kind_label` are gone. `thread_row` replaces both row kinds and is clickable like - the old notification row; `group_sections` now just buckets threads by `item.address`. -- `sub_activity_line` is unchanged and still renders `[avatar] [name] [phrase] · [ago]`, with `You` - for the signed-in user and `activity_phrase(kind)` for the verb. Rows are variable height - (`py_2`), which `gpui::list` auto-measures. -- Thread rows in a section are drawn as one stack: `render_entry` passes `first`/`last` within the - section (`entry_ix == 0` / `entry_ix + 1 == section.entries.len()`), and `thread_row` rounds the - outer edges (`rounded_t` on the first, `rounded_b` on the last, theme radius) and draws a - `border_b_1` divider on every row but the last. -- Trade-off: the row title is the thread root's, not the newest event's, so a comment thread no longer - previews the comment text. That is the point of the merge - the row identifies the thread. -- `cargo clippy -p signed_core -p signed_state -p workspace --all-targets` is clean, - `cargo check -p signed_core -p signed_state -p workspace --all-targets` succeeds, and - `cargo test -p signed_core -p signed_state -p workspace` passes (69 / 24 / 7). - -## 8. Validation - -- `cargo test -p signed_core` (69 tests): root resolution, grouping, merging, read-state cutoff, serde - round-trip. -- `cargo test -p signed_state` (24 tests): the `Inbox` / `query_inbox` paths that do not need GPUI - (state round-trip, grouping helpers). -- `cargo test -p workspace` (7 tests): repository-detail helpers. -- `cargo clippy -p signed_state --all-targets`, `cargo clippy -p workspace --all-targets` and - `cargo check --workspace --all-targets` after each phase. -- Manual: log in with a repo-owning identity; open the inbox from the sidebar and confirm the panel - populates from another identity's issue/comment, the activity list shows your own items, and that no - kind-30078 event is broadcast (watch the relays / `Published` events). Restart to confirm the read - state is read back from LMDB. Confirm the sidebar has no unread badge. - -## 9. SDK APIs used (verified in the pinned `5c669a4` checkout) - -- `Kind::{Comment, GitIssue, GitPullRequest, GitPatch, GitPullRequestUpdate,` - `GitStatusOpen/Applied/Closed/Draft, ApplicationSpecificData, EventDeletion, RequestToVanish}` -- `Filter::{kind, kinds, pubkey, pubkeys, custom_tags, limit, since, events, coordinate, identifier}` - - Non-obvious: `Filter::pubkey`/`pubkeys` set the lowercase **`p` tag**, not `authors`. Use - `Filter::author`/`authors` for authorship. The `notifications` filter relies on this. -- `SingleLetterTag::{LOWERCASE_P, LOWERCASE_E, UPPERCASE_P, UPPERCASE_K, UPPERCASE_E}` -- `nostr::nips::nip22::{extract_root, extract_parent, CommentTarget}`: NIP-22 root/parent pointers -- `Tags::{event_ids, public_keys, coordinates, identifier, hashtags}` iterators -- `Client::{database, subscribe, sync, notifications, send_event, add_relay}`; - `NostrDatabase::{save_event, query}`; `NostrLmdb`, `NostrGossipMemory` -- `EventBuilder::{new, tags, finalize}`, `Tag::identifier`, `Keys::generate` -- `Timestamp`, `EventId` (hex serde), `PublicKey`, `Coordinate` -- Fetch paths converge on the same notification: `client.subscribe(...)` and negentropy - `client.sync(...)` both persist received events to LMDB and surface them as - `ClientNotification::Event`, which `Backend`'s pump batches into `BackendEvent::NostrUpdate`. - This is why the query right after a fetch is a cache read, not a race. diff --git a/docs/repo-state-plan.md b/docs/repo-state-plan.md deleted file mode 100644 index e35db14..0000000 --- a/docs/repo-state-plan.md +++ /dev/null @@ -1,372 +0,0 @@ -# Repository state and panel flow plan - -Status: phases 1-3 implemented (2026-09-13) - -Builds on `docs/backend-rearchitecture.md`, especially §7 (notify audit), -§11 (split independently-observed state), §12 (one debounce at the source) -and §13 (merge the repo listing files, not the entities). - -Scope: `signed_state::{repo, repos}`, `workspace::views::{repo, issues, -pull_requests, inbox}`. - -## Goal - -1. Open an issue or PR directly, from any surface (inbox entry, notification, - future deep link), without walking Explore -> repository panel first. -2. One per-repository entity that spans both identities: the local git - repository and the NIP-34 announcement, instead of today's separate - `LocalReposStore` entry / `Option` + `Option>` - view state. - -## What the code does today (verified against the current tree) - -### 1. Item panels are cached and never observe their store - -The dock renders the active panel through `panel.cached(...)` -(`crates/dock/src/tab_panel.rs:777-795`), so a panel re-renders only when its -own entity notifies. Cross-entity reads are not reactive. - -`RepoDetailView` observes its store (`views/repo/store.rs:35-43`), but the -panels that read the same store do not: - -- `IssueDetailView` (`views/issues/detail.rs:79-85`) renders - `placeholder("Issue not found", cx)` when the issue is absent, with no - observer. When a directly opened store's first pass lands later and notifies, - nothing re-renders the panel: it stays on "Issue not found". -- `PullRequestDetailView::load` runs once from `cx.defer_in` - (`views/pull_requests/detail.rs:88-91`) and caches - `error = "Pull request not found"` when the store is empty - (`:124-133`). It can never recover. -- `IssuesView` / `PullRequestsView` memoize rows behind - `(store.version(), filter)` (`views/issues/mod.rs:324-351`, - `views/pull_requests/mod.rs:334-369`) but nothing re-renders them when the - version changes, so an open list does not see new events either. -- `NewPullRequestView` reads `store.head` / the announcement in `new`, so a - late first pass does not reach its defaults. - -The normal flow hides this because the repository panel is opened first: by -the time the issues list or an item panel is created, the store has already -applied its first pass. Opening an item directly is the case where the store -is still empty at construction. - -### 2. The initial pass waits on a timer the backend already provides - -`RepoStore::new` defers `refresh` (`signed_state/src/repo.rs:130-141`), and -`refresh` always waits `REFRESH_DEBOUNCE` = 300 ms (`repo.rs:25-26`, -`:230-242`) before the local database query. `RepoListStore` has the same -timer (`repos.rs:120-123`, `:270-281`) plus a separate `refresh_initial` -that skips it (`:257-268`). - -Both stores only refresh on `Backend` events: - -- `NostrUpdate` is already batched by the notification pump with its own - `PUMP_DEBOUNCE` = 200 ms (`backend.rs:37`, `:112-166`). Per - `backend-rearchitecture.md` §12, the per-store timers were to be dropped - once the pump absorbed the bursts. -- `Published` and `Synced` are one-off events; `RefreshGate` already folds - them into an in-flight run. - -The timers are therefore pure added latency for these two stores: ~300 ms -(batched updates) to ~500 ms (pump window + store window) before local data -appears. - -### 3. Opening an item requires a hydrated `Announcement` - -`repo_store(announcement)` (`views/repo/actions.rs:216-219`) and the inbox's -lookup in `RepoListStore` (`views/inbox.rs:384-404`) need the announcement in -hand and silently no-op when it is missing (`RepoListStore` not synced yet, -deleted repo, inbox section without a matching list entry). The store itself -can load the announcement from the local database; `run_refresh` already -queries `filters::announcement(&addr)` (`repo.rs:251-261`). - -### 4. Local and announced repositories have no shared identity - -- `LocalReposStore` holds scan paths; `RepoListStore` holds announcements; - `CheckoutsStore` joins them (`checkouts.rs:211-213`, `:569-609`). -- `RepoDetailView` encodes both modes in three option fields: - `initial: Option`, `store: Option>`, - `local_path: Option` (`views/repo/mod.rs:74-85`), with - `apply_announcement` moving between them (`views/repo/store.rs:13-31`). - The invalid combinations and the `initial` fallback in `announcement()` - (`views/repo/mod.rs:322-331`) are the cost of the missing per-repo entity. - -## Design - -### 1. Panels observe, derive into local state, notify on change - -Every panel that reads a store keeps a local snapshot of exactly the slice it -renders, updates it in an observer, and calls `cx.notify()` only when the -slice changed. This is the pattern `RepoDetailView::refresh_statuses` -(`views/repo/store.rs:88-101`) and `SidebarPanel::refresh` already use; the -item panels are missing the observer half. - -| View | Observed entity | Local snapshot | -|---|---|---| -| `IssueDetailView` | `Entity` | root issue, status, comments | -| `PullRequestDetailView` | `Entity` | root PR, description, tip, base, clone urls | -| `IssuesView` | `Entity` | `visible_issues`, `counts`, `item_sizes`, `cache_key` | -| `PullRequestsView` | `Entity` | `visible_prs`, `counts`, `item_sizes`, `cache_key` | -| `NewPullRequestView` | `Entity` | announced head, default base | - -Sketch, matching the existing idiom: - -```rust -// new() -let subscription = cx.observe(&store, |this, store, cx| this.sync(store, cx)); - -// Copy the slice; notify only when it changed. -fn sync(&mut self, store: &Entity, cx: &mut Context) { - let store = store.read(cx); - let issue = store.issues.iter().find(|issue| issue.id == self.issue_id).cloned(); - let comments: Vec = store.comments_of(&self.issue_id).cloned().collect(); - let status = issue.as_ref().map(|issue| store.status_of(issue)); - - if self.issue != issue || self.comments != comments || self.status != status { - self.issue = issue; - self.comments = comments; - self.status = status; - cx.notify(); - } -} -``` - -`PullRequestDetailView` needs its one-shot `load` split in two: - -- `bind` finds the root PR in the store and snapshots description, tip, base - and clone urls. Re-runnable on every store version change. -- `load_diff` does the async patch/git work. Runs once bound, and again when - the bound tip changes (a PR update arriving late). - -### 2. One debounce, at the backend pump - -Delete the `REFRESH_DEBOUNCE` timers from `RepoStore` and `RepoListStore`. -Their triggers all come from `Backend`; the pump batches relay traffic and -`RefreshGate` folds one-off events into an in-flight run. Keep `RefreshGate` -exactly as is, used without a timer: - -```rust -pub fn refresh(&mut self, cx: &mut Context) { - if self.refresh.request() != RefreshRequest::Schedule { - return; - } - self.run_refresh(cx); -} -``` - -`RepoListStore::refresh_initial` collapses into `refresh`; the -`new`-time `cx.defer` call becomes the initial load, with no timer. - -Add `pub loaded: bool` to `RepoStore`, set when the first pass applies. It -separates "no data yet" from "genuinely empty": panels render a loading state -while `!loaded`, and "not found" only after `loaded`. - -`CheckoutsStore` and `InboxView` keep their timers in this plan. Their -refresh request sources are not only the backend pump (checkout requests, -settings and scan observations), so the same argument does not hold -unchanged; revisit separately if measurements show the timers redundant. - -### 3. `RepoStore` is the one per-repository entity - -Keep the name `RepoStore` (`Repository` collides with `gix::Repository`, -already imported in `views/repo/loading.rs`). Shape: - -```rust -pub struct RepoStore { - /// NIP-34 address. `None` while the repository is local-only. - addr: Option, - /// Latest announcement. Seeded from the open-time hint, replaced by the - /// database's latest on the first pass. `None` while local-only. - pub announcement: Option, - /// Local working copy: the scan path for a local repo, an associated - /// checkout for an announced one. A snapshot; `CheckoutsStore` stays the - /// authority for the full list of checkouts. - pub path: Option, - /// The first local pass has been applied. - pub loaded: bool, - // issues, patches, pull_requests, comments, status_by_root, head, flags... - _subscription: Option, -} -``` - -`addr` is required in addition to `announcement`: `(announcement: None, -path: Some(_))` is otherwise ambiguous between "local-only" and "announced, -first pass pending", and the store needs the address to run its query. - -Constructors and the state transition: - -```rust -impl RepoStore { - /// Announced repository. Resolves `path` from `CheckoutsStore` if the - /// user already has a checkout. - pub fn new(addr: RepoAddr, hint: Option, cx: &mut Context) -> Self; - - /// Local repository discovered by the scan, not announced yet. - pub fn new_local(path: PathBuf, cx: &mut Context) -> Self; - - /// Local -> NIP-34 in place. Keeps `path`, so the panel keeps its worktree. - pub fn announce(&mut self, announcement: Announcement, cx: &mut Context); - - pub fn addr(&self) -> Option<&RepoAddr>; -} -``` - -- `new`: subscribes to `Backend`, seeds `announcement` from the hint, defers - remote subscribe/connect, runs the local pass immediately. -- `new_local`: no `Backend` subscription, `loaded = true`, path set. -- `announce`: sets `addr`, `announcement`, keeps `path`; installs the - `Backend` subscription, connects the announced relays and refreshes. Called - from `apply_announcement`, which loses its field surgery. -- Nostr-side actions (`push_repository`, `clone_to_folder`, - `open_issue`/`open_pull_request`, status changes) already have - `action_error("Repository announcement is not loaded yet")` - (`repo.rs:1368-1378`); they now also handle `addr == None` the same way. -- `announce` should also `CheckoutsStore::record(path, addr)` for the scan - path, so the association exists immediately instead of waiting for the - origin/EUC match in `resolve_associations`. Optional, verify behavior. - -`RepoDetailView` then holds `store: Entity` plus explorer state -only. `initial` and `local_path` are deleted; `announcement()` reads the -store; local-mode checks become `store.read(cx).addr().is_none()`; -`load_repo` opens `path` when not announced, and keeps today's cache-mirror -flow for announced repositories. - -### 4. Opening a repository or item needs only a `RepoAddr` - -- `repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx) -> - Entity`. -- `open_repo_item(dock_area, addr: &RepoAddr, item, window, cx)`. -- Inbox passes its already-parsed `address` (`signed_core::InboxItem.address`, - the root event's `a` tag) and drops the `RepoListStore` lookup. -- `open_upstream` no longer polls the list; it opens the panel by address and - the store fills it in (Phase 2). -- `RepoItem::Patch` behavior is unchanged. - -## Phases - -### Phase 1 - make item panels react to the store (fixes the reported flow) - -Status: implemented, except step 6. - -1. `signed_state/src/repo.rs`: `REFRESH_DEBOUNCE` and the timer spawn are - gone; `refresh` runs immediately. `loaded` was added and is set in the - apply closure. A first pass notifies even when it found nothing, so views - leave their loading state and show the empty result. -2. `signed_state/src/repos.rs`: same timer removal; `refresh_initial` folded - into `refresh`. -3. `views/issues/detail.rs`: observes the store and re-renders; loading - placeholder while `!store.loaded()`. -4. `views/pull_requests/detail.rs`: observes the store; `sync`/`sync_missing` - bind the root and `load_diff` does the async work, keyed to a `PrBinding` - and guarded by a generation so late results are discarded. Loading vs not - found is decided by `store.loaded()`. -5. `views/issues/mod.rs`, `views/pull_requests/mod.rs`: observe the store; - `rebuild` recomputes rows/counts/item sizes and `sync` notifies on change. - Filter buttons call `rebuild` before notifying. -6. `views/pull_requests/new.rs`: not done. Its store-derived inputs are - defaults for the compare/base selectors; re-applying them on a late store - pass would clobber a selection the user already made. Left for a follow-up - once the defaults can be derived without resetting the selectors. - -### Phase 2 - entry points by identity - -Status: implemented. - -1. `views/repo/actions.rs`: `repo_store(addr, hint, cx)` seeds the store's - relays from an optional hint but needs nothing else; the store loads the - announcement itself. `open_repo_item(addr, hint, item, window, cx)` takes - the address, not a hydrated announcement. -2. `views/inbox.rs`: `open` passes its already-parsed `address` and the - `RepoListStore` lookup with its silent early-return is gone. An inbox row - opens whether or not the repository is in the list yet. -3. `open_repo_panel` and `RepoDetailView::new` take an address plus an optional - hint, so a repository panel opens from a `RepoAddr` alone. This pulls the - address-based constructor forward from Phase 3 step 2. -4. `RepoDetailView::attach_store` starts the explorer from the store's first - announcement when the panel was opened by address alone, so a panel opened - by address fills in instead of waiting for the caller to have the - announcement. Phase 3 moves this onto the single store observer, gated by - `repo_started`. -5. `open_upstream`: the 60 x 250 ms poll and the `pending_upstream` field are - gone. It opens the panel by address; the store's `subscribe_remote` fetches - the announcement from the bootstrap relays and step 4 loads the explorer. - -### Phase 3 - one entity for local and NIP-34 - -Status: implemented. - -1. `signed_state/src/repo.rs`: `addr: Option`, - `path: Option`, `announcement: Option`, - `_subscription: Option`. `new(addr, hint, cx)` seeds the - announcement and relays from the hint; `new_local(path)`; `announce` - switches a local store to NIP-34 in place, keeping `path`. `addr()` returns - `Option<&RepoAddr>`; `refresh`/`connect_announced_relays`/`subscribe_remote` - no-op without an address. Nostr-side actions guard with `not_announced` - (unit actions) or `action_error` (task actions). -2. `views/repo/mod.rs`: one `store: Entity` field. `initial` and - `local_path` are deleted; `new_local` builds a local store. The store - observer starts the explorer once an announcement lands, tracked by - `repo_started`. `display_name`, `load_repo` and `open_init_dialog` derive - their mode from `addr()`/`path` instead of the removed fields. -3. `views/repo/store.rs`: the store is observed from construction for both - modes; `apply_announcement` calls `store.announce` on the existing entity. - `refresh_ready_statuses` and `refresh_statuses` return early when `addr()` - is `None`. -4. `views/repo/{actions,header,banners,loading}.rs`: the - `Option>` guards are gone. Announced-only entry points - (issue/PR lists, new PR, send patch) guard on `addr()`; `NewPullRequestView` - and `PullRequestDetailView` thread the address option through their - prefill/binding paths. -5. `LocalReposStore` stays as the scan index; `CheckoutsStore` stays the - association authority. - -Deviations from the sketch above: - -- `path` is set only by `new_local` and kept by `announce`. `new` does not - resolve an associated checkout: the explorer still mirrors the cache for - announced repositories, so a stored checkout path would be dead weight. - The field is the seam for the open question below. -- `new_local` takes no `Context`: a local store has nothing to subscribe to and - no first pass to defer. -- `new` keeps the open-time hint until the first pass has confirmed what the - database holds, so a panel opened from a hint renders before the query lands - and still adopts a later deletion. - -### Phase 4 - deferred, only if duplicate stores become a problem - -One store per address via `HashMap>` inside -`RepoListStore`, so an item panel opened while the repository panel is open -shares the same store and its subscriptions. Not needed for correctness once -Phase 1 lands; each open then loads from the local database immediately. - -## Non-goals - -- No per-store debounce; the pump is the one debounce (§12). `RepoStore` and - `RepoListStore` must not grow timers again. -- No new global store, and no merging `LocalReposStore`, `RepoListStore` - and `CheckoutsStore` into one entity (§13). -- Views never query the database directly; `RepoStore` stays the single - projection so status/comment derivation is not duplicated. -- Explorer state (tree, refs, commits, scroll) stays in `RepoDetailView`. -- No `Repository` rename. - -## Validation - -- `cargo check -p signed_state -p workspace`, then clippy. -- Manual scenarios: - 1. Fresh database, never open Explore: click an issue in the inbox. Panel - shows a loading state, then the issue with comments and status. - 2. Same for a PR, including the patch diff loading once the root binds. - 3. Open the issues list, then receive a new issue (second client or - publish); the row appears without reopening. - 4. Local repo: open from the sidebar, Init, panel keeps the worktree and - gains the nostr header; reopening from Explore shows the same data. - 5. Existing flows: Explore list, ready-to-contribute and ready-to-push - banners, new issue/PR dialogs. - -## Open questions - -1. Should an announced repository with an associated checkout browse that - checkout instead of the cache mirror? Today the panel always mirrors. - `RepoStore.path` makes this a one-line decision later. -2. Store sharing (Phase 4): worth it only if duplicate subscriptions show up - in practice. -- 2.54.0 From 33e9429cd04e39bd8ce7f6758570ebc2aeb91ede Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 15:17:51 +0700 Subject: [PATCH 08/12] update --- crates/signed_ui/src/lib.rs | 2 + crates/signed_ui/src/ref_selector.rs | 41 + crates/workspace/src/views/commit_diff/mod.rs | 185 ++++- crates/workspace/src/views/discussion.rs | 217 ++++++ crates/workspace/src/views/issues/detail.rs | 2 +- crates/workspace/src/views/mod.rs | 2 + .../src/views/pull_requests/detail.rs | 2 +- .../workspace/src/views/pull_requests/new.rs | 5 +- crates/workspace/src/views/repo/files.rs | 65 +- crates/workspace/src/views/repo/header.rs | 78 +- crates/workspace/src/views/repo/helpers.rs | 721 ------------------ crates/workspace/src/views/repo/history.rs | 3 +- crates/workspace/src/views/repo/loading.rs | 4 +- crates/workspace/src/views/repo/mod.rs | 1 - crates/workspace/src/views/repo/refs.rs | 2 +- crates/workspace/src/views/tree.rs | 153 ++++ docs/repo-view-refactor-plan.md | 291 +++++++ 17 files changed, 1032 insertions(+), 742 deletions(-) create mode 100644 crates/signed_ui/src/ref_selector.rs create mode 100644 crates/workspace/src/views/discussion.rs delete mode 100644 crates/workspace/src/views/repo/helpers.rs create mode 100644 crates/workspace/src/views/tree.rs create mode 100644 docs/repo-view-refactor-plan.md diff --git a/crates/signed_ui/src/lib.rs b/crates/signed_ui/src/lib.rs index 7f63693..7c63981 100644 --- a/crates/signed_ui/src/lib.rs +++ b/crates/signed_ui/src/lib.rs @@ -2,6 +2,7 @@ mod dropdown_button; mod nav_item; mod pixel_avatar; mod placeholder; +mod ref_selector; mod segment_button; mod setting; mod status_badge; @@ -17,6 +18,7 @@ pub use dropdown_button::DropdownButton; pub use nav_item::NavItem; pub use pixel_avatar::PixelAvatar; pub use placeholder::placeholder; +pub use ref_selector::ref_selector_trigger; pub use segment_button::{CountBadge, SegmentButton}; pub use setting::{SelectOption, setting_block, setting_row}; pub use status_badge::status_badge; diff --git a/crates/signed_ui/src/ref_selector.rs b/crates/signed_ui/src/ref_selector.rs new file mode 100644 index 0000000..1b508d0 --- /dev/null +++ b/crates/signed_ui/src/ref_selector.rs @@ -0,0 +1,41 @@ +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{AnyElement, App, SharedString, div}; +use gpui_component::combobox::{Caret, ComboboxTriggerContext}; +use gpui_component::searchable_list::SearchableVec; +use gpui_component::{ActiveTheme, Icon, Sizable, h_flex}; + +/// The kind icon, the selection or placeholder, and the caret. `Combobox` +/// replaces its default trigger entirely, the only way to show an icon inside it. +pub fn ref_selector_trigger( + ctx: &ComboboxTriggerContext>, + icon: CustomIconName, + cx: &App, +) -> AnyElement { + let muted = cx.theme().muted_foreground; + + h_flex() + .w_full() + .min_w_0() + .gap_1() + .items_center() + .child(Icon::new(icon).small().flex_shrink_0()) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .when(ctx.selection().is_empty(), |this| this.text_color(muted)) + .child( + ctx.selection() + .first() + .map(|(_, item)| item.clone()) + .or_else(|| ctx.placeholder().cloned()) + .unwrap_or_default(), + ), + ) + .child(Caret::new(ctx.size()).text_color(muted)) + .into_any_element() +} diff --git a/crates/workspace/src/views/commit_diff/mod.rs b/crates/workspace/src/views/commit_diff/mod.rs index 6921166..98ad225 100644 --- a/crates/workspace/src/views/commit_diff/mod.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -13,17 +13,15 @@ use gpui_component::resizable::{resizable_panel, v_resizable}; use gpui_component::scroll::{ScrollableElement, Scrollbar}; use gpui_component::spinner::Spinner; use gpui_component::tag::Tag; -use gpui_component::tree::{TreeEntry, TreeState, tree}; +use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; -use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff}; +use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; -use crate::views::repo::helpers::{ - DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items, -}; +use crate::views::tree::{build_tree_items, tree_items}; const TREE_WIDTH: f32 = 260.; @@ -510,3 +508,180 @@ impl Render for CommitDiffView { .child(resizable_panel().child(body)) } } + +const GUTTER_WIDTH: f32 = 44.; +const DIFF_ROW_HEIGHT: f32 = 20.; + +#[derive(Clone, Copy)] +enum DiffRow { + Hunk { + old_start: u32, + old_lines: u32, + new_start: u32, + new_lines: u32, + }, + Line { + hunk: usize, + line: usize, + }, +} + +fn diff_rows(file: &FileDiff) -> Vec { + let mut rows = Vec::new(); + for (hunk_ix, hunk) in file.hunks.iter().enumerate() { + rows.push(DiffRow::Hunk { + old_start: hunk.old_start, + old_lines: hunk.old_lines, + new_start: hunk.new_start, + new_lines: hunk.new_lines, + }); + rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line { + hunk: hunk_ix, + line, + })); + } + rows +} + +fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { + match row { + DiffRow::Hunk { + old_start, + old_lines, + new_start, + new_lines, + } => div() + .px_2() + .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .bg(cx.theme().muted) + .border_y(px(1.)) + .border_color(cx.theme().border) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!( + "@@ -{},{} +{},{} @@", + old_start, old_lines, new_start, new_lines + ))) + .into_any_element(), + DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx), + } +} + +fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { + let bg = match line.kind { + DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), + DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)), + DiffLineKind::Context => None, + }; + let gutter = cx.theme().muted_foreground; + + // Fixed height and nowrap, the virtual list assumes every row has the same height. + // Long lines are clipped instead of wrapped. + h_flex() + .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .items_center() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .when_some(bg, |this, bg| this.bg(bg)) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.old.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.new.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_color(cx.theme().foreground) + .child(line.text.clone()), + ) + .into_any_element() +} + +fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { + let id = id?; + items.iter().find_map(|item| { + if item.id.as_ref() == id { + Some(item) + } else { + find_item(&item.children, Some(id)) + } + }) +} + +pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.; + +pub(crate) fn commit_row( + ix: usize, + commit: &FileCommit, + on_click: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + h_flex() + .id(ix) + .px_4() + .h(px(COMMIT_ROW_HEIGHT)) + .w_full() + .gap_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .hover(|this| this.bg(cx.theme().list_hover)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .justify_center() + .child( + h_flex() + .gap_2() + .items_center() + .overflow_hidden() + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.id.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.author.clone()) + .child(relative_time_secs(commit.time)), + ), + ) + .on_click(move |_event, window, cx| on_click(window, cx)) + .into_any_element() +} diff --git a/crates/workspace/src/views/discussion.rs b/crates/workspace/src/views/discussion.rs new file mode 100644 index 0000000..8f52a56 --- /dev/null +++ b/crates/workspace/src/views/discussion.rs @@ -0,0 +1,217 @@ +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{AnyElement, App, Entity, SharedString, div, px}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Textarea, TextareaState}; +use gpui_component::tag::Tag; +use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; +use nostr::prelude::{Event, EventId, PublicKey}; +use signed_state::{ProfileStore, RepoStore}; +use signed_ui::UserAvatar; +use utils::relative_time; + +pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] { + &store.issues +} + +pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] { + &store.pull_requests +} + +fn sidebar_title(text: &str, cx: &App) -> AnyElement { + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(text.to_string()) + .into_any_element() +} + +pub(crate) fn sidebar_section( + store: &Entity, + id: EventId, + roots: fn(&RepoStore) -> &[Event], + top_gap: bool, + cx: &App, +) -> AnyElement { + let store = store.read(cx); + let Some(root) = roots(store).iter().find(|event| event.id == id) else { + // The caller bails out when the root is missing. + return div().into_any_element(); + }; + let profile_store = ProfileStore::global(cx); + + // Participants, the root author plus everyone who commented. + let mut participants: Vec = vec![root.pubkey]; + participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); + participants.sort_by_key(PublicKey::to_hex); + participants.dedup(); + + // Labels are NIP-34 `t` hashtag tags on the event. + let labels: Vec = root.tags.hashtags().map(|tag| tag.to_string()).collect(); + + v_flex() + .w(px(240.)) + .h_full() + .flex_none() + .px_4() + .gap_4() + .border_l(px(1.)) + .border_color(cx.theme().sidebar_border) + .child( + v_flex() + .when(top_gap, |this| this.mt_4()) + .gap_2() + .child(sidebar_title("Participants", cx)) + .children(participants.iter().map(|pubkey| { + let profile = profile_store.read(cx).get(pubkey); + let name = profile.name(); + let picture = profile.picture(); + + h_flex() + .gap_1() + .items_center() + .child(UserAvatar::new(name.clone()).picture(picture)) + .child(div().text_sm().truncate().text_ellipsis().child(name)) + .into_any_element() + })), + ) + .child( + v_flex() + .gap_2() + .child(sidebar_title("Labels", cx)) + .map(|this| { + if labels.is_empty() { + this.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("None yet."), + ) + } else { + this.child(h_flex().gap_1().children({ + let mut items = vec![]; + + for label in labels.iter() { + items.push( + Tag::secondary() + .outline() + .xsmall() + .child(SharedString::from(label)), + ); + } + + items + })) + } + }), + ) + .into_any_element() +} + +pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { + let store = store.read(cx); + let comments: Vec<&Event> = store.comments_of(&root).collect(); + let title = SharedString::from(format!("Discussions {}", comments.len())); + + v_flex() + .gap_4() + .child(div().text_xs().font_semibold().child(title)) + .children(comments.iter().map(|comment| { + let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); + let author = profile.name(); + let picture = profile.picture(); + let age = relative_time(comment.created_at); + let content = SharedString::from(comment.content.as_str()); + + v_flex() + .gap_1() + .p_3() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .child( + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .gap_1() + .child(UserAvatar::new(author.clone()).picture(picture)) + .child(author), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("commented"), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(age)), + ), + ) + .child(div().text_sm().child(content)) + })) + .into_any_element() +} + +/// `roots` selects the root's list within the store, issues or pull requests. +pub(crate) fn comment_form( + store: &Entity, + root: EventId, + roots: fn(&RepoStore) -> &[Event], + comment_input: &Entity, + button_id: &'static str, + cx: &App, +) -> AnyElement { + let comment_input = comment_input.clone(); + let store = store.clone(); + + v_flex() + .gap_2() + .child( + Textarea::new(&comment_input) + .h_24() + .text_color(cx.theme().muted_foreground) + .bg(cx.theme().muted), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(CustomIconName::Markdown).small()) + .child("Markdown is supported"), + ) + .child( + Button::new(button_id) + .primary() + .label("Comment") + .tooltip("Post comment") + .on_click(move |_event, window, cx| { + let content = comment_input.read(cx).value().trim().to_string(); + if content.is_empty() { + return; + } + let Some(root) = roots(store.read(cx)) + .iter() + .find(|event| event.id == root) + .cloned() + else { + return; + }; + store.update(cx, |store, cx| { + store.comment(&root, content, cx); + }); + comment_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + }), + ), + ) + .into_any_element() +} diff --git a/crates/workspace/src/views/issues/detail.rs b/crates/workspace/src/views/issues/detail.rs index a714407..ecb78cb 100644 --- a/crates/workspace/src/views/issues/detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -13,7 +13,7 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{UserAvatar, placeholder, status_badge}; use utils::relative_time; -use crate::views::repo::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; +use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section}; pub struct IssueDetailView { focus_handle: FocusHandle, diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index a098cb4..ba027ad 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,5 +1,6 @@ mod commit_diff; mod dialog_state; +pub(crate) mod discussion; mod inbox; mod issues; mod pull_requests; @@ -7,6 +8,7 @@ mod repo; mod repo_list; mod send_patch; pub(crate) mod sidebar; +pub(crate) mod tree; pub use inbox::InboxView; pub use repo::RepoDetailView; diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index 93b8508..392ad41 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -31,7 +31,7 @@ use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; use crate::views::commit_diff::{CommitDiffView, DiffPane}; -use crate::views::repo::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; +use crate::views::discussion::{comment_form, comments_section, pr_roots, sidebar_section}; const ROW_HEIGHT: f32 = 37.; diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 5a5d006..2241dee 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -27,10 +27,9 @@ use signed_git::{ worktree_commit_range_commits, worktree_commit_range_diff, }; use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore}; -use signed_ui::{CountBadge, placeholder}; +use signed_ui::{CountBadge, placeholder, ref_selector_trigger}; -use crate::views::commit_diff::{CommitDiffView, DiffPane}; -use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row, ref_selector_trigger}; +use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row}; pub struct NewPullRequestView { focus_handle: FocusHandle, diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs index 16c8cd4..d9c85d2 100644 --- a/crates/workspace/src/views/repo/files.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -13,7 +13,6 @@ use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; use signed_ui::{placeholder, tree_row}; use super::RepoDetailView; -use crate::views::repo::helpers::{code_language, is_markdown_path}; const TREE_WIDTH: f32 = 240.; pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; @@ -477,3 +476,67 @@ impl RepoDetailView { } } } + +/// The markdown fence language for a file path, or `None` for plain text. +fn code_language(path: &str) -> Option<&'static str> { + let name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + + // Some common files are recognized by name rather than extension. + match name { + "Makefile" | "makefile" => return Some("make"), + "CMakeLists.txt" => return Some("cmake"), + _ => {} + } + + let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "rs" => "rust", + "toml" => "toml", + "json" | "jsonc" => "json", + "py" => "python", + "js" | "mjs" | "cjs" => "javascript", + "ts" | "mts" | "cts" => "typescript", + "tsx" | "jsx" => "tsx", + "go" => "go", + "c" | "h" => "c", + "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp", + "cs" => "csharp", + "java" => "java", + "kt" | "kts" | "ktm" => "kotlin", + "swift" => "swift", + "php" | "phtml" => "php", + "rb" => "ruby", + "sh" | "bash" | "zsh" => "bash", + "yml" | "yaml" => "yaml", + "css" | "scss" | "sass" => "css", + "html" | "htm" => "html", + "lua" => "lua", + "sql" => "sql", + "proto" | "protobuf" => "proto", + "cmake" => "cmake", + "zig" => "zig", + "ex" | "exs" => "elixir", + "graphql" | "gql" => "graphql", + "diff" | "patch" => "diff", + "svelte" => "svelte", + "astro" => "astro", + "scala" => "scala", + _ => return None, + }) +} + +/// Whether a file path has a markdown extension. +fn is_markdown_path(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + matches!( + ext.to_ascii_lowercase().as_str(), + "md" | "markdown" | "mdown" | "mkdn" + ) + }) +} diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs index f30e060..e0560df 100644 --- a/crates/workspace/src/views/repo/header.rs +++ b/crates/workspace/src/views/repo/header.rs @@ -7,20 +7,23 @@ use gpui::{Anchor, AnyElement, ClipboardItem, Context, SharedString, div, px, re use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::combobox::Combobox; -use gpui_component::menu::DropdownMenu; +use gpui_component::menu::{DropdownMenu, PopupMenu}; use gpui_component::{ ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, }; +use nostr::nips::nip19::Nip19Coordinate; use nostr::prelude::{RelayUrl, ToBech32}; use signed_core::Announcement; use signed_state::{Backend, ProfileStore, RepoListStore}; -use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row}; +use signed_ui::{ + CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate, + ref_selector_trigger, +}; use super::{RepoAction, RepoDetailView}; use crate::views::issues::open_new_issue_dialog; use crate::views::pull_requests::new::open_new_pull_panel; use crate::views::repo::about::open_about_dialog; -use crate::views::repo::helpers::{ShareTargets, ref_selector_trigger}; use crate::views::send_patch::open_send_patch_panel; impl RepoDetailView { @@ -712,3 +715,72 @@ fn fork_row(announcement: &Announcement, cx: &mut Context) -> Op row.into_any_element() }) } + +struct ShareTargets { + /// NIP-19 `naddr1...` of the announcement, with its announced relays. + naddr: String, + /// Hex ID of the announcement event itself. + event_id: String, + /// NIP-34 coordinate `30617::`. + coordinate: String, + /// `https://gitworkshop.dev/` + gitworkshop: String, + /// `https://ditto.pub/` + ditto: String, +} + +impl ShareTargets { + fn from_announcement(announcement: &Announcement) -> Self { + let addr = announcement.addr(); + let coordinate = addr.to_string(); + let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) + .to_bech32() + .expect("a complete coordinate always encodes to naddr"); + + Self { + naddr: naddr.clone(), + event_id: announcement.event_id.to_bech32().unwrap(), + coordinate, + gitworkshop: format!("https://gitworkshop.dev/{naddr}"), + ditto: format!("https://ditto.pub/{naddr}"), + } + } + + fn menu(&self, menu: PopupMenu) -> PopupMenu { + menu.min_w(px(340.)) + .item(menu_copy_row( + "copy-gitworkshop", + "GitWorkshop", + truncate_naddr_link(&self.gitworkshop, 4), + self.gitworkshop.clone(), + )) + .item(menu_copy_row( + "copy-ditto", + "Ditto", + truncate_naddr_link(&self.ditto, 4), + self.ditto.clone(), + )) + .item(menu_copy_row( + "copy-event-id", + "Event ID", + middle_truncate(&self.event_id, 10, 10), + self.event_id.clone(), + )) + .item(menu_copy_row( + "copy-coordinate", + "Coordinate", + middle_truncate(&self.coordinate, 10, 10), + self.coordinate.clone(), + )) + } +} + +fn truncate_naddr_link(url: &str, tail: usize) -> String { + let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { + return url.to_string(); + }; + if url.len() - end <= tail + 3 { + return url.to_string(); + } + format!("{}...{}", &url[..end], &url[url.len() - tail..]) +} diff --git a/crates/workspace/src/views/repo/helpers.rs b/crates/workspace/src/views/repo/helpers.rs deleted file mode 100644 index e29e82a..0000000 --- a/crates/workspace/src/views/repo/helpers.rs +++ /dev/null @@ -1,721 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use assets::CustomIconName; -use gpui::prelude::*; -use gpui::{AnyElement, App, Entity, SharedString, Window, div, px}; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::{Caret, ComboboxTriggerContext}; -use gpui_component::input::{Textarea, TextareaState}; -use gpui_component::menu::PopupMenu; -use gpui_component::searchable_list::SearchableVec; -use gpui_component::tag::Tag; -use gpui_component::tree::TreeItem; -use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; -use nostr::nips::nip19::{Nip19Coordinate, ToBech32}; -use nostr::prelude::{Event, EventId, PublicKey}; -use signed_core::Announcement; -use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileCommit, FileDiff}; -use signed_state::{ProfileStore, RepoStore}; -use signed_ui::{UserAvatar, menu_copy_row, middle_truncate}; -use utils::{relative_time, relative_time_secs}; - -pub(crate) struct TreeItemSeed { - /// Path of the node, relative to the worktree root. - id: String, - label: String, - children: Vec, -} - -pub(crate) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { - fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { - let mut item = TreeItem::new(seed.id, seed.label); - if expand_folders && !seed.children.is_empty() { - item = item.expanded(true); - } - item.children = seed - .children - .into_iter() - .map(|seed| convert(seed, expand_folders)) - .collect(); - item - } - - seeds - .into_iter() - .map(|seed| convert(seed, expand_folders)) - .collect() -} - -/// Build nested tree items from a flat entry list sorted dirs-first. -pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec { - // Node indices by full path, so parents resolve in constant time while inserting. - let mut index: HashMap = HashMap::new(); - let mut nodes: Vec<(String, String, Vec)> = Vec::new(); - let mut roots: Vec = Vec::new(); - - for entry in entries { - let mut parent: Option = None; - let mut path = String::new(); - for part in entry.components() { - let label = part.as_os_str().to_string_lossy().into_owned(); - path = if path.is_empty() { - label.clone() - } else { - format!("{path}/{label}") - }; - let ix = *index.entry(path.clone()).or_insert_with(|| { - let ix = nodes.len(); - nodes.push((path.clone(), label.clone(), Vec::new())); - match parent { - Some(parent) => nodes[parent].2.push(ix), - None => roots.push(ix), - } - ix - }); - parent = Some(ix); - } - } - - fn assemble(ix: usize, nodes: &[(String, String, Vec)]) -> TreeItemSeed { - let (id, label, children) = &nodes[ix]; - TreeItemSeed { - id: id.clone(), - label: label.clone(), - children: children - .iter() - .map(|child| assemble(*child, nodes)) - .collect(), - } - } - - roots.iter().map(|root| assemble(*root, &nodes)).collect() -} - -/// Sorted relative paths of a worktree snapshot. -/// -/// Compared against the `worktree_paths` of a repository panel to skip -/// rebuilding the explorer when a refresh left the worktree unchanged. -pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec { - let mut paths: Vec = entries - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(); - paths.sort(); - paths -} - -/// The markdown fence language for a file path, or `None` for plain text. -pub(crate) fn code_language(path: &str) -> Option<&'static str> { - let name = Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(); - - // Some common files are recognized by name rather than extension. - match name { - "Makefile" | "makefile" => return Some("make"), - "CMakeLists.txt" => return Some("cmake"), - _ => {} - } - - let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); - Some(match ext.as_str() { - "rs" => "rust", - "toml" => "toml", - "json" | "jsonc" => "json", - "py" => "python", - "js" | "mjs" | "cjs" => "javascript", - "ts" | "mts" | "cts" => "typescript", - "tsx" | "jsx" => "tsx", - "go" => "go", - "c" | "h" => "c", - "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp", - "cs" => "csharp", - "java" => "java", - "kt" | "kts" | "ktm" => "kotlin", - "swift" => "swift", - "php" | "phtml" => "php", - "rb" => "ruby", - "sh" | "bash" | "zsh" => "bash", - "yml" | "yaml" => "yaml", - "css" | "scss" | "sass" => "css", - "html" | "htm" => "html", - "lua" => "lua", - "sql" => "sql", - "proto" | "protobuf" => "proto", - "cmake" => "cmake", - "zig" => "zig", - "ex" | "exs" => "elixir", - "graphql" | "gql" => "graphql", - "diff" | "patch" => "diff", - "svelte" => "svelte", - "astro" => "astro", - "scala" => "scala", - _ => return None, - }) -} - -/// Whether a file path has a markdown extension. -pub(crate) fn is_markdown_path(path: &str) -> bool { - Path::new(path) - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| { - matches!( - ext.to_ascii_lowercase().as_str(), - "md" | "markdown" | "mdown" | "mkdn" - ) - }) -} - -pub(crate) struct ShareTargets { - /// NIP-19 `naddr1...` of the announcement, with its announced relays. - pub(crate) naddr: String, - /// Hex ID of the announcement event itself. - pub(crate) event_id: String, - /// NIP-34 coordinate `30617::`. - pub(crate) coordinate: String, - /// `https://gitworkshop.dev/` - pub(crate) gitworkshop: String, - /// `https://ditto.pub/` - pub(crate) ditto: String, -} - -impl ShareTargets { - pub(crate) fn from_announcement(announcement: &Announcement) -> Self { - let addr = announcement.addr(); - let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) - .to_bech32() - .expect("a complete coordinate always encodes to naddr"); - - Self { - naddr: naddr.clone(), - event_id: announcement.event_id.to_bech32().unwrap(), - coordinate, - gitworkshop: format!("https://gitworkshop.dev/{naddr}"), - ditto: format!("https://ditto.pub/{naddr}"), - } - } - - pub(crate) fn menu(&self, menu: PopupMenu) -> PopupMenu { - menu.min_w(px(340.)) - .item(menu_copy_row( - "copy-gitworkshop", - "GitWorkshop", - truncate_naddr_link(&self.gitworkshop, 4), - self.gitworkshop.clone(), - )) - .item(menu_copy_row( - "copy-ditto", - "Ditto", - truncate_naddr_link(&self.ditto, 4), - self.ditto.clone(), - )) - .item(menu_copy_row( - "copy-event-id", - "Event ID", - middle_truncate(&self.event_id, 10, 10), - self.event_id.clone(), - )) - .item(menu_copy_row( - "copy-coordinate", - "Coordinate", - middle_truncate(&self.coordinate, 10, 10), - self.coordinate.clone(), - )) - } -} - -fn truncate_naddr_link(url: &str, tail: usize) -> String { - let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { - return url.to_string(); - }; - if url.len() - end <= tail + 3 { - return url.to_string(); - } - format!("{}...{}", &url[..end], &url[url.len() - tail..]) -} - -pub(crate) const GUTTER_WIDTH: f32 = 44.; -pub(crate) const DIFF_ROW_HEIGHT: f32 = 20.; - -#[derive(Clone, Copy)] -pub(crate) enum DiffRow { - Hunk { - old_start: u32, - old_lines: u32, - new_start: u32, - new_lines: u32, - }, - Line { - hunk: usize, - line: usize, - }, -} - -pub(crate) fn diff_rows(file: &FileDiff) -> Vec { - let mut rows = Vec::new(); - for (hunk_ix, hunk) in file.hunks.iter().enumerate() { - rows.push(DiffRow::Hunk { - old_start: hunk.old_start, - old_lines: hunk.old_lines, - new_start: hunk.new_start, - new_lines: hunk.new_lines, - }); - rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line { - hunk: hunk_ix, - line, - })); - } - rows -} - -pub(crate) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { - match row { - DiffRow::Hunk { - old_start, - old_lines, - new_start, - new_lines, - } => div() - .px_2() - .w_full() - .h(px(DIFF_ROW_HEIGHT)) - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .bg(cx.theme().muted) - .border_y(px(1.)) - .border_color(cx.theme().border) - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!( - "@@ -{},{} +{},{} @@", - old_start, old_lines, new_start, new_lines - ))) - .into_any_element(), - DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx), - } -} - -pub(crate) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { - let bg = match line.kind { - DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), - DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)), - DiffLineKind::Context => None, - }; - let gutter = cx.theme().muted_foreground; - - // Fixed height and nowrap, the virtual list assumes every row has the same height. - // Long lines are clipped instead of wrapped. - h_flex() - .w_full() - .h(px(DIFF_ROW_HEIGHT)) - .items_center() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .when_some(bg, |this, bg| this.bg(bg)) - .child( - div() - .w(px(GUTTER_WIDTH)) - .flex_none() - .pr_2() - .text_right() - .text_color(gutter) - .child(line.old.map(|n| n.to_string()).unwrap_or_default()), - ) - .child( - div() - .w(px(GUTTER_WIDTH)) - .flex_none() - .pr_2() - .text_right() - .text_color(gutter) - .child(line.new.map(|n| n.to_string()).unwrap_or_default()), - ) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_color(cx.theme().foreground) - .child(line.text.clone()), - ) - .into_any_element() -} - -pub(crate) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { - let id = id?; - items.iter().find_map(|item| { - if item.id.as_ref() == id { - Some(item) - } else { - find_item(&item.children, Some(id)) - } - }) -} - -pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] { - &store.issues -} - -pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] { - &store.pull_requests -} - -/// The kind icon, the selection or placeholder, and the caret. `Combobox` -/// replaces its default trigger entirely, the only way to show an icon inside it. -pub(crate) fn ref_selector_trigger( - ctx: &ComboboxTriggerContext>, - icon: CustomIconName, - cx: &App, -) -> AnyElement { - let muted = cx.theme().muted_foreground; - - h_flex() - .w_full() - .min_w_0() - .gap_1() - .items_center() - .child(Icon::new(icon).small().flex_shrink_0()) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .when(ctx.selection().is_empty(), |this| this.text_color(muted)) - .child( - ctx.selection() - .first() - .map(|(_, item)| item.clone()) - .or_else(|| ctx.placeholder().cloned()) - .unwrap_or_default(), - ), - ) - .child(Caret::new(ctx.size()).text_color(muted)) - .into_any_element() -} - -pub(crate) fn sidebar_title(text: &str, cx: &App) -> AnyElement { - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child(text.to_string()) - .into_any_element() -} - -pub(crate) fn sidebar_section( - store: &Entity, - id: EventId, - roots: fn(&RepoStore) -> &[Event], - top_gap: bool, - cx: &App, -) -> AnyElement { - let store = store.read(cx); - let Some(root) = roots(store).iter().find(|event| event.id == id) else { - // The caller bails out when the root is missing. - return div().into_any_element(); - }; - let profile_store = ProfileStore::global(cx); - - // Participants, the root author plus everyone who commented. - let mut participants: Vec = vec![root.pubkey]; - participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); - participants.sort_by_key(PublicKey::to_hex); - participants.dedup(); - - // Labels are NIP-34 `t` hashtag tags on the event. - let labels: Vec = root.tags.hashtags().map(|tag| tag.to_string()).collect(); - - v_flex() - .w(px(240.)) - .h_full() - .flex_none() - .px_4() - .gap_4() - .border_l(px(1.)) - .border_color(cx.theme().sidebar_border) - .child( - v_flex() - .when(top_gap, |this| this.mt_4()) - .gap_2() - .child(sidebar_title("Participants", cx)) - .children(participants.iter().map(|pubkey| { - let profile = profile_store.read(cx).get(pubkey); - let name = profile.name(); - let picture = profile.picture(); - - h_flex() - .gap_1() - .items_center() - .child(UserAvatar::new(name.clone()).picture(picture)) - .child(div().text_sm().truncate().text_ellipsis().child(name)) - .into_any_element() - })), - ) - .child( - v_flex() - .gap_2() - .child(sidebar_title("Labels", cx)) - .map(|this| { - if labels.is_empty() { - this.child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("None yet."), - ) - } else { - this.child(h_flex().gap_1().children({ - let mut items = vec![]; - - for label in labels.iter() { - items.push( - Tag::secondary() - .outline() - .xsmall() - .child(SharedString::from(label)), - ); - } - - items - })) - } - }), - ) - .into_any_element() -} - -pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { - let store = store.read(cx); - let comments: Vec<&Event> = store.comments_of(&root).collect(); - let title = SharedString::from(format!("Discussions {}", comments.len())); - - v_flex() - .gap_4() - .child(div().text_xs().font_semibold().child(title)) - .children(comments.iter().map(|comment| { - let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); - let author = profile.name(); - let picture = profile.picture(); - let age = relative_time(comment.created_at); - let content = SharedString::from(comment.content.as_str()); - - v_flex() - .gap_1() - .p_3() - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .child( - h_flex() - .gap_2() - .text_sm() - .child( - h_flex() - .gap_1() - .child(UserAvatar::new(author.clone()).picture(picture)) - .child(author), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child("commented"), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(age)), - ), - ) - .child(div().text_sm().child(content)) - })) - .into_any_element() -} - -/// `roots` selects the root's list within the store, issues or pull requests. -pub(crate) fn comment_form( - store: &Entity, - root: EventId, - roots: fn(&RepoStore) -> &[Event], - comment_input: &Entity, - button_id: &'static str, - cx: &App, -) -> AnyElement { - let comment_input = comment_input.clone(); - let store = store.clone(); - - v_flex() - .gap_2() - .child( - Textarea::new(&comment_input) - .h_24() - .text_color(cx.theme().muted_foreground) - .bg(cx.theme().muted), - ) - .child( - h_flex() - .justify_between() - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(CustomIconName::Markdown).small()) - .child("Markdown is supported"), - ) - .child( - Button::new(button_id) - .primary() - .label("Comment") - .tooltip("Post comment") - .on_click(move |_event, window, cx| { - let content = comment_input.read(cx).value().trim().to_string(); - if content.is_empty() { - return; - } - let Some(root) = roots(store.read(cx)) - .iter() - .find(|event| event.id == root) - .cloned() - else { - return; - }; - store.update(cx, |store, cx| { - store.comment(&root, content, cx); - }); - comment_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - }), - ), - ) - .into_any_element() -} - -pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.; - -pub(crate) fn commit_row( - ix: usize, - commit: &FileCommit, - on_click: impl Fn(&mut Window, &mut App) + 'static, - cx: &App, -) -> AnyElement { - h_flex() - .id(ix) - .px_4() - .h(px(COMMIT_ROW_HEIGHT)) - .w_full() - .gap_3() - .items_center() - .border_b(px(1.)) - .border_color(cx.theme().border) - .hover(|this| this.bg(cx.theme().list_hover)) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .justify_center() - .child( - h_flex() - .gap_2() - .items_center() - .overflow_hidden() - .child( - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.id.clone()), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_ellipsis() - .whitespace_nowrap() - .child(commit.summary.clone()), - ), - ) - .child( - h_flex() - .gap_2() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.author.clone()) - .child(relative_time_secs(commit.time)), - ), - ) - .on_click(move |_event, window, cx| on_click(window, cx)) - .into_any_element() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builds_nested_tree_from_flat_entries() { - let entries = vec![ - PathBuf::from("src"), - PathBuf::from("src/lib.rs"), - PathBuf::from("README.md"), - PathBuf::from("docs/guide.md"), - ]; - - let items = build_tree_items(&entries); - - // Input order is preserved, dirs-first as produced by worktree_entries. - assert_eq!(items.len(), 3); - assert_eq!(items[0].label, "src"); - assert_eq!(items[0].id, "src"); - assert_eq!(items[0].children.len(), 1); - assert_eq!(items[0].children[0].label, "lib.rs"); - assert_eq!(items[0].children[0].id, "src/lib.rs"); - - assert_eq!(items[1].label, "README.md"); - assert_eq!(items[1].id, "README.md"); - - assert_eq!(items[2].label, "docs"); - assert_eq!(items[2].children[0].label, "guide.md"); - assert_eq!(items[2].children[0].id, "docs/guide.md"); - } - - #[test] - fn tree_builder_handles_deep_nesting() { - let entries = vec![ - PathBuf::from("a"), - PathBuf::from("a/b"), - PathBuf::from("a/b/c.txt"), - ]; - - let items = build_tree_items(&entries); - assert_eq!(items.len(), 1); - assert_eq!(items[0].children[0].id, "a/b"); - assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); - } - - #[test] - fn tree_builder_merges_shared_prefixes() { - // File children of a directory arrive after other directories' entries. - // The worktree list is dirs-first globally. - // The shared prefix must still resolve to one node. - let entries = vec![ - PathBuf::from("a/x.txt"), - PathBuf::from("b/y.txt"), - PathBuf::from("a/z.txt"), - ]; - - let items = build_tree_items(&entries); - assert_eq!(items.len(), 2); - assert_eq!(items[0].label, "a"); - assert_eq!(items[0].children.len(), 2); - assert_eq!(items[1].label, "b"); - } -} diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs index f6fad59..599806d 100644 --- a/crates/workspace/src/views/repo/history.rs +++ b/crates/workspace/src/views/repo/history.rs @@ -11,8 +11,7 @@ use gpui_component::{ActiveTheme, Sizable, v_flex, v_virtual_list}; use signed_ui::placeholder; use super::RepoDetailView; -use crate::views::commit_diff::CommitDiffView; -use crate::views::repo::helpers::{COMMIT_ROW_HEIGHT, commit_row}; +use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, commit_row}; impl RepoDetailView { pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs index e2c1111..edab3a8 100644 --- a/crates/workspace/src/views/repo/loading.rs +++ b/crates/workspace/src/views/repo/loading.rs @@ -11,9 +11,7 @@ use signed_git::FileCommit; use signed_state::GitStore; use super::RepoDetailView; -use crate::views::repo::helpers::{ - TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items, -}; +use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items}; struct RepoData { tree: Vec, diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index dc5d6bb..5702bf9 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -22,7 +22,6 @@ mod actions; mod banners; mod files; mod header; -pub(super) mod helpers; mod history; mod init_dialog; mod loading; diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs index b6fc1e1..05ddb52 100644 --- a/crates/workspace/src/views/repo/refs.rs +++ b/crates/workspace/src/views/repo/refs.rs @@ -7,7 +7,7 @@ use gpui_component::combobox::ComboboxState; use gpui_component::searchable_list::SearchableVec; use super::{RefKind, RepoDetailView}; -use crate::views::repo::helpers::{build_tree_items, sorted_worktree_paths, tree_items}; +use crate::views::tree::{build_tree_items, sorted_worktree_paths, tree_items}; impl RepoDetailView { pub(super) fn switch_ref( diff --git a/crates/workspace/src/views/tree.rs b/crates/workspace/src/views/tree.rs new file mode 100644 index 0000000..8cc385d --- /dev/null +++ b/crates/workspace/src/views/tree.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use gpui_component::tree::TreeItem; + +pub(crate) struct TreeItemSeed { + /// Path of the node, relative to the worktree root. + pub(crate) id: String, + pub(crate) label: String, + pub(crate) children: Vec, +} + +pub(crate) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { + fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { + let mut item = TreeItem::new(seed.id, seed.label); + if expand_folders && !seed.children.is_empty() { + item = item.expanded(true); + } + item.children = seed + .children + .into_iter() + .map(|seed| convert(seed, expand_folders)) + .collect(); + item + } + + seeds + .into_iter() + .map(|seed| convert(seed, expand_folders)) + .collect() +} + +/// Build nested tree items from a flat entry list sorted dirs-first. +pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec { + // Node indices by full path, so parents resolve in constant time while inserting. + let mut index: HashMap = HashMap::new(); + let mut nodes: Vec<(String, String, Vec)> = Vec::new(); + let mut roots: Vec = Vec::new(); + + for entry in entries { + let mut parent: Option = None; + let mut path = String::new(); + for part in entry.components() { + let label = part.as_os_str().to_string_lossy().into_owned(); + path = if path.is_empty() { + label.clone() + } else { + format!("{path}/{label}") + }; + let ix = *index.entry(path.clone()).or_insert_with(|| { + let ix = nodes.len(); + nodes.push((path.clone(), label.clone(), Vec::new())); + match parent { + Some(parent) => nodes[parent].2.push(ix), + None => roots.push(ix), + } + ix + }); + parent = Some(ix); + } + } + + fn assemble(ix: usize, nodes: &[(String, String, Vec)]) -> TreeItemSeed { + let (id, label, children) = &nodes[ix]; + TreeItemSeed { + id: id.clone(), + label: label.clone(), + children: children + .iter() + .map(|child| assemble(*child, nodes)) + .collect(), + } + } + + roots.iter().map(|root| assemble(*root, &nodes)).collect() +} + +/// Sorted relative paths of a worktree snapshot. +/// +/// Compared against the `worktree_paths` of a repository panel to skip +/// rebuilding the explorer when a refresh left the worktree unchanged. +pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec { + let mut paths: Vec = entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + paths.sort(); + paths +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_nested_tree_from_flat_entries() { + let entries = vec![ + PathBuf::from("src"), + PathBuf::from("src/lib.rs"), + PathBuf::from("README.md"), + PathBuf::from("docs/guide.md"), + ]; + + let items = build_tree_items(&entries); + + // Input order is preserved, dirs-first as produced by worktree_entries. + assert_eq!(items.len(), 3); + assert_eq!(items[0].label, "src"); + assert_eq!(items[0].id, "src"); + assert_eq!(items[0].children.len(), 1); + assert_eq!(items[0].children[0].label, "lib.rs"); + assert_eq!(items[0].children[0].id, "src/lib.rs"); + + assert_eq!(items[1].label, "README.md"); + assert_eq!(items[1].id, "README.md"); + + assert_eq!(items[2].label, "docs"); + assert_eq!(items[2].children[0].label, "guide.md"); + assert_eq!(items[2].children[0].id, "docs/guide.md"); + } + + #[test] + fn tree_builder_handles_deep_nesting() { + let entries = vec![ + PathBuf::from("a"), + PathBuf::from("a/b"), + PathBuf::from("a/b/c.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 1); + assert_eq!(items[0].children[0].id, "a/b"); + assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); + } + + #[test] + fn tree_builder_merges_shared_prefixes() { + // File children of a directory arrive after other directories' entries. + // The worktree list is dirs-first globally. + // The shared prefix must still resolve to one node. + let entries = vec![ + PathBuf::from("a/x.txt"), + PathBuf::from("b/y.txt"), + PathBuf::from("a/z.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 2); + assert_eq!(items[0].label, "a"); + assert_eq!(items[0].children.len(), 2); + assert_eq!(items[1].label, "b"); + } +} diff --git a/docs/repo-view-refactor-plan.md b/docs/repo-view-refactor-plan.md new file mode 100644 index 0000000..1a5350e --- /dev/null +++ b/docs/repo-view-refactor-plan.md @@ -0,0 +1,291 @@ +# Repo view refactor plan + +## Goal + +Make `crates/workspace/src/views/repo/` easy to navigate and change: + +- Each concern owns its own state (its own struct fields), instead of all concerns sharing one 38-field struct. +- Shared UI moves to the module that consumes it, so sibling views stop importing from `views::repo`. +- No behavior change. No new global state. No new store. The GPUI patterns already used in the repo (`Entity` + observe, `TreeState`, `ComboboxState`, `VirtualListScrollHandle`) stay the only patterns used. + +## Constraints + +- Follow `.rules`: no `unwrap` in production, no silently discarded errors, full-word names, comments explain "why" only. +- Do not over-engineer. Files and History become entities because they already render and run async independently. Refs and Banners stay plain field groups on the shell. +- Keep the shell as the single load/reconcile point. The clone/worktree and `ref_generation` belong to the shell, not to child views. +- `signed_ui` does **not** depend on `signed_git` (verified in `crates/signed_ui/Cargo.toml`). Anything that takes a `signed_git` type cannot move there. + +## Current state (verified) + +| File | Lines | Content | +|---|---|---| +| `mod.rs` | ~376 | `RepoDetailView` struct (38 fields), constructors, `render`, panel impls, `display_name` | +| `store.rs` | ~98 | `attach_store`, `apply_announcement`, `refresh_ready_statuses`, `refresh_statuses` | +| `actions.rs` | ~208 | action methods + `open_repo_panel` / `open_repo_item` free functions | +| `loading.rs` | ~435 | `load_repo`, `apply_repo_data`, `sync_ref_selector`, `clone_to_folder`, `load_repo_data` | +| `refs.rs` | ~279 | `switch_ref`, `restore_selection`, `reload_worktree`, `catch_up_worktree` | +| `files.rs` | ~480 | file tree, previews, markdown/code state, eviction | +| `history.rs` | ~228 | commits tab render + per-file and full commit walks | +| `header.rs` | ~715 | header render, maintainers, fork row, clone URL | +| `banners.rs` | ~315 | ready/push suggestion banners | +| `about.rs` | ~224 | about dialog | +| `init_dialog.rs` | ~202 | publish-to-NIP-34 dialog | +| `helpers.rs` | ~722 | `pub(crate)` grab bag: tree building, diff rendering, discussion UI, share targets, commit rows | + +### Problems + +1. **`RepoDetailView` is a god object.** 38 fields across six concerns. All 12 files are `impl RepoDetailView`, so any file can read/write any field. The file split added navigation cost without encapsulation. +2. **`helpers.rs` is an inverted dependency hub.** `views/issues/detail.rs` and `views/pull_requests/*.rs` import from `views::repo::helpers` for discussion UI, diff rows and commit rows. Sibling views reaching into `repo` is backwards. +3. **Two regions have independent async + render lifecycles** (file browser, commit history) but live as shell fields, sharing `worktree` and `ref_generation` by hand. + +### Existing good pattern + +`IssuesView` (`views/issues/mod.rs`): own struct (~13 fields), `cx.observe(&store, ..)`, `rebuild()` into local state, `Render`, no shell fields. The refactor brings `RepoDetailView` in line with this. + +## Target structure + +```mermaid +graph TD + Shell["RepoDetailView shell\nstore, dock_area, tabs, header,\nload orchestration, worktree, generation"] --> Files["Entity\nfiles.rs"] + Shell --> History["Entity\nhistory.rs"] + Shell --> Refs["RefSwitcher (plain)\nrefs.rs"] + Shell --> Banners["Banners (plain)\nbanners.rs"] + Files --> Store["Entity"] + History --> Store +``` + +Field ownership after the refactor: + +| Concern | Fields | Owner | +|---|---|---| +| Files | `tree_state, worktree_paths, md, code, readme_name, selected_file, files, file_order, preview_bytes, loading_files, commits, pending_commits, loading_commits` | `RepoFilesView` | +| History | `all_commits, loading_all_commits, item_sizes, scroll_handle` | `RepoHistoryView` | +| Refs | `branch_select, tag_select, ref_branches, ref_tags, switching_ref` | `RefSwitcher` | +| Banners | `banner_dismissed, ready_requested, ready_head, ready_statuses, push_statuses` | `Banners` | +| Shell | `focus_handle, dock_area, store, repo_started, active_tab, loading, error, head_commit, worktree, ref_generation, _subscriptions` | `RepoDetailView` (11 fields) | + +Shared modules after Phase 1: + +| New / changed module | Contents | Consumers | +|---|---|---| +| `views/tree.rs` | `TreeItemSeed`, `tree_items`, `build_tree_items`, `sorted_worktree_paths` + the 3 tree tests | repo files/loading, commit_diff | +| `views/commit_diff/mod.rs` | adds `DiffRow`, `diff_rows`, `render_diff_row`, `render_diff_line`, `find_item`, `GUTTER_WIDTH`, `DIFF_ROW_HEIGHT`, `commit_row`, `COMMIT_ROW_HEIGHT` | commit_diff, PR new, repo history | +| `views/discussion.rs` | `sidebar_title`, `sidebar_section`, `comments_section`, `comment_form`, `issue_roots`, `pr_roots` | issues detail, PR detail | +| `signed_ui/src/ref_selector.rs` | `ref_selector_trigger` | repo header, PR new | +| `repo/files.rs` | `code_language`, `is_markdown_path` (only used there) | repo files | +| `repo/header.rs` | `ShareTargets`, `truncate_naddr_link` (only used there) | repo header | + +`views/repo/helpers.rs` is deleted at the end of Phase 1. + +--- + +## Phase 0 - baseline + +No code. Record the current state so each later phase can be compared. + +1. `cargo fmt --all -- --check` +2. `cargo check --offline --workspace --all-targets` +3. `cargo test --offline -p workspace` +4. `cargo clippy --offline -p workspace --all-targets` + +Do not run plain `cargo` without `--offline`; the sandbox fails the git fetch and it looks like a dependency error. + +--- + +## Phase 1 - extract shared modules (dissolve `helpers.rs`) + +Low risk, no state moves. Land it as one commit. + +### 1.1 Create `crates/workspace/src/views/tree.rs` + +Move from `repo/helpers.rs`: `TreeItemSeed`, `tree_items`, `build_tree_items`, `sorted_worktree_paths`, and the three tests (`builds_nested_tree_from_flat_entries`, `tree_builder_handles_deep_nesting`, `tree_builder_merges_shared_prefixes`). + +- Add `pub(crate) mod tree;` to `views/mod.rs`. +- Update imports in `repo/loading.rs`, `repo/refs.rs`, `commit_diff/mod.rs` to `crate::views::tree::...`. + +### 1.2 Move diff and commit-row rendering into `views/commit_diff/mod.rs` + +Move from `repo/helpers.rs`: `GUTTER_WIDTH`, `DIFF_ROW_HEIGHT`, `DiffRow`, `diff_rows`, `render_diff_row`, `render_diff_line`, `find_item`, `COMMIT_ROW_HEIGHT`, `commit_row`. + +- `commit_diff/mod.rs` already owns `DiffPane` and depends on `signed_git`, so this is its natural home and keeps `signed_ui` free of a `signed_git` dependency. +- Update imports in `views/pull_requests/new.rs` and `repo/history.rs`. + +### 1.3 Create `crates/workspace/src/views/discussion.rs` + +Move from `repo/helpers.rs`: `sidebar_title`, `sidebar_section`, `comments_section`, `comment_form`, `issue_roots`, `pr_roots`. + +- Add `pub(crate) mod discussion;` to `views/mod.rs`. +- Update imports in `views/issues/detail.rs` and `views/pull_requests/detail.rs`. After this, neither imports from `views::repo`. + +### 1.4 Move `ref_selector_trigger` into `signed_ui` + +It takes `CustomIconName` (from `assets`) and `ComboboxTriggerContext` (from `gpui_component`); both are already `signed_ui` dependencies, so no dependency changes. + +- Add `crates/signed_ui/src/ref_selector.rs`, export it from `lib.rs`. +- Update imports in `repo/header.rs` and `views/pull_requests/new.rs`. + +### 1.5 Move `code_language` and `is_markdown_path` into `repo/files.rs` + +Only `repo/files.rs` uses them. Keep them private there. + +### 1.6 Move `ShareTargets` and `truncate_naddr_link` into `repo/header.rs` + +Only `repo/header.rs` uses them. Keep them private there. + +### 1.7 Delete `repo/helpers.rs` + +Remove `pub(super) mod helpers;` from `repo/mod.rs`. Confirm no `use ...repo::helpers` remains anywhere: + +``` +grep -rn "repo::helpers" crates/workspace/src +``` + +### Phase 1 validation + +`cargo fmt --all`, `cargo check --offline -p workspace --all-targets`, `cargo test --offline -p workspace`, `cargo clippy --offline -p workspace --all-targets`. + +--- + +## Phase 2 - extract `Entity` + +Largest win: removes 14 fields and most of the preview logic from the shell. + +### 2.1 Define the view + +In `repo/files.rs`, replace `impl RepoDetailView` with `pub(super) struct RepoFilesView` holding: `tree_state`, `worktree`, `worktree_paths`, `md`, `code`, `readme_name`, `selected_file`, `files`, `file_order`, `preview_bytes`, `loading_files`, `commits`, `pending_commits`, `loading_commits`. + +Move the supporting types and helpers from the current `files.rs` into the view: `FileContent`, `MarkdownView`, `CodeView`, `MAX_PREVIEW_BYTES`, `MAX_PREVIEWED_FILES`, `MAX_PREVIEW_CACHE_BYTES`, `source_hash`, `preview_spinner`, `render_tree_item`, `render_tree_column`, `render_content_column`, `set_markdown`, `markdown_element`, `set_code`, `code_element`, `open_file`, `drop_preview_of`, `evict_previews`. + +Move from `repo/history.rs`: `load_commit`, `load_commits` (the per-file commit map). + +### 2.2 Define the view's interface + +- `pub(super) fn new(window: &mut Window, cx: &mut Context) -> Self` - creates the `TreeState`. +- `pub(super) fn set_worktree(&mut self, path: PathBuf)`. +- `pub(super) fn apply_entries(&mut self, tree: Vec, paths: Vec, window, cx)` - used by `load_repo` / `reload_worktree` / `catch_up_worktree`. +- `pub(super) fn set_readme(&mut self, path: Option, bytes: Option>, cx)`. +- `pub(super) fn clear_previews(&mut self)` - branch switch. +- `pub(super) fn catch_up(&mut self, snapshot, window, cx) -> bool` - rebuild tree, drop removed previews, re-render README; returns whether anything changed. +- `impl Render for RepoFilesView`. +- `pub(super) fn pane_title(&self) -> SharedString` - `selected_file` or `readme_name` or `"Overview"`. + +### 2.3 Move the clone loading/error display out of the file view + +`render_content_column` currently shows "Cloning repository..." / a load error from `self.loading` and `self.error`, which are shell state. Move that decision to the shell's `render`: while `self.loading`, render a spinner in the tab body; when `self.error` is set, the existing `Alert` already covers it. `render_content_column` then handles only file previews and the README. + +### 2.4 Wire the shell + +- Add `files: Entity` to `RepoDetailView`. +- In `new_common`, `let files = cx.new(|cx| RepoFilesView::new(window, cx));`. +- In `render`, the Files tab body becomes `self.files.clone()`. +- In `load_repo` (`loading.rs`) and `reload_worktree` / `catch_up_worktree` (`refs.rs`), replace direct field writes with calls on `self.files`. +- Remove the now-unused `files.rs` imports from `mod.rs` and the moved fields from the struct and constructor. + +### Phase 2 validation + +Same commands. Manual: open explore repo, click files in the tree, open the README, switch branch (previews clear), switch back, confirm no spinner sticks. + +--- + +## Phase 3 - extract `Entity` + +### 3.1 Define the view + +In `repo/history.rs`, replace the commits-tab methods with `pub(super) struct RepoHistoryView` holding: `store: Entity`, `dock_area: WeakEntity`, `worktree: Option`, `all_commits`, `loading_all_commits`, `item_sizes`, `scroll_handle`. + +Move: `render_commits_tab` (becomes `impl Render`), `load_all_commits`, `open_commit_diff`. + +### 3.2 Display name + +`open_commit_diff` uses the shell's `display_name`. Extract the `display_name` logic from `RepoDetailView` into a free function in `repo/mod.rs`: + +```rust +pub(super) fn repo_display_name(store: &RepoStore) -> SharedString +``` + +It keeps the local-path fallback that `RepoStore::name()` does not have. Use it in the shell's `Panel::title`, in the header, and in `RepoHistoryView::open_commit_diff`. + +### 3.3 Interface + +- `pub(super) fn new(store, dock_area, window, cx) -> Self`. +- `pub(super) fn set_worktree(&mut self, path: Option)`. +- `pub(super) fn reload(&mut self, cx)` - clears `all_commits` and starts the walk (called when HEAD changes or the branch switches). +- `impl Render for RepoHistoryView`. + +### 3.4 Wire the shell + +- Add `history: Entity` to `RepoDetailView`; create it in `new_common`. +- In `render`, tab 1 becomes `self.history.clone()`. +- Replace `self.all_commits` / `self.loading_all_commits` / `self.item_sizes` writes in `load_repo`, `reload_worktree`, `catch_up_worktree`, and the header-commit pill path with `self.history.update(..)` calls. +- Remove the moved fields from the struct and constructor. + +### Phase 3 validation + +Same commands. Manual: open the Commits tab, scroll a long history, click a commit (diff panel opens), switch branch and confirm the list reloads. + +--- + +## Phase 4 - group `RefSwitcher` and `Banners` + +Plain structs on the shell. No entity, no observer changes. + +### 4.1 `RefSwitcher` + +Move into a `struct RefSwitcher { branch_select, tag_select, ref_branches, ref_tags, switching_ref }` field on the shell. Update `refs.rs` and `loading.rs` methods to read/write `self.refs.*`. `switch_ref` stays on the shell because it fans out to files, history and `head_commit`. + +`ref_generation` stays on the shell: it is shared with the files and history loads. + +### 4.2 `Banners` + +Move into a `struct Banners { dismissed, ready_requested, ready_head, ready_statuses, push_statuses }` field. `banners.rs` and `store.rs` methods keep their `impl RepoDetailView` shape but read/write `self.banners.*`. + +### Phase 4 validation + +Same commands. Manual: the ready-to-contribute banner appears and dismisses, the push banner appears for an owned repo, dismissing survives a store refresh. + +--- + +## Phase 5 - fold `store.rs` and tidy + +1. Move `attach_store`, `apply_announcement`, `refresh_ready_statuses`, `refresh_statuses` into `mod.rs` and delete `repo/store.rs`. +2. Remove `mod store;` from `repo/mod.rs`. +3. Confirm `mod.rs` reads as a shell: struct, constructors, load coordination, `render`, panel impls. +4. Final validation: + +``` +cargo fmt --all +cargo check --offline --workspace --all-targets +cargo test --offline --workspace +cargo clippy --offline --workspace --all-targets +``` + +## Validation (manual smoke, after each phase) + +- Open a repo from the explore list, then open an issue and a PR. +- Deep-link straight to an issue / PR without visiting the repo panel. +- Open a local repository (never announced). +- Initialize a local repo to NIP-34, confirm it leaves the sidebar's local section. +- Clone to folder; clone again before the first clone completes. +- Switch a branch and a tag; confirm previews and the commit list reset. +- Owned repo with unpushed commits: push banner, push, republish banner. + +## Boundary test for "done" + +- No file can touch fields it does not own. +- `repo/mod.rs` is a shell, roughly 200 lines. +- `grep -rn "views::repo::helpers" crates/workspace/src` returns nothing. +- `views/issues` and `views/pull_requests` have no `use ...views::repo`. + +## Non-goals + +- No behavior change; no UI redesign. +- No new global state, no new store, no changes to `signed_state` or `dock`. +- No more `impl RepoDetailView` chapters. New files own structs, not fragments of one struct. +- Do not move `commit_row` into `signed_ui`: it takes `signed_git::FileCommit` and `signed_ui` does not depend on `signed_git`. + +## Risks and open questions + +- **Async generation.** `ref_generation` discards stale loads. It stays on the shell; when the shell pushes a snapshot into a child view, the child must not start a new load that outlives the generation. Simplest rule: only the shell starts loads, child views only render and own per-file preview fetches keyed to the current worktree. +- **Files owns the per-file commit walk.** `load_commit`/`load_commits` move with the preview state, so the shell no longer coordinates them. Confirm the README commit lookup still works after the move. +- **History is small.** After moving `load_commit`/`load_commits` to Files, `history.rs` is ~150 lines. If an entity feels heavy for that, a plain `struct History` field is an acceptable fallback; the field ownership still improves. +- **`RepoStore::name()` vs `display_name`.** `RepoStore::name()` returns `Unknown` for local repos. The extracted `repo_display_name` must keep the local-path fallback so titles are unchanged. -- 2.54.0 From b59f6a95deb2d06e99d768b4648ff67fb9742271 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 15:46:34 +0700 Subject: [PATCH 09/12] . --- crates/workspace/src/views/repo/actions.rs | 32 +- crates/workspace/src/views/repo/banners.rs | 5 - crates/workspace/src/views/repo/files.rs | 397 ++++++++++++++++----- crates/workspace/src/views/repo/header.rs | 14 +- crates/workspace/src/views/repo/history.rs | 250 ++++++------- crates/workspace/src/views/repo/loading.rs | 64 ++-- crates/workspace/src/views/repo/mod.rs | 192 ++++------ crates/workspace/src/views/repo/refs.rs | 143 ++------ crates/workspace/src/views/repo/store.rs | 8 +- 9 files changed, 566 insertions(+), 539 deletions(-) diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs index 8682bf2..cc4ffb9 100644 --- a/crates/workspace/src/views/repo/actions.rs +++ b/crates/workspace/src/views/repo/actions.rs @@ -22,9 +22,8 @@ impl RepoDetailView { self.error = None; cx.notify(); - self.store - .update(cx, |store, cx| store.push_repository(cx)) - .detach(); + let task = self.store.update(cx, |store, cx| store.push_repository(cx)); + self.tasks.push(task); } pub(super) fn push_unpushed_checkout( @@ -43,13 +42,10 @@ impl RepoDetailView { cx.notify(); let task: gpui::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); @@ -59,14 +55,15 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); } /// Delete the repository from nostr, announcement, state and activity. pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { - self.store - .update(cx, |store, cx| store.delete_repository(cx)) - .detach(); + let task = self + .store + .update(cx, |store, cx| store.delete_repository(cx)); + self.tasks.push(task); } pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { @@ -130,12 +127,7 @@ impl RepoDetailView { } } -/// Open `addr`'s repository as a panel in the dock's center. -/// -/// `hint` is an announcement already in hand for `addr`. It seeds the store's -/// relays and lets the explorer load without waiting for the database; the -/// store loads the announcement itself when the hint is absent, so an entry -/// point with only an address works too. +/// Open repository as a panel in the dock's center. pub(crate) fn open_repo_panel( dock_area: &WeakEntity, addr: &RepoAddr, @@ -156,24 +148,18 @@ pub(crate) fn open_repo_panel( } /// The nostr store of `addr`'s repository, without opening a repository panel. -/// -/// `hint` is an announcement already in hand for `addr`. It only seeds the -/// relays to connect to right away; the store loads the announcement from the -/// local database on its first pass, so the hint is optional. fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity { cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx)) } /// An item of a repository to open from outside its detail panel. -/// A patch has no detail view in Signed, so it opens nothing. pub(crate) enum RepoItem { Issue(EventId), PullRequest(EventId), Patch, } -/// The repository store is built here, not taken from a `RepoDetailView`, so the -/// item panel is the only panel docked. +/// The repository store is built here. pub(crate) fn open_repo_item( dock_area: &WeakEntity, addr: &RepoAddr, diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index ee9e1c7..db16bf0 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -11,11 +11,6 @@ use super::RepoDetailView; use crate::views::pull_requests::new::open_new_pull_panel; impl RepoDetailView { - /// The first checkout ready for a pull request on this repository. - /// Not covered by an open PR of the signed-in user. - /// Not dismissed in this panel. - /// The repository's own checkouts are not suggested here. - /// Their work is pushed, see [`Self::push_suggestion`]. fn ready_suggestion(&self, cx: &App) -> Option { let store = self.store.read(cx); let addr = store.addr()?; diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs index d9c85d2..1ff85c0 100644 --- a/crates/workspace/src/views/repo/files.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -1,8 +1,9 @@ -use std::path::{Component, Path}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Component, Path, PathBuf}; use anyhow::Error; use gpui::prelude::*; -use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px}; +use gpui::{AnyElement, Context, Entity, Render, SharedString, Task, WeakEntity, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::{Editor, EditorState}; use gpui_component::list::ListItem; @@ -10,61 +11,204 @@ use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; +use signed_git::{FileCommit, WorktreeSnapshot}; use signed_ui::{placeholder, tree_row}; -use super::RepoDetailView; +use crate::views::tree::{TreeItemSeed, tree_items}; const TREE_WIDTH: f32 = 240.; -pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024; -/// The oldest previews are evicted beyond the caps. -pub(super) const MAX_PREVIEWED_FILES: usize = 32; -pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024; +const MAX_PREVIEW_BYTES: usize = 1024 * 1024; +const MAX_PREVIEWED_FILES: usize = 32; +const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024; -pub(super) enum FileContent { +enum FileContent { Text(String), Binary, - /// Bigger than [`MAX_PREVIEW_BYTES`]. TooLarge, Failed(String), } -pub(super) struct MarkdownView { +struct MarkdownView { /// `None` means the repository README. - pub(super) path: Option, - pub(super) state: Entity, + path: Option, + state: Entity, /// Hash of the source, so the same document is not re-parsed on a refresh. source_hash: u64, } -pub(super) struct CodeView { +struct CodeView { /// Source path, relative to the worktree root. - pub(super) path: SharedString, - pub(super) state: Entity, + path: SharedString, + state: Entity, /// Hash of the source, so the same document is not re-parsed on a refresh. source_hash: u64, } -/// Two loads of the same document produce the same hash, so the persistent -/// markdown/editor state can be kept instead of rebuilt, which would re-parse -/// and flash the pane. -fn source_hash(text: &str) -> u64 { - use std::hash::{Hash, Hasher}; - - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - text.hash(&mut hasher); - hasher.finish() +pub(super) struct RepoFilesView { + tree_state: Entity, + worktree: Option, + worktree_paths: Vec, + md: Option, + code: Option, + readme_name: Option, + selected_file: Option, + files: HashMap, + file_order: VecDeque, + preview_bytes: usize, + loading_files: HashSet, + commits: HashMap, + pending_commits: Vec, + loading_commits: bool, + generation: u64, + tasks: Vec>>, } -fn preview_spinner() -> AnyElement { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element() -} +impl RepoFilesView { + pub(super) fn new(cx: &mut Context) -> Self { + Self { + tree_state: cx.new(|cx| TreeState::new(cx)), + worktree: None, + worktree_paths: Vec::new(), + md: None, + code: None, + readme_name: None, + selected_file: None, + files: HashMap::new(), + file_order: VecDeque::new(), + preview_bytes: 0, + loading_files: HashSet::new(), + commits: HashMap::new(), + pending_commits: Vec::new(), + loading_commits: false, + generation: 0, + tasks: Vec::new(), + } + } + + pub(super) fn set_worktree(&mut self, path: PathBuf) { + self.worktree = Some(path); + } + + pub(super) fn apply_entries( + &mut self, + tree: Vec, + paths: Vec, + cx: &mut Context, + ) { + self.worktree_paths = paths; + self.tree_state.update(cx, |state, cx| { + state.set_items(tree_items(tree, false), cx); + }); + } + + /// Point the README pane at `path`/`bytes`, or clear it when absent. + /// + /// Returns whether the pane changed. + pub(super) fn set_readme( + &mut self, + path: Option, + bytes: Option>, + cx: &mut Context, + ) -> bool { + let Some((path, bytes)) = path.zip(bytes) else { + let changed = self.readme_name.is_some() || self.md.is_some(); + self.readme_name = None; + self.md = None; + return changed; + }; + + let name: SharedString = path.to_string_lossy().into(); + let mut changed = self.readme_name.as_ref() != Some(&name); + self.readme_name = Some(name); + self.load_commit(&path.to_string_lossy(), cx); + + if let Ok(text) = String::from_utf8(bytes) { + changed |= self.set_markdown(None, &text, cx); + } + + changed + } + + /// Drop every cached preview and the README, e.g. on a branch switch. + pub(super) fn clear_previews(&mut self) { + self.selected_file = None; + self.files.clear(); + self.file_order.clear(); + self.preview_bytes = 0; + self.loading_files.clear(); + self.commits.clear(); + self.pending_commits.clear(); + self.loading_commits = false; + self.md = None; + self.code = None; + self.readme_name = None; + self.generation += 1; + } + + /// Refresh after the mirror caught up with the remote. + /// + /// Unlike a branch switch this keeps the selection and previews: it rebuilds + /// the tree, drops previews of files the refresh removed and re-renders the + /// README when it is on screen. + /// + /// Returns whether the tree, a preview or the README changed. + pub(super) fn catch_up( + &mut self, + snapshot: &WorktreeSnapshot, + tree: Vec, + paths: Vec, + cx: &mut Context, + ) -> bool { + let mut changed = false; + + if paths != self.worktree_paths { + self.apply_entries(tree, paths, cx); + changed = true; + } + + let present: HashSet = snapshot + .entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + + let mut previewed: Vec = Vec::new(); + + previewed.extend(self.files.keys().cloned()); + previewed.extend(self.selected_file.clone().map(|path| path.to_string())); + + if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) { + previewed.push(path.to_string()); + } + + if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) { + previewed.push(path.to_string()); + } + + previewed.sort(); + previewed.dedup(); + + for path in previewed { + if !present.contains(&path) { + self.drop_preview_of(&path); + changed = true; + } + } + + if self.selected_file.is_none() { + changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx); + } + + changed + } + + fn pane_title(&self) -> SharedString { + self.selected_file + .clone() + .or_else(|| self.readme_name.clone()) + .unwrap_or_else(|| "Overview".into()) + } -impl RepoDetailView { fn render_tree_item( ix: usize, entry: &TreeEntry, @@ -81,7 +225,7 @@ impl RepoDetailView { }) } - pub(super) fn render_tree_column( + fn render_tree_column( tree_state: Entity, view: WeakEntity, cx: &mut Context, @@ -101,43 +245,12 @@ impl RepoDetailView { ))) } - pub(super) fn render_content_column( + fn render_content_column( &self, pane_title: SharedString, cx: &mut Context, ) -> impl IntoElement { - let loading = self.loading; - let error = self.error.clone(); - let selected_file = self.selected_file.clone(); - - let body: AnyElement = if loading { - v_flex() - .size_full() - .items_center() - .justify_center() - .gap_2() - .child(Spinner::new().small()) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Cloning repository..."), - ) - .into_any_element() - } else if let Some(error) = error { - v_flex() - .size_full() - .items_center() - .justify_center() - .p_4() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(error), - ) - .into_any_element() - } else if let Some(path) = selected_file { + let body: AnyElement = if let Some(path) = self.selected_file.clone() { match self.files.get(path.as_ref()) { Some(FileContent::Text(_)) => { if is_markdown_path(path.as_ref()) { @@ -158,7 +271,6 @@ impl RepoDetailView { }; // Latest commit for the current pane, the selected file or the README. - // Computed after the body above, which needs `&mut self`. let commit = match &self.selected_file { Some(path) => self.commits.get(path.as_ref()), None => self @@ -213,27 +325,31 @@ impl RepoDetailView { .child(div().id("repo-content").flex_1().min_h_0().child(body)) } - pub(super) fn set_markdown( + fn set_markdown( &mut self, path: Option, text: &str, cx: &mut Context, - ) { + ) -> bool { let hash = source_hash(text); + if let Some(md) = &self.md && md.path == path && md.source_hash == hash { - return; + return false; } let state = cx.new(|cx| TextViewState::markdown("", cx)); state.update(cx, |state, cx| state.push_str(text, cx)); + self.md = Some(MarkdownView { path, state, source_hash: hash, }); + + true } fn markdown_element(&self, path: Option<&str>, _cx: &mut Context) -> AnyElement { @@ -258,7 +374,7 @@ impl RepoDetailView { .into_any_element() } - pub(super) fn set_code( + fn set_code( &mut self, path: SharedString, text: &str, @@ -266,6 +382,7 @@ impl RepoDetailView { cx: &mut Context, ) { let hash = source_hash(text); + if let Some(code) = &self.code && code.path == path && code.source_hash == hash @@ -281,6 +398,7 @@ impl RepoDetailView { .line_number(true) .folding(true) }); + self.code = Some(CodeView { path, state, @@ -304,16 +422,11 @@ impl RepoDetailView { .text_sm() .into_any_element() } -} -impl RepoDetailView { fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { self.selected_file = Some(path.into()); if self.files.contains_key(path) { - // The file is cached, but the markdown or code state may hold a different file. - // Re-point it at this one, the parse runs on a background task either way. - // Without this, the pane would show a spinner forever. if let Some(FileContent::Text(text)) = self.files.get(path) { let text = text.clone(); if is_markdown_path(path) { @@ -332,8 +445,6 @@ impl RepoDetailView { return; } - // Paths come from our own tree walk, but never trust them. - // Refuse anything that could escape the worktree. let rel = Path::new(path); let unsafe_path = rel.is_absolute() || rel.components().any(|c| { @@ -355,27 +466,28 @@ impl RepoDetailView { let path = path.to_string(); self.load_commit(&path, cx); - let generation = self.ref_generation; + let generation = self.generation; - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let task: Task> = cx.spawn_in(window, async move |this, cx| { let path_for_read = path.clone(); let content = cx .background_spawn(async move { let full = worktree.join(&path_for_read); - // Refuse oversized files before reading them. - // Reading a multi-gigabyte file just to classify it is wasteful. - // It would burn disk and memory bandwidth. + let metadata = match std::fs::metadata(&full) { Ok(metadata) => metadata, Err(error) => return Err(anyhow::anyhow!("{}", error)), }; + if metadata.len() > MAX_PREVIEW_BYTES as u64 { return Ok(FileContent::TooLarge); } + let bytes = match std::fs::read(&full) { Ok(bytes) => bytes, Err(error) => return Err(anyhow::anyhow!("{}", error)), }; + match String::from_utf8(bytes) { Ok(text) => Ok(FileContent::Text(text)), Err(_) => Ok(FileContent::Binary), @@ -384,15 +496,13 @@ impl RepoDetailView { .await; this.update_in(cx, |this, window, cx| { - // The worktree was switched while this file was reading. - // The result belongs to the previous branch. - // Clear the in-flight marker either way. - // Otherwise the path could never be loaded again. - if generation != this.ref_generation { + if generation != this.generation { this.loading_files.remove(&path); return; } + this.loading_files.remove(&path); + match content { Ok(kind) => { if let FileContent::Text(text) = &kind { @@ -426,45 +536,49 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); } - pub(super) fn drop_preview_of(&mut self, path: &str) { + fn drop_preview_of(&mut self, path: &str) { if let Some(FileContent::Text(text)) = self.files.remove(path) { self.preview_bytes -= text.len(); } + self.commits.remove(path); + if self.selected_file.as_deref() == Some(path) { self.selected_file = None; } + if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) { self.md = None; } + if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) { self.code = None; } } - /// Drop the oldest previews beyond the cache caps. - /// Keep the currently selected file. - /// An evicted file's parsed editor state drops with its entry. - /// Re-opening it re-parses on a background task. fn evict_previews(&mut self) { while (self.files.len() > MAX_PREVIEWED_FILES || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) && self.file_order.len() > 1 { let path = self.file_order.pop_front().expect("non-empty"); + if Some(path.as_str()) == self.selected_file.as_deref() { self.file_order.push_back(path); continue; } + if let Some(FileContent::Text(text)) = self.files.remove(&path) { self.preview_bytes -= text.len(); } + if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) { self.md = None; } + if self .code .as_ref() @@ -472,9 +586,102 @@ impl RepoDetailView { { self.code = None; } + self.commits.remove(&path); } } + + fn load_commit(&mut self, path: &str, cx: &mut Context) { + if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { + return; + } + + self.pending_commits.push(path.to_string()); + + if !self.loading_commits { + self.load_commits(cx); + } + } + + fn load_commits(&mut self, cx: &mut Context) { + if self.pending_commits.is_empty() || self.loading_commits { + return; + } + + let Some(worktree) = self.worktree.clone() else { + self.pending_commits.clear(); + return; + }; + + self.loading_commits = true; + + let paths = std::mem::take(&mut self.pending_commits); + let generation = self.generation; + + let task: Task> = cx.spawn(async move |this, cx| { + let rels: Vec = paths.iter().map(PathBuf::from).collect(); + let result = cx + .background_spawn( + async move { signed_git::worktree_last_commits(&worktree, &rels) }, + ) + .await; + + this.update(cx, |this, cx| { + this.loading_commits = false; + + if generation == this.generation + && let Ok(found) = result + { + for (path, commit) in found { + this.commits + .insert(path.to_string_lossy().into_owned(), commit); + } + } + + if !this.pending_commits.is_empty() { + this.load_commits(cx); + } + + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } +} + +impl Render for RepoFilesView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + let pane_title = self.pane_title(); + + h_flex() + .flex_1() + .w_full() + .overflow_hidden() + .child(Self::render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)) + } +} + +fn source_hash(text: &str) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + +fn preview_spinner() -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() } /// The markdown fence language for a file path, or `None` for plain text. diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs index e0560df..f2f82d5 100644 --- a/crates/workspace/src/views/repo/header.rs +++ b/crates/workspace/src/views/repo/header.rs @@ -20,7 +20,7 @@ use signed_ui::{ ref_selector_trigger, }; -use super::{RepoAction, RepoDetailView}; +use super::{RepoAction, RepoDetailView, repo_display_name}; use crate::views::issues::open_new_issue_dialog; use crate::views::pull_requests::new::open_new_pull_panel; use crate::views::repo::about::open_about_dialog; @@ -44,8 +44,6 @@ impl RepoDetailView { return div().into_any_element(); }; - // Derived NIP-34 header data, share targets and clone commands. - // Rebuilt per frame: two bech32 encodes and a couple of format strings. let nip05 = ProfileStore::global(cx) .read(cx) .get(&source.owner) @@ -62,7 +60,7 @@ impl RepoDetailView { let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); let git_commands = Rc::new(announcement.clone_urls()); - let name = self.display_name(cx); + let name = repo_display_name(self.store.read(cx)); let description = announcement.description(); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); @@ -413,7 +411,7 @@ impl RepoDetailView { } fn render_local_header(&self, cx: &mut Context) -> AnyElement { - let name = self.display_name(cx); + let name = repo_display_name(self.store.read(cx)); let path = self .store .read(cx) @@ -476,7 +474,7 @@ impl RepoDetailView { } fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { - let commits_count = self.all_commits.as_ref().map(|list| list.total); + let commits_count = self.history.read(cx).commit_count(); let worktree_empty = self.switching_ref || self.worktree.is_none(); h_flex() @@ -574,7 +572,9 @@ impl RepoDetailView { .on_click(cx.listener(|this, _event, window, cx| { if let Some(commit) = &this.head_commit { let id = commit.id.clone(); - this.open_commit_diff(&id, window, cx); + this.history.update(cx, |history, cx| { + history.open_commit_diff(&id, window, cx) + }); } })), ) diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs index 599806d..9e47eb9 100644 --- a/crates/workspace/src/views/repo/history.rs +++ b/crates/workspace/src/views/repo/history.rs @@ -2,19 +2,130 @@ use std::path::PathBuf; use std::rc::Rc; use anyhow::Error; -use dock::{add_center_panel, panel_handle}; +use dock::{DockArea, add_center_panel, panel_handle}; use gpui::prelude::*; -use gpui::{AnyElement, Context, Window, div, px, size}; +use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, px, size}; use gpui_component::scroll::Scrollbar; use gpui_component::spinner::Spinner; -use gpui_component::{ActiveTheme, Sizable, v_flex, v_virtual_list}; +use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list}; +use signed_git::CommitList; +use signed_state::RepoStore; use signed_ui::placeholder; -use super::RepoDetailView; +use super::repo_display_name; use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, commit_row}; -impl RepoDetailView { - pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { +pub(super) struct RepoHistoryView { + store: Entity, + dock_area: WeakEntity, + worktree: Option, + all_commits: Option, + loading_all_commits: bool, + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, + /// Bumped on reload, so an in-flight walk of the previous HEAD is discarded. + generation: u64, + tasks: Vec>>, +} + +impl RepoHistoryView { + pub(super) fn new(store: Entity, dock_area: WeakEntity) -> Self { + Self { + store, + dock_area, + worktree: None, + all_commits: None, + loading_all_commits: false, + scroll_handle: VirtualListScrollHandle::new(), + item_sizes: Rc::new(Vec::new()), + generation: 0, + tasks: Vec::new(), + } + } + + pub(super) fn set_worktree(&mut self, path: Option) { + self.worktree = path; + } + + /// Number of commits reachable from HEAD, for the Commits tab badge. + pub(super) fn commit_count(&self) -> Option { + self.all_commits.as_ref().map(|list| list.total) + } + + /// Drop the current list and walk HEAD again. + pub(super) fn reload(&mut self, cx: &mut Context) { + self.generation += 1; + self.all_commits = None; + self.loading_all_commits = false; + self.load(cx); + } + + fn load(&mut self, cx: &mut Context) { + if self.loading_all_commits || self.all_commits.is_some() { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + self.loading_all_commits = true; + let generation = self.generation; + + let task: Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) + .await; + + this.update(cx, |this, cx| { + if generation != this.generation { + return; + } + + if let Ok(list) = result { + let count = list.commits.len(); + this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); + this.all_commits = Some(list); + } + + this.loading_all_commits = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + pub(super) fn open_commit_diff( + &mut self, + commit_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + // Same display name as the repo detail panel's title. + let repo_name = repo_display_name(self.store.read(cx)); + + let panel = + cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } +} + +impl Render for RepoHistoryView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(list) = self.all_commits.as_ref() else { return if self.loading_all_commits { v_flex() @@ -32,9 +143,6 @@ impl RepoDetailView { return placeholder("No commits found", cx); } - // Copy only the values the element tree needs. - // The list is borrowed by the renderer below instead of cloned per frame. - // A full history can be tens of thousands of commits. let view = cx.entity().clone(); let sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); @@ -79,8 +187,6 @@ impl RepoDetailView { .size_full(), ) .when(shown < total, |this| { - // The history is capped. - // Tell the user the list is truncated. this.child( div() .py_2() @@ -102,125 +208,3 @@ impl RepoDetailView { .into_any_element() } } - -impl RepoDetailView { - pub(super) fn load_commit(&mut self, path: &str, cx: &mut Context) { - if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { - return; - } - self.pending_commits.push(path.to_string()); - if !self.loading_commits { - self.load_commits(cx); - } - } - - fn load_commits(&mut self, cx: &mut Context) { - if self.pending_commits.is_empty() || self.loading_commits { - return; - } - let Some(worktree) = self.worktree.clone() else { - self.pending_commits.clear(); - return; - }; - - self.loading_commits = true; - let paths = std::mem::take(&mut self.pending_commits); - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let rels: Vec = paths.iter().map(PathBuf::from).collect(); - let result = cx - .background_spawn( - async move { signed_git::worktree_last_commits(&worktree, &rels) }, - ) - .await; - - this.update(cx, |this, cx| { - this.loading_commits = false; - if generation == this.ref_generation - && let Ok(found) = result - { - for (path, commit) in found { - this.commits - .insert(path.to_string_lossy().into_owned(), commit); - } - } - // Paths queued while the walk was in flight start the next batch. - // A stale walk, branch switched mid-flight, must not strand them. - // This runs under the current generation regardless of the result. - if !this.pending_commits.is_empty() { - this.load_commits(cx); - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - pub(super) fn load_all_commits(&mut self, cx: &mut Context) { - if self.loading_all_commits || self.all_commits.is_some() { - return; - } - - let Some(worktree) = self.worktree.clone() else { - return; - }; - - self.loading_all_commits = true; - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) - .await; - - this.update(cx, |this, cx| { - // A stale walk, branch switched mid-flight, must not leave the flag set. - // Otherwise the Commits tab would spin forever. - if generation != this.ref_generation { - this.loading_all_commits = false; - return; - } - if let Ok(list) = result { - let count = list.commits.len(); - this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); - this.all_commits = Some(list); - } - this.loading_all_commits = false; - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - pub(super) fn open_commit_diff( - &mut self, - commit_id: &str, - window: &mut Window, - cx: &mut Context, - ) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - // Same display name as the repo detail panel's title. - let repo_name = self.display_name(cx); - - let panel = - cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } -} diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs index edab3a8..e0726d1 100644 --- a/crates/workspace/src/views/repo/loading.rs +++ b/crates/workspace/src/views/repo/loading.rs @@ -11,11 +11,10 @@ use signed_git::FileCommit; use signed_state::GitStore; use super::RepoDetailView; -use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths, tree_items}; +use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; struct RepoData { tree: Vec, - /// Relative paths of the worktree entries, for [`RepoDetailView::worktree_paths`]. entries: Vec, readme_path: Option, readme: Option>, @@ -28,9 +27,6 @@ struct RepoData { impl RepoDetailView { /// Load the repository and populate the file explorer. - /// - /// An announced repository's clone, if any, loads first without touching - /// the network. pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; @@ -74,7 +70,7 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); return; } @@ -141,6 +137,7 @@ impl RepoDetailView { let refresh = { let cache = cache.clone(); let addr = addr.clone(); + cx.background_spawn(async move { let Some(repo) = cache.open(&addr)? else { return Ok::<_, Error>(None); @@ -210,23 +207,10 @@ impl RepoDetailView { let head_changed = new_head_commit != current_head_commit; this.head_commit = head_commit; - log::debug!( - "repo detail refresh reconcile: branches_changed={branches_changed} tags_changed={tags_changed} head_changed={head_changed} moved={moved}" - ); - - // Only a moved HEAD invalidates the commit list. - // Leaving an in-flight walk alone when HEAD did not move - // keeps a refresh that learned nothing new from flashing - // the commits tab. if head_changed { - this.all_commits = None; - this.loading_all_commits = false; - this.load_all_commits(cx); + this.history.update(cx, |history, cx| history.reload(cx)); } - // A fast-forward may touch a branch that is not checked out. - // `catch_up_worktree` no-ops when the tree is unchanged and - // re-renders only when it actually rebuilt something. if moved { this.catch_up_worktree(cx); } @@ -240,7 +224,7 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); } fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { @@ -256,16 +240,24 @@ impl RepoDetailView { current_branch, head_commit, } = data; + let Some(worktree) = worktree else { self.error = Some("Repository has no worktree".into()); return; }; - self.worktree = Some(worktree); + self.worktree = Some(worktree.clone()); self.head_commit = head_commit; - self.worktree_paths = sorted_worktree_paths(&entries); - self.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); + + self.files.update(cx, |files, cx| { + files.set_worktree(worktree.clone()); + files.apply_entries(tree, sorted_worktree_paths(&entries), cx); + files.set_readme(readme_path, readme, cx); + }); + + self.history.update(cx, |history, cx| { + history.set_worktree(Some(worktree)); + history.reload(cx); }); let branches: Vec = branches.into_iter().map(Into::into).collect(); @@ -279,26 +271,11 @@ impl RepoDetailView { window, cx, ); + Self::sync_ref_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx); - - self.load_all_commits(cx); - - if let Some((path, bytes)) = readme_path.zip(readme) { - self.readme_name = Some(path.to_string_lossy().into()); - self.load_commit(&path.to_string_lossy(), cx); - if let Ok(text) = String::from_utf8(bytes) { - self.set_markdown(None, &text, cx); - } - } } /// Point a ref selector at `items`, selecting `selected` when given. - /// - /// Updates the items and selection only when they differ from `cached` and - /// the current selection. `set_items`/`set_selected_values` notify the - /// combobox, which re-renders the header, so skipping the no-op keeps a - /// background refresh that learned nothing new from flashing the selectors. - /// Returns whether anything was set. fn sync_ref_selector( select: &Entity>>, cached: &mut Vec, @@ -308,6 +285,7 @@ impl RepoDetailView { cx: &mut Context, ) -> bool { let items_changed = *cached != items; + let selection_changed = selected .as_ref() .is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value)); @@ -340,7 +318,6 @@ impl RepoDetailView { }; let addr = announcement.addr(); // Directory name, the display name falling back to the repo id. - // Both are sanitized to a safe single path component. let name = announcement .name .as_ref() @@ -348,6 +325,7 @@ impl RepoDetailView { .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| addr.identifier.clone()); let name = signed_git::sanitize_path_component(&name); + if name.is_empty() { "repository".to_owned() } else { @@ -392,7 +370,7 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); } } diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 5702bf9..d30cdb5 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -1,20 +1,20 @@ -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::HashSet; use std::path::PathBuf; -use std::rc::Rc; +use anyhow::Error; use dock::{BasePanel, DockArea, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ - Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, Subscription, WeakEntity, Window, + Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, + SharedString, Subscription, Task, WeakEntity, Window, div, }; use gpui_component::alert::Alert; use gpui_component::combobox::{ComboboxEvent, ComboboxState}; use gpui_component::searchable_list::SearchableVec; -use gpui_component::tree::TreeState; -use gpui_component::{VirtualListScrollHandle, h_flex, v_flex}; +use gpui_component::spinner::Spinner; +use gpui_component::{ActiveTheme, Sizable, v_flex}; use signed_core::{Announcement, RepoAddr}; -use signed_git::{CommitList, FileCommit}; +use signed_git::FileCommit; use signed_state::{CheckoutStatus, CheckoutsStore, RepoStore}; mod about; @@ -30,7 +30,8 @@ mod store; pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel}; -use self::files::{CodeView, FileContent, MarkdownView}; +use self::files::RepoFilesView; +use self::history::RepoHistoryView; #[derive(Clone, Copy, PartialEq, Eq)] enum RefKind { @@ -62,34 +63,14 @@ pub struct RepoDetailView { /// A repository opened by address alone starts without an announcement; the /// store observer starts the load once the first one lands. repo_started: bool, - tree_state: Entity, + /// The Files tab, which owns the explorer, previews and the per-file commit map. + files: Entity, + /// The checked-out worktree path, shared by the Files tab and the commit list. worktree: Option, - /// A background refresh that did not change the tree skips rebuilding it, - /// see [`Self::catch_up_worktree`], so a fetch that learned nothing new - /// does not flash the explorer. - worktree_paths: Vec, - md: Option, - code: Option, - readme_name: Option, - selected_file: Option, - files: HashMap, - /// Paths of cached previews, oldest first. Feeds the eviction caps in - /// [`Self::evict_previews`]. - file_order: VecDeque, - preview_bytes: usize, - loading_files: HashSet, - /// Latest commit touching a previewed file or the README, keyed by path. - commits: HashMap, - pending_commits: Vec, - loading_commits: bool, /// 0 = Files tree, 1 = Commits. active_tab: usize, - /// Commits reachable from HEAD, newest first. `None` until the walk - /// finishes or fails. `total` feeds the tab badge. - all_commits: Option, - loading_all_commits: bool, - scroll_handle: VirtualListScrollHandle, - item_sizes: Rc>>, + /// The Commits tab, which owns the commit list and the commit diff panels. + history: Entity, loading: bool, error: Option, head_commit: Option, @@ -102,6 +83,7 @@ pub struct RepoDetailView { switching_ref: bool, /// In-flight loads with an older generation are discarded when they complete. ref_generation: u64, + tasks: Vec>>, _subscriptions: Vec, /// `(path, branch)` ready-suggestions dismissed by the user, per panel. banner_dismissed: HashSet<(PathBuf, String)>, @@ -151,10 +133,11 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) -> Self { - let tree_state = cx.new(|cx| TreeState::new(cx)); + let checkouts = CheckoutsStore::global(cx); + let files = cx.new(RepoFilesView::new); + let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone())); - // Empty until the clone completes, then filled with the local refs. - let branch_select: Entity>> = cx.new(|cx| { + let branch_select = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), Vec::new(), @@ -163,7 +146,8 @@ impl RepoDetailView { ) .searchable(true) }); - let tag_select: Entity>> = cx.new(|cx| { + + let tag_select = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::new()), Vec::new(), @@ -175,9 +159,6 @@ impl RepoDetailView { let mut subscriptions = vec![ cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { - // `Change` fires only when the selection actually changed. - // Picking the already-selected branch emits nothing. - // A confirmed value always means a switch. if let ComboboxEvent::Change(values) = event && let Some(name) = values.first() { @@ -193,11 +174,7 @@ impl RepoDetailView { }), ]; - // The ready-to-contribute and ready-to-push banners are driven by the - // global checkouts store. It notifies on every recompute; compare the - // statuses of this repository so unrelated updates (the sidebar badges, - // other open panels) do not re-render this panel. - let checkouts = CheckoutsStore::global(cx); + // The ready-to-contribute and ready-to-push banners are driven by the global checkouts store. subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { if this.refresh_statuses(cx) { cx.notify(); @@ -213,25 +190,10 @@ impl RepoDetailView { dock_area, store: store.clone(), repo_started: false, - tree_state, + files, worktree: None, - worktree_paths: Vec::new(), - md: None, - code: None, - readme_name: None, - selected_file: None, - files: HashMap::new(), - file_order: VecDeque::new(), - preview_bytes: 0, - loading_files: HashSet::new(), - commits: HashMap::new(), - pending_commits: Vec::new(), - loading_commits: false, active_tab: 0, - all_commits: None, - loading_all_commits: false, - scroll_handle: VirtualListScrollHandle::new(), - item_sizes: Rc::new(Vec::new()), + history, loading: true, error: None, head_commit: None, @@ -241,6 +203,7 @@ impl RepoDetailView { ref_tags: Vec::new(), switching_ref: false, ref_generation: 0, + tasks: Vec::new(), banner_dismissed: HashSet::new(), ready_requested: false, ready_head: None, @@ -254,45 +217,65 @@ impl RepoDetailView { view } - /// The latest announcement of the repository, `None` while local-only or - /// until the store's first pass loads it. + /// The latest announcement of the repository, + /// + /// `None` while local-only or until the store's first pass loads it. fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { self.store.read(cx).announcement.as_ref() } - /// The announcement's name or ID for announced repositories, the directory - /// name for local ones. - fn display_name(&self, cx: &App) -> SharedString { - let store = self.store.read(cx); - - if store.addr().is_none() { - return store - .path - .as_ref() - .map(|path| { - SharedString::from( - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()), - ) - }) - .unwrap_or_default(); + /// The Files tab body, or the clone/initial-load spinner. + fn render_files_tab(&self, cx: &mut Context) -> AnyElement { + if self.loading { + return v_flex() + .flex_1() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child(Spinner::new().small()) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Cloning repository..."), + ) + .into_any_element(); } - store - .announcement - .as_ref() - .map(|announcement| { - announcement - .name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) - }) - .unwrap_or_default() + self.files.clone().into_any_element() } } +/// The announcement's name or ID for announced repositories, the directory name for local ones. +pub(super) fn repo_display_name(store: &RepoStore) -> SharedString { + if store.addr().is_none() { + return store + .path + .as_ref() + .map(|path| { + SharedString::from( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ) + }) + .unwrap_or_default(); + } + + store + .announcement + .as_ref() + .map(|announcement| { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + }) + .unwrap_or_default() +} + impl BasePanel for RepoDetailView { fn panel_name(&self) -> &'static str { "repo" @@ -301,7 +284,7 @@ impl BasePanel for RepoDetailView { impl Panel for RepoDetailView { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.display_name(cx) + repo_display_name(self.store.read(cx)) } } @@ -315,21 +298,10 @@ impl Focusable for RepoDetailView { impl Render for RepoDetailView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let tree_state = self.tree_state.clone(); - let view = cx.entity().downgrade(); - - let pane_title = self - .selected_file - .clone() - .or_else(|| self.readme_name.clone()) - .unwrap_or_else(|| "Overview".into()); - let banner = self .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 .read(cx) @@ -359,16 +331,8 @@ impl Render for RepoDetailView { ) }) .map(|this| match self.active_tab { - 0 => this.child( - h_flex() - .flex_1() - .w_full() - .overflow_hidden() - .child(Self::render_tree_column(tree_state, view, cx)) - .child(self.render_content_column(pane_title, cx)) - .into_any_element(), - ), - _ => this.child(self.render_commits_tab(cx)), + 0 => this.child(self.render_files_tab(cx)), + _ => this.child(self.history.clone()), }) } } diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs index 05ddb52..cc5ab79 100644 --- a/crates/workspace/src/views/repo/refs.rs +++ b/crates/workspace/src/views/repo/refs.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use anyhow::Error; use gpui::prelude::*; use gpui::{Context, Entity, SharedString, Window}; @@ -7,26 +5,27 @@ use gpui_component::combobox::ComboboxState; use gpui_component::searchable_list::SearchableVec; use super::{RefKind, RepoDetailView}; -use crate::views::tree::{build_tree_items, sorted_worktree_paths, tree_items}; +use crate::views::tree::{build_tree_items, sorted_worktree_paths}; impl RepoDetailView { - pub(super) fn switch_ref( + pub(super) fn switch_ref( &mut self, kind: RefKind, - name: SharedString, + name: T, window: &mut Window, cx: &mut Context, - ) { + ) where + T: Into, + { if self.switching_ref { return; } + let Some(worktree) = self.worktree.clone() else { return; }; - // Branches and tags are mutually exclusive states of HEAD. - // Selecting one clears the other selector. - // Remember the previous selections to restore them if the checkout fails. + let name = name.into(); let previous_branch = self.branch_select.read(cx).selected_value(); let previous_tag = self.tag_select.read(cx).selected_value(); @@ -40,8 +39,8 @@ impl RepoDetailView { .update(cx, |state, cx| state.clear_selection(cx)); } } + self.switching_ref = true; - // In-flight loads of the previous branch are discarded when they complete. self.ref_generation += 1; cx.notify(); @@ -76,7 +75,7 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); } fn restore_selection( @@ -113,45 +112,21 @@ impl RepoDetailView { match result { Ok((snapshot, tree, paths)) => { this.head_commit = snapshot.head_commit; - this.worktree_paths = paths; - // Rebuild the tree from scratch. - // Entries of the previous branch are gone. - // The expansion state goes with them. - this.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); + let readme_path = snapshot.readme_path; + let readme = snapshot.readme; + this.files.update(cx, |files, cx| { + files.clear_previews(); + files.apply_entries(tree, paths, cx); + files.set_readme(readme_path, readme, cx); }); - - // Drop cached previews and commits of the old branch. - this.selected_file = None; - this.files.clear(); - this.file_order.clear(); - this.preview_bytes = 0; - this.loading_files.clear(); - this.commits.clear(); - this.pending_commits.clear(); - this.loading_commits = false; - this.md = None; - this.code = None; - this.readme_name = None; - this.all_commits = None; - this.loading_all_commits = false; - - if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) { - this.readme_name = Some(path.to_string_lossy().into()); - this.load_commit(&path.to_string_lossy(), cx); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - this.load_all_commits(cx); + this.history.update(cx, |history, cx| history.reload(cx)); } Err(error) => { this.error = Some(error.to_string().into()); this.head_commit = None; - this.worktree_paths.clear(); // The tree may show files that no longer exist. - this.tree_state.update(cx, |state, cx| { - state.set_items(Vec::new(), cx); + this.files.update(cx, |files, cx| { + files.apply_entries(Vec::new(), Vec::new(), cx); }); } } @@ -161,16 +136,10 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); } - /// Refresh the file explorer, previews and commit list after the mirror - /// caught up with the remote. - /// - /// The checked-out branch fast-forwarded in place, so unlike - /// [`Self::reload_worktree`] this keeps the panel's selection and previews: - /// it rebuilds the tree, drops previews of files the refresh removed and - /// re-renders the README when it is on screen. + /// Refresh the file explorer, previews and commit list after the mirror caught up with the remote. pub(super) fn catch_up_worktree(&mut self, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; @@ -192,87 +161,37 @@ impl RepoDetailView { let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id) != this.head_commit.as_ref().map(|c| &c.id); + let files_changed = this + .files + .update(cx, |files, cx| files.catch_up(&snapshot, tree, paths, cx)); + // A fast-forward of a branch other than the checked-out // one leaves the worktree untouched. Rebuilding the tree // and re-parsing the README would flash the panel for // nothing, so it is a no-op. - if !head_changed && paths == this.worktree_paths { + if !head_changed && !files_changed { log::debug!("repo detail catch_up_worktree: no-op"); return; } - log::debug!( - "repo detail catch_up_worktree: head_changed={head_changed} entries={}", - paths.len() - ); - this.head_commit = snapshot.head_commit; - this.worktree_paths = paths; - this.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); - }); - - // Drop previews of files the refresh removed from the worktree, - // everything else stays put. - let present: HashSet = snapshot - .entries - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(); - - let mut previewed: Vec = Vec::new(); - previewed.extend(this.files.keys().cloned()); - previewed.extend(this.selected_file.clone().map(|p| p.to_string())); - - if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) { - previewed.push(path.to_string()); - } - - if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) { - previewed.push(path.to_string()); - } - - previewed.sort(); - previewed.dedup(); - - for path in previewed { - if !present.contains(&path) { - this.drop_preview_of(&path); - } - } - - // Re-render the README when it is on screen, i.e. when no file preview is open. - if this.selected_file.is_none() { - match snapshot.readme_path.zip(snapshot.readme) { - Some((path, bytes)) => { - this.readme_name = Some(path.to_string_lossy().into()); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - None => { - this.md = None; - this.readme_name = None; - } - } - } if head_changed { - this.all_commits = None; - this.loading_all_commits = false; - this.load_all_commits(cx); + this.history.update(cx, |history, cx| history.reload(cx)); } + + cx.notify(); } Err(error) => { this.error = Some(error.to_string().into()); + cx.notify(); } } - cx.notify(); })?; Ok(()) }); - task.detach(); + self.tasks.push(task); } } diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs index b67bb06..77bf495 100644 --- a/crates/workspace/src/views/repo/store.rs +++ b/crates/workspace/src/views/repo/store.rs @@ -5,15 +5,13 @@ use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; use super::RepoDetailView; impl RepoDetailView { - /// Switch a local repository into its NIP-34 mode after a successful init. - /// The store is kept, so the panel keeps its path and loaded worktree. - /// Drops the local scan identity so it leaves the sidebar's local section. pub(crate) fn apply_announcement( &mut self, announcement: Announcement, cx: &mut Context, ) { let path = self.store.read(cx).path.clone(); + if let Some(path) = path { LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); } @@ -43,10 +41,6 @@ impl RepoDetailView { self.refresh_ready_statuses(cx); } - /// Request the statuses of this repository again when the announced HEAD changes. - /// The HEAD is the base the checkouts are compared against. - /// Owned repositories are watched for unpushed commits. - /// Other repositories for ready-to-contribute checkouts. fn refresh_ready_statuses(&mut self, cx: &mut Context) { let Some(addr) = self.store.read(cx).addr().cloned() else { return; -- 2.54.0 From 93d96ca9ee2761328fa23d5c299a9e64e417e56d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 15:54:17 +0700 Subject: [PATCH 10/12] . --- crates/workspace/src/views/repo/banners.rs | 73 +++++++--- crates/workspace/src/views/repo/header.rs | 6 +- crates/workspace/src/views/repo/loading.rs | 72 ++-------- crates/workspace/src/views/repo/mod.rs | 108 ++++---------- crates/workspace/src/views/repo/refs.rs | 160 +++++++++++++++++---- crates/workspace/src/views/repo/store.rs | 11 +- 6 files changed, 242 insertions(+), 188 deletions(-) diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index db16bf0..86725b1 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -1,3 +1,6 @@ +use std::collections::HashSet; +use std::path::PathBuf; + use assets::CustomIconName; use gpui::prelude::*; use gpui::{AnyElement, App, Context, SharedString, div, transparent_white}; @@ -10,6 +13,48 @@ use signed_state::{Backend, CheckoutStatus, CheckoutsStore, pr_proposes_checkout use super::RepoDetailView; use crate::views::pull_requests::new::open_new_pull_panel; +#[derive(Default)] +pub(super) struct Banners { + dismissed: HashSet<(PathBuf, String)>, + ready_requested: bool, + /// Re-requested only when the announced HEAD or the base default changes. + ready_head: Option, + ready_statuses: Vec, + push_statuses: Vec, +} + +impl Banners { + pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool { + self.dismissed + .contains(&(status.path.clone(), status.branch.clone())) + } + + pub(super) fn dismiss(&mut self, status: &CheckoutStatus) { + self.dismissed + .insert((status.path.clone(), status.branch.clone())); + } + + pub(super) fn ready_requested_at(&self) -> (bool, &Option) { + (self.ready_requested, &self.ready_head) + } + + pub(super) fn mark_ready_requested(&mut self, head: Option) { + self.ready_requested = true; + self.ready_head = head; + } + + pub(super) fn set_statuses( + &mut self, + ready: Vec, + push: Vec, + ) -> bool { + let changed = ready != self.ready_statuses || push != self.push_statuses; + self.ready_statuses = ready; + self.push_statuses = push; + changed + } +} + impl RepoDetailView { fn ready_suggestion(&self, cx: &App) -> Option { let store = self.store.read(cx); @@ -23,10 +68,7 @@ impl RepoDetailView { let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr); 'status: for status in statuses { - if self - .banner_dismissed - .contains(&(status.path.clone(), status.branch.clone())) - { + if self.banners.dismissal(&status) { continue; } for pr in &store.pull_requests { @@ -55,17 +97,15 @@ impl RepoDetailView { let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr); - statuses.into_iter().find(|status| { - !self - .banner_dismissed - .contains(&(status.path.clone(), status.branch.clone())) - }) + statuses + .into_iter() + .find(|status| !self.banners.dismissal(status)) } pub(super) fn render_push_banner(&self, cx: &Context) -> Option { let status = self.push_suggestion(cx)?; - let key = (status.path.clone(), status.branch.clone()); let path = status.path.clone(); + let branch = status.branch.clone(); // The push busy flag lives on the store; it disables the banner's triggers. let pushing = self.store.read(cx).pushing; @@ -98,7 +138,7 @@ impl RepoDetailView { .text_xs() .font_semibold() .font_family(cx.theme().mono_font_family.clone()) - .child(status.branch), + .child(branch), ) .child("has") .child( @@ -138,7 +178,7 @@ impl RepoDetailView { .ghost() .disabled(pushing) .on_click(cx.listener(move |this, _ev, _window, cx| { - this.banner_dismissed.insert(key.clone()); + this.banners.dismiss(&status); cx.notify(); })), ), @@ -213,7 +253,8 @@ impl RepoDetailView { pub(super) fn render_ready_banner(&self, cx: &Context) -> Option { let status = self.ready_suggestion(cx)?; - let key = (status.path.clone(), status.branch.clone()); + let branch = status.branch.clone(); + let base = status.base.clone(); let commits = if status.ahead == 1 { SharedString::from("1 commit") @@ -244,7 +285,7 @@ impl RepoDetailView { .text_xs() .font_semibold() .font_family(cx.theme().mono_font_family.clone()) - .child(status.branch), + .child(branch), ) .child("is") .child( @@ -270,7 +311,7 @@ impl RepoDetailView { .text_xs() .font_semibold() .font_family(cx.theme().mono_font_family.clone()) - .child(status.base), + .child(base), ), ) .child( @@ -298,7 +339,7 @@ impl RepoDetailView { .small() .ghost() .on_click(cx.listener(move |this, _ev, _window, cx| { - this.banner_dismissed.insert(key.clone()); + this.banners.dismiss(&status); cx.notify(); })), ), diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs index f2f82d5..82999b1 100644 --- a/crates/workspace/src/views/repo/header.rs +++ b/crates/workspace/src/views/repo/header.rs @@ -475,7 +475,7 @@ impl RepoDetailView { fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { let commits_count = self.history.read(cx).commit_count(); - let worktree_empty = self.switching_ref || self.worktree.is_none(); + let worktree_empty = self.refs.switching_ref || self.worktree.is_none(); h_flex() .items_center() @@ -580,7 +580,7 @@ impl RepoDetailView { ) .child( div().w(px(120.)).child( - Combobox::new(&self.branch_select) + Combobox::new(&self.refs.branch_select) .placeholder("Branch") .appearance(false) .menu_width(px(200.)) @@ -594,7 +594,7 @@ impl RepoDetailView { ) .child( div().w(px(120.)).child( - Combobox::new(&self.tag_select) + Combobox::new(&self.refs.tag_select) .placeholder("Tag") .appearance(false) .menu_width(px(200.)) diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs index e0726d1..0fd4264 100644 --- a/crates/workspace/src/views/repo/loading.rs +++ b/crates/workspace/src/views/repo/loading.rs @@ -3,9 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::Error; use gix::Repository; use gpui::prelude::*; -use gpui::{Context, Entity, PathPromptOptions, SharedString, Window}; -use gpui_component::combobox::ComboboxState; -use gpui_component::searchable_list::SearchableVec; +use gpui::{Context, PathPromptOptions, SharedString, Window}; use nostr::prelude::Url; use signed_git::FileCommit; use signed_state::GitStore; @@ -185,25 +183,17 @@ impl RepoDetailView { let branches: Vec = branches.iter().map(Into::into).collect(); let tags: Vec = tags.iter().map(Into::into).collect(); - let branches_changed = Self::sync_ref_selector( - &this.branch_select, - &mut this.ref_branches, + let branches_changed = this.refs.set_branches( branches, current_branch.map(Into::into), window, cx, ); - let tags_changed = Self::sync_ref_selector( - &this.tag_select, - &mut this.ref_tags, - tags, - None, - window, - cx, - ); + let tags_changed = this.refs.set_tags(tags, window, cx); let new_head_commit = head_commit.as_ref().map(|c| &c.id); let current_head_commit = this.head_commit.as_ref().map(|c| &c.id); + let head_changed = new_head_commit != current_head_commit; this.head_commit = head_commit; @@ -263,50 +253,10 @@ impl RepoDetailView { let branches: Vec = branches.into_iter().map(Into::into).collect(); let tags: Vec = tags.into_iter().map(Into::into).collect(); - Self::sync_ref_selector( - &self.branch_select, - &mut self.ref_branches, - branches, - current_branch.map(Into::into), - window, - cx, - ); + self.refs + .set_branches(branches, current_branch.map(Into::into), window, cx); - Self::sync_ref_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx); - } - - /// Point a ref selector at `items`, selecting `selected` when given. - fn sync_ref_selector( - select: &Entity>>, - cached: &mut Vec, - items: Vec, - selected: Option, - window: &mut Window, - cx: &mut Context, - ) -> bool { - let items_changed = *cached != items; - - let selection_changed = selected - .as_ref() - .is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value)); - - if !items_changed && !selection_changed { - return false; - } - - select.update(cx, |state, cx| { - if items_changed { - state.set_items(SearchableVec::from(items.clone()), window, cx); - } - if let Some(value) = selected - && (items_changed || selection_changed) - { - state.set_selected_values(std::slice::from_ref(&value), window, cx); - } - }); - *cached = items; - - true + self.refs.set_tags(tags, window, cx); } pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { @@ -378,14 +328,15 @@ fn load_repo_data(repo: &Repository) -> Result { let entries = signed_git::worktree_entries(repo)?; let tree = build_tree_items(&entries); let readme_path = signed_git::find_readme(repo)?; + let readme = match &readme_path { Some(path) => signed_git::worktree_read(repo, path)?, None => None, }; + let worktree = repo.workdir().map(Path::to_path_buf); - // Ref listing is auxiliary UI. - // A broken ref must not prevent the explorer from loading. - // Failures degrade to empty selectors. + let head_commit = signed_git::head_commit(repo).unwrap_or(None); + let (branches, tags, current_branch) = match &worktree { Some(_) => ( signed_git::repo_branches(repo).unwrap_or_default(), @@ -394,7 +345,6 @@ fn load_repo_data(repo: &Repository) -> Result { ), None => (Vec::new(), Vec::new(), None), }; - let head_commit = signed_git::head_commit(repo).unwrap_or(None); Ok(RepoData { tree, diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index d30cdb5..18f772f 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::path::PathBuf; use anyhow::Error; @@ -9,13 +8,12 @@ use gpui::{ SharedString, Subscription, Task, WeakEntity, Window, div, }; use gpui_component::alert::Alert; -use gpui_component::combobox::{ComboboxEvent, ComboboxState}; -use gpui_component::searchable_list::SearchableVec; +use gpui_component::combobox::ComboboxEvent; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Sizable, v_flex}; use signed_core::{Announcement, RepoAddr}; use signed_git::FileCommit; -use signed_state::{CheckoutStatus, CheckoutsStore, RepoStore}; +use signed_state::{CheckoutsStore, RepoStore}; mod about; mod actions; @@ -30,8 +28,10 @@ mod store; pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel}; +use self::banners::Banners; use self::files::RepoFilesView; use self::history::RepoHistoryView; +use self::refs::RefSwitcher; #[derive(Clone, Copy, PartialEq, Eq)] enum RefKind { @@ -41,10 +41,6 @@ enum RefKind { Tag, } -/// Header actions dispatched by the header dropdown menus. -/// -/// `pub(crate)` because the pull-request list panel shares this action set, -/// offering the New-PR and Send-patch actions in its own dropdown. #[derive(Clone, Action, PartialEq, Eq)] #[action(namespace = repo, no_json)] pub(crate) enum RepoAction { @@ -74,38 +70,15 @@ pub struct RepoDetailView { loading: bool, error: Option, head_commit: Option, - branch_select: Entity>>, - tag_select: Entity>>, - /// Branch names currently in `branch_select`, for cheap no-op detection. - ref_branches: Vec, - /// Tag names currently in `tag_select`, for cheap no-op detection. - ref_tags: Vec, - switching_ref: bool, + refs: RefSwitcher, /// In-flight loads with an older generation are discarded when they complete. ref_generation: u64, tasks: Vec>>, _subscriptions: Vec, - /// `(path, branch)` ready-suggestions dismissed by the user, per panel. - banner_dismissed: HashSet<(PathBuf, String)>, - /// Whether the ready statuses were requested at all. - ready_requested: bool, - /// The announced HEAD they were last requested with. Re-requested only when - /// the HEAD, the base default, changes, e.g. when the store's first refresh - /// lands. - ready_head: Option, - /// The global checkouts store's ready-to-contribute statuses of this - /// repository, last seen when they drove a render. - /// - /// The store notifies on any recompute pass; the observer re-renders this - /// panel only when these slices changed. - ready_statuses: Vec, - push_statuses: Vec, + banners: Banners, } impl RepoDetailView { - /// `hint` is an announcement already in hand. It seeds the store's relays - /// and the explorer's clone URLs; without it the panel waits for the store - /// to load the announcement from the local database. pub fn new( dock_area: WeakEntity, addr: RepoAddr, @@ -136,42 +109,31 @@ impl RepoDetailView { let checkouts = CheckoutsStore::global(cx); let files = cx.new(RepoFilesView::new); let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone())); - - let branch_select = cx.new(|cx| { - ComboboxState::new( - SearchableVec::new(Vec::::new()), - Vec::new(), - window, - cx, - ) - .searchable(true) - }); - - let tag_select = cx.new(|cx| { - ComboboxState::new( - SearchableVec::new(Vec::::new()), - Vec::new(), - window, - cx, - ) - .searchable(true) - }); + let refs = RefSwitcher::new(window, cx); let mut subscriptions = vec![ - cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { - if let ComboboxEvent::Change(values) = event - && let Some(name) = values.first() - { - this.switch_ref(RefKind::Branch, name.clone(), window, cx); - } - }), - cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| { - if let ComboboxEvent::Change(values) = event - && let Some(name) = values.first() - { - this.switch_ref(RefKind::Tag, name.clone(), window, cx); - } - }), + cx.subscribe_in( + &refs.branch_select, + window, + |this, _state, event, window, cx| { + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Branch, name.clone(), window, cx); + } + }, + ), + cx.subscribe_in( + &refs.tag_select, + window, + |this, _state, event, window, cx| { + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Tag, name.clone(), window, cx); + } + }, + ), ]; // The ready-to-contribute and ready-to-push banners are driven by the global checkouts store. @@ -197,18 +159,10 @@ impl RepoDetailView { loading: true, error: None, head_commit: None, - branch_select, - tag_select, - ref_branches: Vec::new(), - ref_tags: Vec::new(), - switching_ref: false, + refs, ref_generation: 0, tasks: Vec::new(), - banner_dismissed: HashSet::new(), - ready_requested: false, - ready_head: None, - ready_statuses: Vec::new(), - push_statuses: Vec::new(), + banners: Banners::default(), focus_handle: cx.focus_handle(), _subscriptions: subscriptions, }; diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs index cc5ab79..c48a52a 100644 --- a/crates/workspace/src/views/repo/refs.rs +++ b/crates/workspace/src/views/repo/refs.rs @@ -1,12 +1,123 @@ use anyhow::Error; use gpui::prelude::*; -use gpui::{Context, Entity, SharedString, Window}; +use gpui::{App, Context, Entity, SharedString, Window}; use gpui_component::combobox::ComboboxState; use gpui_component::searchable_list::SearchableVec; use super::{RefKind, RepoDetailView}; use crate::views::tree::{build_tree_items, sorted_worktree_paths}; +pub(super) struct RefSwitcher { + pub(super) branch_select: Entity>>, + pub(super) tag_select: Entity>>, + ref_branches: Vec, + ref_tags: Vec, + pub(super) switching_ref: bool, +} + +impl RefSwitcher { + pub(super) fn new(window: &mut Window, cx: &mut App) -> Self { + let branch_select = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + let tag_select = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + + Self { + branch_select, + tag_select, + ref_branches: Vec::new(), + ref_tags: Vec::new(), + switching_ref: false, + } + } + + pub(super) fn set_branches( + &mut self, + branches: Vec, + selected: Option, + window: &mut Window, + cx: &mut App, + ) -> bool { + sync_selector( + &self.branch_select, + &mut self.ref_branches, + branches, + selected, + window, + cx, + ) + } + + pub(super) fn set_tags( + &mut self, + tags: Vec, + window: &mut Window, + cx: &mut App, + ) -> bool { + sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx) + } + + fn restore_selection( + &self, + select: &Entity>>, + previous: &Option, + window: &mut Window, + cx: &mut App, + ) { + select.update(cx, |state, cx| match previous { + Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), + None => state.clear_selection(cx), + }); + } +} + +fn sync_selector( + select: &Entity>>, + cached: &mut Vec, + items: Vec, + selected: Option, + window: &mut Window, + cx: &mut App, +) -> bool { + let items_changed = *cached != items; + + let selection_changed = selected + .as_ref() + .is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value)); + + if !items_changed && !selection_changed { + return false; + } + + select.update(cx, |state, cx| { + if items_changed { + state.set_items(SearchableVec::from(items.clone()), window, cx); + } + if let Some(value) = selected + && (items_changed || selection_changed) + { + state.set_selected_values(std::slice::from_ref(&value), window, cx); + } + }); + *cached = items; + + true +} + impl RepoDetailView { pub(super) fn switch_ref( &mut self, @@ -17,7 +128,7 @@ impl RepoDetailView { ) where T: Into, { - if self.switching_ref { + if self.refs.switching_ref { return; } @@ -26,21 +137,23 @@ impl RepoDetailView { }; let name = name.into(); - let previous_branch = self.branch_select.read(cx).selected_value(); - let previous_tag = self.tag_select.read(cx).selected_value(); + let previous_branch = self.refs.branch_select.read(cx).selected_value(); + let previous_tag = self.refs.tag_select.read(cx).selected_value(); match kind { RefKind::Branch => { - self.tag_select + self.refs + .tag_select .update(cx, |state, cx| state.clear_selection(cx)); } RefKind::Tag => { - self.branch_select + self.refs + .branch_select .update(cx, |state, cx| state.clear_selection(cx)); } } - self.switching_ref = true; + self.refs.switching_ref = true; self.ref_generation += 1; cx.notify(); @@ -64,9 +177,19 @@ impl RepoDetailView { Ok(()) => this.reload_worktree(cx), Err(error) => { this.error = Some(format!("Failed to check out {name}: {error}").into()); - this.switching_ref = false; - this.restore_selection(&this.branch_select, &previous_branch, window, cx); - this.restore_selection(&this.tag_select, &previous_tag, window, cx); + this.refs.switching_ref = false; + this.refs.restore_selection( + &this.refs.branch_select, + &previous_branch, + window, + cx, + ); + this.refs.restore_selection( + &this.refs.tag_select, + &previous_tag, + window, + cx, + ); } } cx.notify(); @@ -78,19 +201,6 @@ impl RepoDetailView { self.tasks.push(task); } - fn restore_selection( - &self, - select: &Entity>>, - previous: &Option, - window: &mut Window, - cx: &mut Context, - ) { - select.update(cx, |state, cx| match previous { - Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), - None => state.clear_selection(cx), - }); - } - fn reload_worktree(&mut self, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; @@ -108,7 +218,8 @@ impl RepoDetailView { .await; this.update(cx, |this, cx| { - this.switching_ref = false; + this.refs.switching_ref = false; + match result { Ok((snapshot, tree, paths)) => { this.head_commit = snapshot.head_commit; @@ -130,6 +241,7 @@ impl RepoDetailView { }); } } + cx.notify(); })?; diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs index 77bf495..8308984 100644 --- a/crates/workspace/src/views/repo/store.rs +++ b/crates/workspace/src/views/repo/store.rs @@ -47,13 +47,13 @@ impl RepoDetailView { }; let head = self.store.read(cx).head.clone(); + let (requested, requested_head) = self.banners.ready_requested_at(); - if self.ready_requested && self.ready_head == head { + if requested && requested_head == &head { return; } - self.ready_requested = true; - self.ready_head = head.clone(); + self.banners.mark_ready_requested(head.clone()); let backend = Backend::global(cx); let checkout = CheckoutsStore::global(cx); @@ -83,9 +83,6 @@ impl RepoDetailView { let ready_statuses = checkouts.ready_statuses_of(&addr); let push_statuses = checkouts.push_statuses_of(&addr); - let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses; - self.ready_statuses = ready_statuses; - self.push_statuses = push_statuses; - changed + self.banners.set_statuses(ready_statuses, push_statuses) } } -- 2.54.0 From 26653fa1245180b868d2afd9155056edb4020249 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 16:14:33 +0700 Subject: [PATCH 11/12] . --- crates/workspace/src/views/repo/actions.rs | 117 +- crates/workspace/src/views/repo/banners.rs | 306 +--- crates/workspace/src/views/repo/header.rs | 786 --------- crates/workspace/src/views/repo/loading.rs | 360 ---- crates/workspace/src/views/repo/mod.rs | 1820 +++++++++++++++++++- crates/workspace/src/views/repo/refs.rs | 198 +-- crates/workspace/src/views/repo/store.rs | 88 - 7 files changed, 1807 insertions(+), 1868 deletions(-) delete mode 100644 crates/workspace/src/views/repo/header.rs delete mode 100644 crates/workspace/src/views/repo/loading.rs delete mode 100644 crates/workspace/src/views/repo/store.rs diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs index cc4ffb9..e48c855 100644 --- a/crates/workspace/src/views/repo/actions.rs +++ b/crates/workspace/src/views/repo/actions.rs @@ -1,131 +1,16 @@ -use std::path::PathBuf; use std::sync::Arc; -use anyhow::Error; use dock::{DockArea, add_center_panel, panel_handle}; use gpui::prelude::*; -use gpui::{App, Context, Entity, WeakEntity, Window}; +use gpui::{App, Entity, WeakEntity, Window}; use gpui_base::dock::PanelView; use nostr::prelude::EventId; use signed_core::{Announcement, RepoAddr}; use signed_state::RepoStore; use super::RepoDetailView; -use crate::views::issues::IssuesView; use crate::views::issues::detail::IssueDetailView; -use crate::views::pull_requests::PullRequestsView; use crate::views::pull_requests::detail::PullRequestDetailView; -use crate::views::repo::init_dialog; - -impl RepoDetailView { - pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { - self.error = None; - cx.notify(); - - let task = self.store.update(cx, |store, cx| store.push_repository(cx)); - self.tasks.push(task); - } - - pub(super) fn push_unpushed_checkout( - &mut self, - path: PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - let store = self.store.clone(); - - if store.read(cx).pushing { - return; - } - - self.error = None; - cx.notify(); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let push = this.update_in(cx, |_this, _window, cx| { - store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) - })?; - - 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. - pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let task = self - .store - .update(cx, |store, cx| store.delete_repository(cx)); - self.tasks.push(task); - } - - pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { - if self.store.read(cx).addr().is_none() { - return; - } - - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - let store = self.store.clone(); - let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { - if self.store.read(cx).addr().is_none() { - return; - } - - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - let store = self.store.clone(); - let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - /// Open the upstream repository, the `u` tag of this fork's announcement. - /// - /// The announcement may not be in the local database yet. The panel opens - /// from the address and fills in when the store loads it; the store's - /// `subscribe_remote` fetches it from the bootstrap relays. - pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { - let Some(addr) = self - .announcement(cx) - .and_then(|announcement| announcement.upstream.as_ref()) - .and_then(|upstream| upstream.addr.clone()) - else { - return; - }; - - open_repo_panel(&self.dock_area, &addr, None, window, &mut *cx); - } - - /// Open the dialog that publishes the local repository to NIP-34. - pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(local_path) = self.store.read(cx).path.clone() else { - return; - }; - let view = cx.entity().downgrade(); - init_dialog::open(local_path, view, window, cx); - } -} /// Open repository as a panel in the dock's center. pub(crate) fn open_repo_panel( diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index 86725b1..06a4967 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -1,17 +1,7 @@ use std::collections::HashSet; use std::path::PathBuf; -use assets::CustomIconName; -use gpui::prelude::*; -use gpui::{AnyElement, App, Context, SharedString, div, transparent_white}; -use gpui_base::Disableable; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, h_flex}; -use signed_core::RepoStatus; -use signed_state::{Backend, CheckoutStatus, CheckoutsStore, pr_proposes_checkout}; - -use super::RepoDetailView; -use crate::views::pull_requests::new::open_new_pull_panel; +use signed_state::CheckoutStatus; #[derive(Default)] pub(super) struct Banners { @@ -54,297 +44,3 @@ impl Banners { changed } } - -impl RepoDetailView { - fn ready_suggestion(&self, cx: &App) -> Option { - let store = self.store.read(cx); - let addr = store.addr()?; - let user = Backend::global(cx).read(cx).current_user()?; - - if store.is_author(&user) { - return None; - } - - let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr); - - 'status: for status in statuses { - if self.banners.dismissal(&status) { - continue; - } - for pr in &store.pull_requests { - if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) - { - continue 'status; - } - } - return Some(status); - } - - None - } - - /// The first checkout of this owned repository with unpushed commits. - /// - /// Not dismissed in this panel. - fn push_suggestion(&self, cx: &App) -> Option { - let store = self.store.read(cx); - let addr = store.addr()?; - let user = Backend::global(cx).read(cx).current_user()?; - - if !store.is_author(&user) { - return None; - } - - let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr); - - statuses - .into_iter() - .find(|status| !self.banners.dismissal(status)) - } - - pub(super) fn render_push_banner(&self, cx: &Context) -> Option { - let status = self.push_suggestion(cx)?; - let path = status.path.clone(); - let branch = status.branch.clone(); - // The push busy flag lives on the store; it disables the banner's triggers. - let pushing = self.store.read(cx).pushing; - - let commits = if status.ahead == 1 { - SharedString::from("1 commit") - } else { - SharedString::from(format!("{} commits", status.ahead)) - }; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_center() - .justify_between() - .bg(cx.theme().muted) - .child( - 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(branch), - ) - .child("has") - .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("ready to push"), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("push-checkout-banner") - .icon(IconName::ArrowUp) - .label("Push") - .small() - .info() - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(move |this, _event, window, cx| { - this.push_unpushed_checkout(path.clone(), window, cx); - })), - ) - .child( - Button::new("close-repo") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .disabled(pushing) - .on_click(cx.listener(move |this, _ev, _window, cx| { - this.banners.dismiss(&status); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { - let store = self.store.read(cx); - let warning = store.last_push_warning.clone()?; - let pushing = store.pushing; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_start() - .justify_between() - .bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08)) - .child( - h_flex() - .gap_2() - .min_w_0() - .flex_1() - .items_start() - .child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0()) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_color(cx.theme().warning) - .child(SharedString::from(warning)), - ), - ) - .child( - h_flex() - .gap_1() - .flex_shrink_0() - .child( - Button::new("republish-after-partial-push") - .icon(CustomIconName::Init) - .label("Republish") - .small() - .info() - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(|this, _event, window, cx| { - this.push_repository(window, cx); - })), - ) - .child( - Button::new("dismiss-push-warning") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .disabled(pushing) - .on_click(cx.listener(|this, _ev, _window, cx| { - this.store.update(cx, |store, _| { - store.last_push_warning = None; - }); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - pub(super) fn render_ready_banner(&self, cx: &Context) -> Option { - let status = self.ready_suggestion(cx)?; - let branch = status.branch.clone(); - let base = status.base.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() - .w_full() - .items_center() - .justify_between() - .bg(cx.theme().muted) - .child( - 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(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(base), - ), - ) - .child( - 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| { - open_new_pull_panel( - this.dock_area.clone(), - this.store.clone(), - 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.banners.dismiss(&status); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } -} diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs deleted file mode 100644 index 82999b1..0000000 --- a/crates/workspace/src/views/repo/header.rs +++ /dev/null @@ -1,786 +0,0 @@ -use std::collections::HashSet; -use std::rc::Rc; - -use assets::CustomIconName; -use gpui::prelude::*; -use gpui::{Anchor, AnyElement, ClipboardItem, Context, SharedString, div, px, relative}; -use gpui_base::{Button as BaseButton, Disableable, Popover}; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::Combobox; -use gpui_component::menu::{DropdownMenu, PopupMenu}; -use gpui_component::{ - ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, -}; -use nostr::nips::nip19::Nip19Coordinate; -use nostr::prelude::{RelayUrl, ToBech32}; -use signed_core::Announcement; -use signed_state::{Backend, ProfileStore, RepoListStore}; -use signed_ui::{ - CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate, - ref_selector_trigger, -}; - -use super::{RepoAction, RepoDetailView, repo_display_name}; -use crate::views::issues::open_new_issue_dialog; -use crate::views::pull_requests::new::open_new_pull_panel; -use crate::views::repo::about::open_about_dialog; -use crate::views::send_patch::open_send_patch_panel; - -impl RepoDetailView { - pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { - if self.store.read(cx).addr().is_none() { - return self.render_local_header(cx); - } - - let store = self.store.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() else { - return div().into_any_element(); - }; - - let nip05 = ProfileStore::global(cx) - .read(cx) - .get(&source.owner) - .metadata() - .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}")); - let git_commands = Rc::new(announcement.clone_urls()); - - let name = repo_display_name(self.store.read(cx)); - let description = announcement.description(); - let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); - - v_flex() - .on_action( - cx.listener(|this, action: &RepoAction, window, cx| match action { - RepoAction::NewIssue => { - open_new_issue_dialog(this.store.clone(), window, cx); - } - RepoAction::NewPR => { - open_new_pull_panel(this.dock_area.clone(), this.store.clone(), window, cx); - } - RepoAction::SendPatch => { - open_send_patch_panel( - this.dock_area.clone(), - this.store.clone(), - window, - cx, - ); - } - RepoAction::About => { - if let Some(announcement) = this.announcement(cx) { - open_about_dialog(announcement.clone(), window, cx); - } - } - RepoAction::Push => this.push_repository(window, cx), - RepoAction::Delete => this.delete_repository(window, cx), - }), - ) - .p_4() - .w_full() - .gap_8() - .border_b_1() - .border_color(cx.theme().border) - .child( - h_flex() - .w_full() - .gap_4() - .items_start() - .justify_between() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - h_flex() - .gap_2() - .min_h_8() - .font_semibold() - .child(avatar.size_6()) - .child(name), - ) - .child( - div() - .min_w_0() - .text_sm() - .text_color(cx.theme().muted_foreground) - .line_clamp(2) - .line_height(relative(1.25)) - .text_ellipsis() - .child(description), - ) - .when_some(fork_row(&announcement, cx), |this, row| this.child(row)) - .child( - h_flex() - .mt_2() - .w_full() - .gap_0p5() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .font_semibold() - .child("Maintainers:"), - ) - .child(self.render_maintainers(cx)), - ), - ) - .child( - h_flex() - .flex_none() - .gap_2() - .justify_end() - .child( - DropdownButton::new("issues") - .action( - BaseButton::new("issues-open") - .child( - h_flex() - .h_8() - .px_2() - .gap_1() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .hover(|this| { - this.bg(cx.theme().secondary_hover) - }) - .text_sm() - .text_color(cx.theme().secondary_foreground) - .child(Icon::new(CustomIconName::GitIssueDone)) - .child("Issues") - .child( - div() - .mx_1() - .h_5() - .w_px() - .bg(cx.theme().border.darken(0.1)), - ) - .child(issue_count), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_issue_detail(window, cx); - })), - ) - .dropdown_menu(|menu, _, _| { - menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::Plus)) - .child("New issue") - }) - }), - ) - .child( - DropdownButton::new("prs") - .action( - BaseButton::new("prs-open") - .child( - h_flex() - .h_8() - .px_2() - .gap_1() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .hover(|this| { - this.bg(cx.theme().secondary_hover) - }) - .text_sm() - .text_color(cx.theme().secondary_foreground) - .child(Icon::new( - CustomIconName::GitPullRequest, - )) - .child("Pull Requests") - .child( - div() - .mx_1() - .h_5() - .w_px() - .bg(cx.theme().border.darken(0.1)), - ) - .child(pr_count), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.open_pull_request_detail(window, cx); - })), - ) - .dropdown_menu(|menu, _, _| { - menu.menu_element(Box::new(RepoAction::NewPR), |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::Plus)) - .child("New Pull Request") - }) - .menu_element( - Box::new(RepoAction::SendPatch), - |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::File)) - .child("Send Patch") - }, - ) - }), - ) - .child( - DropdownButton::new("share") - .action( - Button::new("link") - .icon(IconName::Copy) - .tooltip("Copy ID") - .secondary() - .on_click({ - let naddr = share.naddr.clone(); - move |_, _, cx| { - cx.write_to_clipboard( - ClipboardItem::new_string(naddr.clone()), - ); - } - }), - ) - .dropdown_menu(move |menu, _, _| share.menu(menu)), - ) - .child( - Button::new("repo-menu-open") - .icon(IconName::EllipsisVertical) - .tooltip("Repository management") - .compact() - .secondary() - .loading(pushing) - .disabled(pushing) - .dropdown_menu(move |menu, _, cx| { - let backend = Backend::global(cx); - let current_user = backend.read(cx).current_user(); - let owner = current_user == Some(announcement.owner); - - let menu = menu.menu_element( - Box::new(RepoAction::About), - |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(IconName::Info)) - .child("About") - }, - ); - - if owner { - menu.menu_element(Box::new(RepoAction::Push), |_, _| { - h_flex() - .gap_2() - .text_sm() - .child(Icon::new(CustomIconName::Init)) - .child("Republish") - }) - .separator() - .menu_element(Box::new(RepoAction::Delete), |_, cx| { - h_flex() - .gap_2() - .text_sm() - .text_color(cx.theme().danger) - .child(Icon::new(IconName::Delete)) - .child("Delete") - }) - } else { - menu - } - }), - ) - .child({ - let view = cx.entity(); - let ngit_command = ngit_command.clone(); - let nak_command = nak_command.clone(); - let git_commands = git_commands.clone(); - - Popover::new("clone") - .anchor(Anchor::TopRight) - .trigger( - Button::new("clone") - .icon(CustomIconName::GitClone) - .tooltip("Clone") - .loading(cloning) - .disabled(cloning) - .primary(), - ) - .content(move |_, _window, cx| { - let state = cx.entity(); - let ngit_row = copy_row("copy-ngit", &ngit_command, cx); - let nak_row = copy_row("copy-nak", &nak_command, cx); - - v_flex() - .w(px(440.)) - .mt_1() - .p_3() - .gap_4() - .popover_style(cx) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Clone with ngit"), - ) - .child(ngit_row), - ) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Clone with nak"), - ) - .child(nak_row), - ) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child("Grasp Servers"), - ) - .when(!git_commands.is_empty(), |this| { - this.children( - git_commands.iter().enumerate().map( - |(ix, cmd)| { - copy_row( - format!("copy-git-{ix}"), - cmd, - cx, - ) - }, - ), - ) - }) - .when(git_commands.is_empty(), |this| { - this.child( - div() - .text_xs() - .child("No git clone urls."), - ) - }), - ) - .child(div().h_px().w_full().bg(cx.theme().border)) - .child( - h_flex().gap_1().justify_end().child( - Button::new("download") - .icon(CustomIconName::GitClone) - .label("Download") - .primary() - .on_click(move |_event, window, cx| { - state.update(cx, |state, cx| { - state.dismiss(window, cx); - }); - view.update(cx, |this, cx| { - this.clone_to_folder(window, cx); - }); - }), - ), - ) - }) - }), - ), - ) - .child(self.render_header_tabs(cx)) - .into_any_element() - } - - fn render_local_header(&self, cx: &mut Context) -> AnyElement { - let name = repo_display_name(self.store.read(cx)); - let path = self - .store - .read(cx) - .path - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(); - let avatar = PixelAvatar::new(path.clone()); - - v_flex() - .px_4() - .pb_4() - .w_full() - .gap_8() - .border_b_1() - .border_color(cx.theme().border) - .child( - h_flex() - .w_full() - .gap_4() - .items_start() - .justify_between() - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .child( - h_flex() - .gap_2() - .min_h_8() - .font_semibold() - .child(avatar.size_6()) - .child(name), - ) - .child( - div() - .min_w_0() - .text_sm() - .text_color(cx.theme().muted_foreground) - .line_clamp(2) - .line_height(relative(1.25)) - .text_ellipsis() - .child(path), - ), - ) - .child( - Button::new("init") - .icon(CustomIconName::Init) - .label("Initialize on Nostr") - .primary() - .tooltip("Publish this repository to Nostr") - .on_click(cx.listener(|this, _event, window, cx| { - this.open_init_dialog(window, cx); - })), - ), - ) - .child(self.render_header_tabs(cx)) - .into_any_element() - } - - fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { - let commits_count = self.history.read(cx).commit_count(); - let worktree_empty = self.refs.switching_ref || self.worktree.is_none(); - - h_flex() - .items_center() - .gap_2() - .child( - BaseButton::new("files-tab") - .flex() - .items_center() - .h_8() - .px_2() - .gap_2() - .child( - h_flex() - .gap_1() - .text_sm() - .child(Icon::new(CustomIconName::GitFile).small()) - .child("Files"), - ) - .text_color(cx.theme().button_foreground) - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().button_hover)) - .active(|this| this.bg(cx.theme().button_active)) - .selected(self.active_tab == 0) - .when(self.active_tab == 0, |this| { - this.bg(cx.theme().button_active) - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.active_tab = 0; - cx.notify(); - })), - ) - .child( - BaseButton::new("commits-tab") - .flex() - .items_center() - .h_8() - .px_2() - .gap_2() - .child( - h_flex() - .gap_1() - .text_sm() - .child(Icon::new(CustomIconName::GitCommit).small()) - .child("Commits"), - ) - .when_some(commits_count, |this, count| { - this.child(CountBadge::new(count)) - }) - .text_color(cx.theme().button_foreground) - .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().button_hover)) - .active(|this| this.bg(cx.theme().button_active)) - .selected(self.active_tab == 1) - .when(self.active_tab == 1, |this| { - this.bg(cx.theme().button_active) - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.active_tab = 1; - cx.notify(); - })), - ) - .child( - h_flex() - .flex_1() - .gap_2() - .justify_end() - .child( - Button::new("enc") - .ghost() - .when_some(self.head_commit.as_ref(), |this, commit| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(&commit.id)), - ) - .child( - div() - .max_w(px(200.)) - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .text_xs() - .child(SharedString::from(&commit.summary)), - ) - }) - .tooltip( - self.head_commit - .as_ref() - .map_or_else(SharedString::default, |commit| { - commit.summary.clone().into() - }), - ) - .on_click(cx.listener(|this, _event, window, cx| { - if let Some(commit) = &this.head_commit { - let id = commit.id.clone(); - this.history.update(cx, |history, cx| { - history.open_commit_diff(&id, window, cx) - }); - } - })), - ) - .child( - div().w(px(120.)).child( - Combobox::new(&self.refs.branch_select) - .placeholder("Branch") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - ref_selector_trigger(ctx, CustomIconName::GitBranch, cx) - }), - ), - ) - .child( - div().w(px(120.)).child( - Combobox::new(&self.refs.tag_select) - .placeholder("Tag") - .appearance(false) - .menu_width(px(200.)) - .disabled(worktree_empty) - .bg(cx.theme().muted) - .rounded(cx.theme().radius) - .render_trigger(|ctx, _window, cx| { - ref_selector_trigger(ctx, CustomIconName::Tag, cx) - }), - ), - ), - ) - .into_any_element() - } - - fn render_maintainers(&self, cx: &mut Context) -> AnyElement { - let Some(announcement) = self.announcement(cx) else { - return div().into_any_element(); - }; - let profile_store = ProfileStore::global(cx); - - let mut seen = HashSet::new(); - let rest: Vec<_> = announcement - .maintainers - .iter() - .copied() - .filter(|key| key != &announcement.owner && seen.insert(*key)) - .collect(); - - let owner = profile_store.read(cx).get(&announcement.owner); - let owner_name = owner.name(); - let owner_picture = owner.picture(); - - h_flex() - .w_full() - .gap_3() - .child( - Button::new("maintainers").compact().ghost().child( - h_flex() - .gap_2() - .child( - h_flex() - .gap_1() - .child(UserAvatar::new(owner_name.clone()).picture(owner_picture)) - .child(div().text_xs().whitespace_nowrap().child(owner_name)), - ) - .when(!rest.is_empty(), |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!("+{}", rest.len()))), - ) - }), - ), - ) - .into_any_element() - } -} - -fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { - let owner = announcement.owner; - let user = nip05 - .map(str::to_owned) - .unwrap_or_else(|| owner.to_bech32().unwrap()); - - let mut url = format!("nostr://{user}"); - if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { - url.push('/'); - url.push_str(hint); - } - url.push('/'); - url.push_str(&announcement.id); - - SharedString::from(url) -} - -fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { - let upstream = announcement.upstream.as_ref()?; - - let (label, clickable) = match &upstream.addr { - Some(addr) => { - // Prefer the upstream's display name when its announcement is known locally. - // Fall back to its repository id otherwise. - let name = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == *addr) - .map(|a| { - a.name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(a.id.clone())) - }) - .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); - (SharedString::from(format!("Forked from {name}")), true) - } - None => (SharedString::from(upstream.display().as_str()), false), - }; - - let row = h_flex() - .gap_1() - .items_center() - .min_w_0() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(CustomIconName::GitBranch).small()) - .child(div().whitespace_nowrap().text_ellipsis().child(label)); - - Some(if clickable { - row.id("fork-upstream") - .cursor_pointer() - .hover(|this| this.text_color(cx.theme().foreground)) - .on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx))) - .into_any_element() - } else { - row.into_any_element() - }) -} - -struct ShareTargets { - /// NIP-19 `naddr1...` of the announcement, with its announced relays. - naddr: String, - /// Hex ID of the announcement event itself. - event_id: String, - /// NIP-34 coordinate `30617::`. - coordinate: String, - /// `https://gitworkshop.dev/` - gitworkshop: String, - /// `https://ditto.pub/` - ditto: String, -} - -impl ShareTargets { - fn from_announcement(announcement: &Announcement) -> Self { - let addr = announcement.addr(); - let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) - .to_bech32() - .expect("a complete coordinate always encodes to naddr"); - - Self { - naddr: naddr.clone(), - event_id: announcement.event_id.to_bech32().unwrap(), - coordinate, - gitworkshop: format!("https://gitworkshop.dev/{naddr}"), - ditto: format!("https://ditto.pub/{naddr}"), - } - } - - fn menu(&self, menu: PopupMenu) -> PopupMenu { - menu.min_w(px(340.)) - .item(menu_copy_row( - "copy-gitworkshop", - "GitWorkshop", - truncate_naddr_link(&self.gitworkshop, 4), - self.gitworkshop.clone(), - )) - .item(menu_copy_row( - "copy-ditto", - "Ditto", - truncate_naddr_link(&self.ditto, 4), - self.ditto.clone(), - )) - .item(menu_copy_row( - "copy-event-id", - "Event ID", - middle_truncate(&self.event_id, 10, 10), - self.event_id.clone(), - )) - .item(menu_copy_row( - "copy-coordinate", - "Coordinate", - middle_truncate(&self.coordinate, 10, 10), - self.coordinate.clone(), - )) - } -} - -fn truncate_naddr_link(url: &str, tail: usize) -> String { - let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { - return url.to_string(); - }; - if url.len() - end <= tail + 3 { - return url.to_string(); - } - format!("{}...{}", &url[..end], &url[url.len() - tail..]) -} diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs deleted file mode 100644 index 0fd4264..0000000 --- a/crates/workspace/src/views/repo/loading.rs +++ /dev/null @@ -1,360 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::Error; -use gix::Repository; -use gpui::prelude::*; -use gpui::{Context, PathPromptOptions, SharedString, Window}; -use nostr::prelude::Url; -use signed_git::FileCommit; -use signed_state::GitStore; - -use super::RepoDetailView; -use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; - -struct RepoData { - tree: Vec, - entries: Vec, - readme_path: Option, - readme: Option>, - worktree: Option, - branches: Vec, - tags: Vec, - current_branch: Option, - head_commit: Option, -} - -impl RepoDetailView { - /// Load the repository and populate the file explorer. - pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { - self.loading = true; - self.error = None; - cx.notify(); - - let (addr, announcement, local_path) = { - let store = self.store.read(cx); - ( - store.addr().cloned(), - store.announcement.clone(), - store.path.clone(), - ) - }; - - // Local repositories live on disk at their scan path. - // No clone step or network refresh applies here. - if addr.is_none() { - self.repo_started = true; - - let Some(local_path) = local_path else { - return; - }; - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let data = cx - .background_spawn(async move { - let repo = gix::open(&local_path)?; - load_repo_data(&repo) - }) - .await; - - this.update_in(cx, |this, window, cx| { - match data { - Ok(data) => this.apply_repo_data(data, window, cx), - Err(error) => this.error = Some(error.to_string().into()), - } - this.loading = false; - cx.notify(); - })?; - - Ok(()) - }); - - self.tasks.push(task); - - return; - } - - let Some(announcement) = announcement else { - return; - }; - self.repo_started = true; - - let cache = GitStore::global(cx).cache().clone(); - let addr = announcement.addr(); - let clone_urls: Vec = announcement.clone.clone(); - - // Captured before the loads start. - // A branch/tag switch bumps the generation, discarding the refresh below. - let refresh_generation = self.ref_generation; - - let disk = { - let cache = cache.clone(); - let addr = addr.clone(); - cx.background_spawn(async move { - match cache.open(&addr)? { - Some(repo) => Ok(Some(load_repo_data(&repo)?)), - None => Ok(None), - } - }) - }; - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let disk = disk.await; - let had_clone = matches!(&disk, Ok(Some(_))); - - let data = match disk { - Ok(Some(data)) => Ok(data), - Ok(None) => { - let cache = cache.clone(); - let addr = addr.clone(); - let clone_urls = clone_urls.clone(); - cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - load_repo_data(&repo) - }) - .await - } - Err(error) => Err(error), - }; - - this.update_in(cx, |this, window, cx| { - match data { - Ok(data) => this.apply_repo_data(data, window, cx), - Err(error) => this.error = Some(error.to_string().into()), - } - this.loading = false; - cx.notify(); - })?; - - // Refresh the clone from the network in the background. - // When it completes, update the refs and commit list. - // Loads started before a branch/tag switch are discarded via the generation. - if !had_clone { - return Ok(()); - } - - let refresh = { - let cache = cache.clone(); - let addr = addr.clone(); - - cx.background_spawn(async move { - 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. - // That covers own repo pushes from a checkout and updates fetched here. - // Fast-forward branches from the remote, like `git pull --ff-only`. - // Only the checked-out branch's worktree can change on disk. - let moved = match &worktree { - Some(worktree) => { - signed_git::fast_forward_branches(worktree).unwrap_or(false) - } - None => false, - }; - - let (branches, tags) = match &worktree { - Some(_) => ( - signed_git::repo_branches(&repo).unwrap_or_default(), - signed_git::repo_tags(&repo).unwrap_or_default(), - ), - 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))) - }) - } - .await; - - this.update_in(cx, |this, window, cx| { - if refresh_generation != this.ref_generation { - return; - } - - if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { - let branches: Vec = branches.iter().map(Into::into).collect(); - let tags: Vec = tags.iter().map(Into::into).collect(); - - let branches_changed = this.refs.set_branches( - branches, - current_branch.map(Into::into), - window, - cx, - ); - - let tags_changed = this.refs.set_tags(tags, window, cx); - let new_head_commit = head_commit.as_ref().map(|c| &c.id); - let current_head_commit = this.head_commit.as_ref().map(|c| &c.id); - - let head_changed = new_head_commit != current_head_commit; - this.head_commit = head_commit; - - if head_changed { - this.history.update(cx, |history, cx| history.reload(cx)); - } - - if moved { - this.catch_up_worktree(cx); - } - - if branches_changed || tags_changed || head_changed { - cx.notify(); - } - } - })?; - - Ok(()) - }); - - self.tasks.push(task); - } - - fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { - log::debug!("repo detail: apply_repo_data"); - let RepoData { - tree, - entries, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - } = data; - - let Some(worktree) = worktree else { - self.error = Some("Repository has no worktree".into()); - return; - }; - - self.worktree = Some(worktree.clone()); - self.head_commit = head_commit; - - self.files.update(cx, |files, cx| { - files.set_worktree(worktree.clone()); - files.apply_entries(tree, sorted_worktree_paths(&entries), cx); - files.set_readme(readme_path, readme, cx); - }); - - self.history.update(cx, |history, cx| { - history.set_worktree(Some(worktree)); - history.reload(cx); - }); - - let branches: Vec = branches.into_iter().map(Into::into).collect(); - let tags: Vec = tags.into_iter().map(Into::into).collect(); - - self.refs - .set_branches(branches, current_branch.map(Into::into), window, cx); - - self.refs.set_tags(tags, window, cx); - } - - pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { - let store = self.store.clone(); - - let name = { - let Some(announcement) = self.announcement(cx) else { - return; - }; - let addr = announcement.addr(); - // Directory name, the display name falling back to the repo id. - let name = announcement - .name - .as_ref() - .map(|name| name.to_string()) - .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| addr.identifier.clone()); - let name = signed_git::sanitize_path_component(&name); - - if name.is_empty() { - "repository".to_owned() - } else { - name - } - }; - - let prompt = cx.prompt_for_paths(PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: Some("Clone".into()), - }); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - // `Ok(Ok(Some(paths)))` means the user picked a folder. - // A cancel or picker failure resolves to anything else. - let picked = match prompt.await { - Ok(Ok(Some(mut paths))) => paths.pop(), - _ => None, - }; - let Some(folder) = picked else { - return Ok(()); - }; - - let destination = folder.join(&name); - let destination_for_open = destination.clone(); - - // 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(()) - }); - - self.tasks.push(task); - } -} - -fn load_repo_data(repo: &Repository) -> Result { - let entries = signed_git::worktree_entries(repo)?; - let tree = build_tree_items(&entries); - let readme_path = signed_git::find_readme(repo)?; - - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(repo, path)?, - None => None, - }; - - let worktree = repo.workdir().map(Path::to_path_buf); - let head_commit = signed_git::head_commit(repo).unwrap_or(None); - - let (branches, tags, current_branch) = match &worktree { - Some(_) => ( - signed_git::repo_branches(repo).unwrap_or_default(), - signed_git::repo_tags(repo).unwrap_or_default(), - signed_git::current_branch(repo).unwrap_or(None), - ), - None => (Vec::new(), Vec::new(), None), - }; - - Ok(RepoData { - tree, - entries, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - }) -} diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 18f772f..6673c54 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -1,30 +1,46 @@ -use std::path::PathBuf; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::rc::Rc; use anyhow::Error; -use dock::{BasePanel, DockArea, Panel, PanelEvent}; +use assets::CustomIconName; +use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; +use gix::Repository; use gpui::prelude::*; use gpui::{ - Action, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, - SharedString, Subscription, Task, WeakEntity, Window, div, + Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, + Focusable, PathPromptOptions, Render, SharedString, Subscription, Task, WeakEntity, Window, + div, px, relative, transparent_white, }; +use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::alert::Alert; -use gpui_component::combobox::ComboboxEvent; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::combobox::{Combobox, ComboboxEvent}; +use gpui_component::menu::{DropdownMenu, PopupMenu}; use gpui_component::spinner::Spinner; -use gpui_component::{ActiveTheme, Sizable, v_flex}; -use signed_core::{Announcement, RepoAddr}; +use gpui_component::{ + ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, +}; +use nostr::nips::nip19::Nip19Coordinate; +use nostr::prelude::{RelayUrl, ToBech32, Url}; +use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_git::FileCommit; -use signed_state::{CheckoutsStore, RepoStore}; +use signed_state::{ + Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore, + RepoListStore, RepoStore, pr_proposes_checkout, +}; +use signed_ui::{ + CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate, + ref_selector_trigger, +}; mod about; mod actions; mod banners; mod files; -mod header; mod history; mod init_dialog; -mod loading; mod refs; -mod store; pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel}; @@ -32,6 +48,12 @@ use self::banners::Banners; use self::files::RepoFilesView; use self::history::RepoHistoryView; use self::refs::RefSwitcher; +use crate::views::issues::{IssuesView, open_new_issue_dialog}; +use crate::views::pull_requests::PullRequestsView; +use crate::views::pull_requests::new::open_new_pull_panel; +use crate::views::repo::about::open_about_dialog; +use crate::views::send_patch::open_send_patch_panel; +use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; #[derive(Clone, Copy, PartialEq, Eq)] enum RefKind { @@ -56,26 +78,35 @@ pub struct RepoDetailView { focus_handle: FocusHandle, dock_area: WeakEntity, store: Entity, - /// A repository opened by address alone starts without an announcement; the - /// store observer starts the load once the first one lands. + /// A repository opened by address alone starts without an announcement. repo_started: bool, /// The Files tab, which owns the explorer, previews and the per-file commit map. files: Entity, /// The checked-out worktree path, shared by the Files tab and the commit list. worktree: Option, - /// 0 = Files tree, 1 = Commits. + /// The active tab, either 0 (Files tree) or 1 (Commits). active_tab: usize, - /// The Commits tab, which owns the commit list and the commit diff panels. history: Entity, loading: bool, error: Option, head_commit: Option, refs: RefSwitcher, - /// In-flight loads with an older generation are discarded when they complete. ref_generation: u64, + banners: Banners, tasks: Vec>>, _subscriptions: Vec, - banners: Banners, +} + +struct RepoData { + tree: Vec, + entries: Vec, + readme_path: Option, + readme: Option>, + worktree: Option, + branches: Vec, + tags: Vec, + current_branch: Option, + head_commit: Option, } impl RepoDetailView { @@ -171,6 +202,87 @@ impl RepoDetailView { view } + pub(crate) fn apply_announcement( + &mut self, + announcement: Announcement, + cx: &mut Context, + ) { + let path = self.store.read(cx).path.clone(); + + if let Some(path) = path { + LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); + } + + self.store + .update(cx, |store, cx| store.announce(announcement, cx)); + + // The new address needs its ready-to-contribute statuses requested. + self.refresh_ready_statuses(cx); + cx.notify(); + } + + pub(super) fn attach_store( + &mut self, + store: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + self._subscriptions + .push(cx.observe_in(store, window, |this, store, window, cx| { + this.refresh_ready_statuses(cx); + if !this.repo_started && store.read(cx).announcement.is_some() { + this.load_repo(window, cx); + } + cx.notify(); + })); + self.refresh_ready_statuses(cx); + } + + fn refresh_ready_statuses(&mut self, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let head = self.store.read(cx).head.clone(); + let (requested, requested_head) = self.banners.ready_requested_at(); + + if requested && requested_head == &head { + return; + } + + self.banners.mark_ready_requested(head.clone()); + + let backend = Backend::global(cx); + let checkout = CheckoutsStore::global(cx); + + let owned = backend + .read(cx) + .current_user() + .is_some_and(|user| self.store.read(cx).is_author(&user)); + + checkout.update(cx, |store, cx| { + // The ready statuses keep the fast poll running while the panel is open. + // The sidebar's push watch alone polls slower. + store.request_statuses(&addr, head, cx); + + if owned { + store.request_push_statuses(&addr, cx); + } + }); + } + + pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return false; + }; + + let checkouts = CheckoutsStore::global(cx).read(cx); + let ready_statuses = checkouts.ready_statuses_of(&addr); + let push_statuses = checkouts.push_statuses_of(&addr); + + self.banners.set_statuses(ready_statuses, push_statuses) + } + /// The latest announcement of the repository, /// /// `None` while local-only or until the store's first pass loads it. @@ -178,6 +290,1515 @@ impl RepoDetailView { self.store.read(cx).announcement.as_ref() } + /// Load the repository and populate the file explorer. + pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { + self.loading = true; + self.error = None; + cx.notify(); + + let (addr, announcement, local_path) = { + let store = self.store.read(cx); + ( + store.addr().cloned(), + store.announcement.clone(), + store.path.clone(), + ) + }; + + // Local repositories live on disk at their scan path. + // No clone step or network refresh applies here. + if addr.is_none() { + self.repo_started = true; + + let Some(local_path) = local_path else { + return; + }; + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let data = cx + .background_spawn(async move { + let repo = gix::open(&local_path)?; + load_repo_data(&repo) + }) + .await; + + this.update_in(cx, |this, window, cx| { + match data { + Ok(data) => this.apply_repo_data(data, window, cx), + Err(error) => this.error = Some(error.to_string().into()), + } + this.loading = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + + return; + } + + let Some(announcement) = announcement else { + return; + }; + self.repo_started = true; + + let cache = GitStore::global(cx).cache().clone(); + let addr = announcement.addr(); + let clone_urls: Vec = announcement.clone.clone(); + + // Captured before the loads start. + // A branch/tag switch bumps the generation, discarding the refresh below. + let refresh_generation = self.ref_generation; + + let disk = { + let cache = cache.clone(); + let addr = addr.clone(); + cx.background_spawn(async move { + match cache.open(&addr)? { + Some(repo) => Ok(Some(load_repo_data(&repo)?)), + None => Ok(None), + } + }) + }; + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let disk = disk.await; + let had_clone = matches!(&disk, Ok(Some(_))); + + let data = match disk { + Ok(Some(data)) => Ok(data), + Ok(None) => { + let cache = cache.clone(); + let addr = addr.clone(); + let clone_urls = clone_urls.clone(); + cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; + load_repo_data(&repo) + }) + .await + } + Err(error) => Err(error), + }; + + this.update_in(cx, |this, window, cx| { + match data { + Ok(data) => this.apply_repo_data(data, window, cx), + Err(error) => this.error = Some(error.to_string().into()), + } + this.loading = false; + cx.notify(); + })?; + + // Refresh the clone from the network in the background. + // When it completes, update the refs and commit list. + // Loads started before a branch/tag switch are discarded via the generation. + if !had_clone { + return Ok(()); + } + + let refresh = { + let cache = cache.clone(); + let addr = addr.clone(); + + cx.background_spawn(async move { + 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. + // That covers own repo pushes from a checkout and updates fetched here. + // Fast-forward branches from the remote, like `git pull --ff-only`. + // Only the checked-out branch's worktree can change on disk. + let moved = match &worktree { + Some(worktree) => { + signed_git::fast_forward_branches(worktree).unwrap_or(false) + } + None => false, + }; + + let (branches, tags) = match &worktree { + Some(_) => ( + signed_git::repo_branches(&repo).unwrap_or_default(), + signed_git::repo_tags(&repo).unwrap_or_default(), + ), + 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))) + }) + } + .await; + + this.update_in(cx, |this, window, cx| { + if refresh_generation != this.ref_generation { + return; + } + + if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { + let branches: Vec = branches.iter().map(Into::into).collect(); + let tags: Vec = tags.iter().map(Into::into).collect(); + + let branches_changed = this.refs.set_branches( + branches, + current_branch.map(Into::into), + window, + cx, + ); + + let tags_changed = this.refs.set_tags(tags, window, cx); + let new_head_commit = head_commit.as_ref().map(|c| &c.id); + let current_head_commit = this.head_commit.as_ref().map(|c| &c.id); + + let head_changed = new_head_commit != current_head_commit; + this.head_commit = head_commit; + + if head_changed { + this.history.update(cx, |history, cx| history.reload(cx)); + } + + if moved { + this.catch_up_worktree(cx); + } + + if branches_changed || tags_changed || head_changed { + cx.notify(); + } + } + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { + log::debug!("repo detail: apply_repo_data"); + let RepoData { + tree, + entries, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + } = data; + + let Some(worktree) = worktree else { + self.error = Some("Repository has no worktree".into()); + return; + }; + + self.worktree = Some(worktree.clone()); + self.head_commit = head_commit; + + self.files.update(cx, |files, cx| { + files.set_worktree(worktree.clone()); + files.apply_entries(tree, sorted_worktree_paths(&entries), cx); + files.set_readme(readme_path, readme, cx); + }); + + self.history.update(cx, |history, cx| { + history.set_worktree(Some(worktree)); + history.reload(cx); + }); + + let branches: Vec = branches.into_iter().map(Into::into).collect(); + let tags: Vec = tags.into_iter().map(Into::into).collect(); + + self.refs + .set_branches(branches, current_branch.map(Into::into), window, cx); + + self.refs.set_tags(tags, window, cx); + } + + pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { + let store = self.store.clone(); + + let name = { + let Some(announcement) = self.announcement(cx) else { + return; + }; + let addr = announcement.addr(); + // Directory name, the display name falling back to the repo id. + let name = announcement + .name + .as_ref() + .map(|name| name.to_string()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| addr.identifier.clone()); + let name = signed_git::sanitize_path_component(&name); + + if name.is_empty() { + "repository".to_owned() + } else { + name + } + }; + + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Clone".into()), + }); + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + // `Ok(Ok(Some(paths)))` means the user picked a folder. + // A cancel or picker failure resolves to anything else. + let picked = match prompt.await { + Ok(Ok(Some(mut paths))) => paths.pop(), + _ => None, + }; + let Some(folder) = picked else { + return Ok(()); + }; + + let destination = folder.join(&name); + let destination_for_open = destination.clone(); + + // 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(()) + }); + + self.tasks.push(task); + } + + fn switch_ref(&mut self, kind: RefKind, name: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { + if self.refs.switching_ref { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let name = name.into(); + let previous_branch = self.refs.branch_select.read(cx).selected_value(); + let previous_tag = self.refs.tag_select.read(cx).selected_value(); + + match kind { + RefKind::Branch => { + self.refs + .tag_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + RefKind::Tag => { + self.refs + .branch_select + .update(cx, |state, cx| state.clear_selection(cx)); + } + } + + self.refs.switching_ref = true; + self.ref_generation += 1; + cx.notify(); + + let checkout_name = name.clone(); + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let result = cx + .background_spawn(async move { + match kind { + RefKind::Branch => { + signed_git::worktree_checkout_branch(&worktree, &checkout_name) + } + RefKind::Tag => { + signed_git::worktree_checkout_tag(&worktree, &checkout_name) + } + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + match result { + Ok(()) => this.reload_worktree(cx), + Err(error) => { + this.error = Some(format!("Failed to check out {name}: {error}").into()); + this.refs.switching_ref = false; + this.refs.restore_selection( + &this.refs.branch_select, + &previous_branch, + window, + cx, + ); + this.refs.restore_selection( + &this.refs.tag_select, + &previous_tag, + window, + cx, + ); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + fn reload_worktree(&mut self, cx: &mut Context) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let snapshot = signed_git::worktree_snapshot(&worktree)?; + // Build the tree off the main thread, like [`Self::load_repo`]. + let tree = build_tree_items(&snapshot.entries); + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) + }) + .await; + + this.update(cx, |this, cx| { + this.refs.switching_ref = false; + + match result { + Ok((snapshot, tree, paths)) => { + this.head_commit = snapshot.head_commit; + let readme_path = snapshot.readme_path; + let readme = snapshot.readme; + this.files.update(cx, |files, cx| { + files.clear_previews(); + files.apply_entries(tree, paths, cx); + files.set_readme(readme_path, readme, cx); + }); + this.history.update(cx, |history, cx| history.reload(cx)); + } + Err(error) => { + this.error = Some(error.to_string().into()); + this.head_commit = None; + // The tree may show files that no longer exist. + this.files.update(cx, |files, cx| { + files.apply_entries(Vec::new(), Vec::new(), cx); + }); + } + } + + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Refresh the file explorer, previews and commit list after the mirror caught up with the remote. + pub(super) fn catch_up_worktree(&mut self, cx: &mut Context) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let snapshot = signed_git::worktree_snapshot(&worktree)?; + let tree = build_tree_items(&snapshot.entries); + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) + }) + .await; + + this.update(cx, |this, cx| { + match result { + Ok((snapshot, tree, paths)) => { + let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id) + != this.head_commit.as_ref().map(|c| &c.id); + + let files_changed = this + .files + .update(cx, |files, cx| files.catch_up(&snapshot, tree, paths, cx)); + + // A fast-forward of a branch other than the checked-out + // one leaves the worktree untouched. Rebuilding the tree + // and re-parsing the README would flash the panel for + // nothing, so it is a no-op. + if !head_changed && !files_changed { + log::debug!("repo detail catch_up_worktree: no-op"); + return; + } + + this.head_commit = snapshot.head_commit; + + if head_changed { + this.history.update(cx, |history, cx| history.reload(cx)); + } + + cx.notify(); + } + Err(error) => { + this.error = Some(error.to_string().into()); + cx.notify(); + } + } + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { + self.error = None; + cx.notify(); + + let task = self.store.update(cx, |store, cx| store.push_repository(cx)); + self.tasks.push(task); + } + + pub(super) fn push_unpushed_checkout( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let store = self.store.clone(); + + if store.read(cx).pushing { + return; + } + + self.error = None; + cx.notify(); + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let push = this.update_in(cx, |_this, _window, cx| { + store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) + })?; + + 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. + pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let task = self + .store + .update(cx, |store, cx| store.delete_repository(cx)); + self.tasks.push(task); + } + + pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { + if self.store.read(cx).addr().is_none() { + return; + } + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let store = self.store.clone(); + let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } + + pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { + if self.store.read(cx).addr().is_none() { + return; + } + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let store = self.store.clone(); + let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } + + /// Open the upstream repository, the `u` tag of this fork's announcement. + /// + /// The announcement may not be in the local database yet. The panel opens + /// from the address and fills in when the store loads it; the store's + /// `subscribe_remote` fetches it from the bootstrap relays. + pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self + .announcement(cx) + .and_then(|announcement| announcement.upstream.as_ref()) + .and_then(|upstream| upstream.addr.clone()) + else { + return; + }; + + open_repo_panel(&self.dock_area, &addr, None, window, &mut *cx); + } + + /// Open the dialog that publishes the local repository to NIP-34. + pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(local_path) = self.store.read(cx).path.clone() else { + return; + }; + let view = cx.entity().downgrade(); + init_dialog::open(local_path, view, window, cx); + } + + pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { + if self.store.read(cx).addr().is_none() { + return self.render_local_header(cx); + } + + let store = self.store.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() else { + return div().into_any_element(); + }; + + let nip05 = ProfileStore::global(cx) + .read(cx) + .get(&source.owner) + .metadata() + .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}")); + let git_commands = Rc::new(announcement.clone_urls()); + + let name = repo_display_name(self.store.read(cx)); + let description = announcement.description(); + let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); + + v_flex() + .on_action( + cx.listener(|this, action: &RepoAction, window, cx| match action { + RepoAction::NewIssue => { + open_new_issue_dialog(this.store.clone(), window, cx); + } + RepoAction::NewPR => { + open_new_pull_panel(this.dock_area.clone(), this.store.clone(), window, cx); + } + RepoAction::SendPatch => { + open_send_patch_panel( + this.dock_area.clone(), + this.store.clone(), + window, + cx, + ); + } + RepoAction::About => { + if let Some(announcement) = this.announcement(cx) { + open_about_dialog(announcement.clone(), window, cx); + } + } + RepoAction::Push => this.push_repository(window, cx), + RepoAction::Delete => this.delete_repository(window, cx), + }), + ) + .p_4() + .w_full() + .gap_8() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .min_h_8() + .font_semibold() + .child(avatar.size_6()) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .line_clamp(2) + .line_height(relative(1.25)) + .text_ellipsis() + .child(description), + ) + .when_some(fork_row(&announcement, cx), |this, row| this.child(row)) + .child( + h_flex() + .mt_2() + .w_full() + .gap_0p5() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .font_semibold() + .child("Maintainers:"), + ) + .child(self.render_maintainers(cx)), + ), + ) + .child( + h_flex() + .flex_none() + .gap_2() + .justify_end() + .child( + DropdownButton::new("issues") + .action( + BaseButton::new("issues-open") + .child( + h_flex() + .h_8() + .px_2() + .gap_1() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary) + .hover(|this| { + this.bg(cx.theme().secondary_hover) + }) + .text_sm() + .text_color(cx.theme().secondary_foreground) + .child(Icon::new(CustomIconName::GitIssueDone)) + .child("Issues") + .child( + div() + .mx_1() + .h_5() + .w_px() + .bg(cx.theme().border.darken(0.1)), + ) + .child(issue_count), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_issue_detail(window, cx); + })), + ) + .dropdown_menu(|menu, _, _| { + menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New issue") + }) + }), + ) + .child( + DropdownButton::new("prs") + .action( + BaseButton::new("prs-open") + .child( + h_flex() + .h_8() + .px_2() + .gap_1() + .rounded(cx.theme().radius) + .bg(cx.theme().secondary) + .hover(|this| { + this.bg(cx.theme().secondary_hover) + }) + .text_sm() + .text_color(cx.theme().secondary_foreground) + .child(Icon::new( + CustomIconName::GitPullRequest, + )) + .child("Pull Requests") + .child( + div() + .mx_1() + .h_5() + .w_px() + .bg(cx.theme().border.darken(0.1)), + ) + .child(pr_count), + ) + .on_click(cx.listener(|this, _event, window, cx| { + this.open_pull_request_detail(window, cx); + })), + ) + .dropdown_menu(|menu, _, _| { + menu.menu_element(Box::new(RepoAction::NewPR), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New Pull Request") + }) + .menu_element( + Box::new(RepoAction::SendPatch), + |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::File)) + .child("Send Patch") + }, + ) + }), + ) + .child( + DropdownButton::new("share") + .action( + Button::new("link") + .icon(IconName::Copy) + .tooltip("Copy ID") + .secondary() + .on_click({ + let naddr = share.naddr.clone(); + move |_, _, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string(naddr.clone()), + ); + } + }), + ) + .dropdown_menu(move |menu, _, _| share.menu(menu)), + ) + .child( + Button::new("repo-menu-open") + .icon(IconName::EllipsisVertical) + .tooltip("Repository management") + .compact() + .secondary() + .loading(pushing) + .disabled(pushing) + .dropdown_menu(move |menu, _, cx| { + let backend = Backend::global(cx); + let current_user = backend.read(cx).current_user(); + let owner = current_user == Some(announcement.owner); + + let menu = menu.menu_element( + Box::new(RepoAction::About), + |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Info)) + .child("About") + }, + ); + + if owner { + menu.menu_element(Box::new(RepoAction::Push), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(CustomIconName::Init)) + .child("Republish") + }) + .separator() + .menu_element(Box::new(RepoAction::Delete), |_, cx| { + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().danger) + .child(Icon::new(IconName::Delete)) + .child("Delete") + }) + } else { + menu + } + }), + ) + .child({ + let view = cx.entity(); + let ngit_command = ngit_command.clone(); + let nak_command = nak_command.clone(); + let git_commands = git_commands.clone(); + + Popover::new("clone") + .anchor(Anchor::TopRight) + .trigger( + Button::new("clone") + .icon(CustomIconName::GitClone) + .tooltip("Clone") + .loading(cloning) + .disabled(cloning) + .primary(), + ) + .content(move |_, _window, cx| { + let state = cx.entity(); + let ngit_row = copy_row("copy-ngit", &ngit_command, cx); + let nak_row = copy_row("copy-nak", &nak_command, cx); + + v_flex() + .w(px(440.)) + .mt_1() + .p_3() + .gap_4() + .popover_style(cx) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Clone with ngit"), + ) + .child(ngit_row), + ) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Clone with nak"), + ) + .child(nak_row), + ) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Grasp Servers"), + ) + .when(!git_commands.is_empty(), |this| { + this.children( + git_commands.iter().enumerate().map( + |(ix, cmd)| { + copy_row( + format!("copy-git-{ix}"), + cmd, + cx, + ) + }, + ), + ) + }) + .when(git_commands.is_empty(), |this| { + this.child( + div() + .text_xs() + .child("No git clone urls."), + ) + }), + ) + .child(div().h_px().w_full().bg(cx.theme().border)) + .child( + h_flex().gap_1().justify_end().child( + Button::new("download") + .icon(CustomIconName::GitClone) + .label("Download") + .primary() + .on_click(move |_event, window, cx| { + state.update(cx, |state, cx| { + state.dismiss(window, cx); + }); + view.update(cx, |this, cx| { + this.clone_to_folder(window, cx); + }); + }), + ), + ) + }) + }), + ), + ) + .child(self.render_header_tabs(cx)) + .into_any_element() + } + + fn render_local_header(&self, cx: &mut Context) -> AnyElement { + let name = repo_display_name(self.store.read(cx)); + let path = self + .store + .read(cx) + .path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + let avatar = PixelAvatar::new(path.clone()); + + v_flex() + .px_4() + .pb_4() + .w_full() + .gap_8() + .border_b_1() + .border_color(cx.theme().border) + .child( + h_flex() + .w_full() + .gap_4() + .items_start() + .justify_between() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .min_h_8() + .font_semibold() + .child(avatar.size_6()) + .child(name), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .line_clamp(2) + .line_height(relative(1.25)) + .text_ellipsis() + .child(path), + ), + ) + .child( + Button::new("init") + .icon(CustomIconName::Init) + .label("Initialize on Nostr") + .primary() + .tooltip("Publish this repository to Nostr") + .on_click(cx.listener(|this, _event, window, cx| { + this.open_init_dialog(window, cx); + })), + ), + ) + .child(self.render_header_tabs(cx)) + .into_any_element() + } + + fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { + let commits_count = self.history.read(cx).commit_count(); + let worktree_empty = self.refs.switching_ref || self.worktree.is_none(); + + h_flex() + .items_center() + .gap_2() + .child( + BaseButton::new("files-tab") + .flex() + .items_center() + .h_8() + .px_2() + .gap_2() + .child( + h_flex() + .gap_1() + .text_sm() + .child(Icon::new(CustomIconName::GitFile).small()) + .child("Files"), + ) + .text_color(cx.theme().button_foreground) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().button_hover)) + .active(|this| this.bg(cx.theme().button_active)) + .selected(self.active_tab == 0) + .when(self.active_tab == 0, |this| { + this.bg(cx.theme().button_active) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.active_tab = 0; + cx.notify(); + })), + ) + .child( + BaseButton::new("commits-tab") + .flex() + .items_center() + .h_8() + .px_2() + .gap_2() + .child( + h_flex() + .gap_1() + .text_sm() + .child(Icon::new(CustomIconName::GitCommit).small()) + .child("Commits"), + ) + .when_some(commits_count, |this, count| { + this.child(CountBadge::new(count)) + }) + .text_color(cx.theme().button_foreground) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().button_hover)) + .active(|this| this.bg(cx.theme().button_active)) + .selected(self.active_tab == 1) + .when(self.active_tab == 1, |this| { + this.bg(cx.theme().button_active) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.active_tab = 1; + cx.notify(); + })), + ) + .child( + h_flex() + .flex_1() + .gap_2() + .justify_end() + .child( + Button::new("enc") + .ghost() + .when_some(self.head_commit.as_ref(), |this, commit| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&commit.id)), + ) + .child( + div() + .max_w(px(200.)) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_xs() + .child(SharedString::from(&commit.summary)), + ) + }) + .tooltip( + self.head_commit + .as_ref() + .map_or_else(SharedString::default, |commit| { + commit.summary.clone().into() + }), + ) + .on_click(cx.listener(|this, _event, window, cx| { + if let Some(commit) = &this.head_commit { + let id = commit.id.clone(); + this.history.update(cx, |history, cx| { + history.open_commit_diff(&id, window, cx) + }); + } + })), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.refs.branch_select) + .placeholder("Branch") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + ref_selector_trigger(ctx, CustomIconName::GitBranch, cx) + }), + ), + ) + .child( + div().w(px(120.)).child( + Combobox::new(&self.refs.tag_select) + .placeholder("Tag") + .appearance(false) + .menu_width(px(200.)) + .disabled(worktree_empty) + .bg(cx.theme().muted) + .rounded(cx.theme().radius) + .render_trigger(|ctx, _window, cx| { + ref_selector_trigger(ctx, CustomIconName::Tag, cx) + }), + ), + ), + ) + .into_any_element() + } + + fn render_maintainers(&self, cx: &mut Context) -> AnyElement { + let Some(announcement) = self.announcement(cx) else { + return div().into_any_element(); + }; + let profile_store = ProfileStore::global(cx); + + let mut seen = HashSet::new(); + let rest: Vec<_> = announcement + .maintainers + .iter() + .copied() + .filter(|key| key != &announcement.owner && seen.insert(*key)) + .collect(); + + let owner = profile_store.read(cx).get(&announcement.owner); + let owner_name = owner.name(); + let owner_picture = owner.picture(); + + h_flex() + .w_full() + .gap_3() + .child( + Button::new("maintainers").compact().ghost().child( + h_flex() + .gap_2() + .child( + h_flex() + .gap_1() + .child(UserAvatar::new(owner_name.clone()).picture(owner_picture)) + .child(div().text_xs().whitespace_nowrap().child(owner_name)), + ) + .when(!rest.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!("+{}", rest.len()))), + ) + }), + ), + ) + .into_any_element() + } + + fn ready_suggestion(&self, cx: &App) -> Option { + let store = self.store.read(cx); + let addr = store.addr()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if store.is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr); + + 'status: for status in statuses { + if self.banners.dismissal(&status) { + continue; + } + for pr in &store.pull_requests { + if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) + { + continue 'status; + } + } + return Some(status); + } + + None + } + + /// The first checkout of this owned repository with unpushed commits. + /// + /// Not dismissed in this panel. + fn push_suggestion(&self, cx: &App) -> Option { + let store = self.store.read(cx); + let addr = store.addr()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if !store.is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr); + + statuses + .into_iter() + .find(|status| !self.banners.dismissal(status)) + } + + pub(super) fn render_push_banner(&self, cx: &Context) -> Option { + let status = self.push_suggestion(cx)?; + let path = status.path.clone(); + let branch = status.branch.clone(); + // The push busy flag lives on the store; it disables the banner's triggers. + let pushing = self.store.read(cx).pushing; + + let commits = if status.ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", status.ahead)) + }; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( + 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(branch), + ) + .child("has") + .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("ready to push"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("push-checkout-banner") + .icon(IconName::ArrowUp) + .label("Push") + .small() + .info() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(move |this, _event, window, cx| { + this.push_unpushed_checkout(path.clone(), window, cx); + })), + ) + .child( + Button::new("close-repo") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .disabled(pushing) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.banners.dismiss(&status); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { + let store = self.store.read(cx); + let warning = store.last_push_warning.clone()?; + let pushing = store.pushing; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_start() + .justify_between() + .bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08)) + .child( + h_flex() + .gap_2() + .min_w_0() + .flex_1() + .items_start() + .child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0()) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_color(cx.theme().warning) + .child(SharedString::from(warning)), + ), + ) + .child( + h_flex() + .gap_1() + .flex_shrink_0() + .child( + Button::new("republish-after-partial-push") + .icon(CustomIconName::Init) + .label("Republish") + .small() + .info() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(|this, _event, window, cx| { + this.push_repository(window, cx); + })), + ) + .child( + Button::new("dismiss-push-warning") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .disabled(pushing) + .on_click(cx.listener(|this, _ev, _window, cx| { + this.store.update(cx, |store, _| { + store.last_push_warning = None; + }); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + pub(super) fn render_ready_banner(&self, cx: &Context) -> Option { + let status = self.ready_suggestion(cx)?; + let branch = status.branch.clone(); + let base = status.base.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() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( + 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(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(base), + ), + ) + .child( + 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| { + open_new_pull_panel( + this.dock_area.clone(), + this.store.clone(), + 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.banners.dismiss(&status); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + /// The Files tab body, or the clone/initial-load spinner. fn render_files_tab(&self, cx: &mut Context) -> AnyElement { if self.loading { @@ -201,6 +1822,171 @@ impl RepoDetailView { } } +fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { + let owner = announcement.owner; + let user = nip05 + .map(str::to_owned) + .unwrap_or_else(|| owner.to_bech32().unwrap()); + + let mut url = format!("nostr://{user}"); + if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { + url.push('/'); + url.push_str(hint); + } + url.push('/'); + url.push_str(&announcement.id); + + SharedString::from(url) +} + +fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { + let upstream = announcement.upstream.as_ref()?; + + let (label, clickable) = match &upstream.addr { + Some(addr) => { + // Prefer the upstream's display name when its announcement is known locally. + // Fall back to its repository id otherwise. + let name = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|a| a.addr() == *addr) + .map(|a| { + a.name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(a.id.clone())) + }) + .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); + (SharedString::from(format!("Forked from {name}")), true) + } + None => (SharedString::from(upstream.display().as_str()), false), + }; + + let row = h_flex() + .gap_1() + .items_center() + .min_w_0() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(CustomIconName::GitBranch).small()) + .child(div().whitespace_nowrap().text_ellipsis().child(label)); + + Some(if clickable { + row.id("fork-upstream") + .cursor_pointer() + .hover(|this| this.text_color(cx.theme().foreground)) + .on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx))) + .into_any_element() + } else { + row.into_any_element() + }) +} + +struct ShareTargets { + /// NIP-19 `naddr1...` of the announcement, with its announced relays. + naddr: String, + /// Hex ID of the announcement event itself. + event_id: String, + /// NIP-34 coordinate `30617::`. + coordinate: String, + /// `https://gitworkshop.dev/` + gitworkshop: String, + /// `https://ditto.pub/` + ditto: String, +} + +impl ShareTargets { + fn from_announcement(announcement: &Announcement) -> Self { + let addr = announcement.addr(); + let coordinate = addr.to_string(); + let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) + .to_bech32() + .expect("a complete coordinate always encodes to naddr"); + + Self { + naddr: naddr.clone(), + event_id: announcement.event_id.to_bech32().unwrap(), + coordinate, + gitworkshop: format!("https://gitworkshop.dev/{naddr}"), + ditto: format!("https://ditto.pub/{naddr}"), + } + } + + fn menu(&self, menu: PopupMenu) -> PopupMenu { + menu.min_w(px(340.)) + .item(menu_copy_row( + "copy-gitworkshop", + "GitWorkshop", + truncate_naddr_link(&self.gitworkshop, 4), + self.gitworkshop.clone(), + )) + .item(menu_copy_row( + "copy-ditto", + "Ditto", + truncate_naddr_link(&self.ditto, 4), + self.ditto.clone(), + )) + .item(menu_copy_row( + "copy-event-id", + "Event ID", + middle_truncate(&self.event_id, 10, 10), + self.event_id.clone(), + )) + .item(menu_copy_row( + "copy-coordinate", + "Coordinate", + middle_truncate(&self.coordinate, 10, 10), + self.coordinate.clone(), + )) + } +} + +fn truncate_naddr_link(url: &str, tail: usize) -> String { + let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { + return url.to_string(); + }; + if url.len() - end <= tail + 3 { + return url.to_string(); + } + format!("{}...{}", &url[..end], &url[url.len() - tail..]) +} + +fn load_repo_data(repo: &Repository) -> Result { + let entries = signed_git::worktree_entries(repo)?; + let tree = build_tree_items(&entries); + let readme_path = signed_git::find_readme(repo)?; + + let readme = match &readme_path { + Some(path) => signed_git::worktree_read(repo, path)?, + None => None, + }; + + let worktree = repo.workdir().map(Path::to_path_buf); + let head_commit = signed_git::head_commit(repo).unwrap_or(None); + + let (branches, tags, current_branch) = match &worktree { + Some(_) => ( + signed_git::repo_branches(repo).unwrap_or_default(), + signed_git::repo_tags(repo).unwrap_or_default(), + signed_git::current_branch(repo).unwrap_or(None), + ), + None => (Vec::new(), Vec::new(), None), + }; + + Ok(RepoData { + tree, + entries, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + }) +} + /// The announcement's name or ID for announced repositories, the directory name for local ones. pub(super) fn repo_display_name(store: &RepoStore) -> SharedString { if store.addr().is_none() { diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs index c48a52a..0c1bb03 100644 --- a/crates/workspace/src/views/repo/refs.rs +++ b/crates/workspace/src/views/repo/refs.rs @@ -1,12 +1,8 @@ -use anyhow::Error; use gpui::prelude::*; -use gpui::{App, Context, Entity, SharedString, Window}; +use gpui::{App, Entity, SharedString, Window}; use gpui_component::combobox::ComboboxState; use gpui_component::searchable_list::SearchableVec; -use super::{RefKind, RepoDetailView}; -use crate::views::tree::{build_tree_items, sorted_worktree_paths}; - pub(super) struct RefSwitcher { pub(super) branch_select: Entity>>, pub(super) tag_select: Entity>>, @@ -71,7 +67,7 @@ impl RefSwitcher { sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx) } - fn restore_selection( + pub(super) fn restore_selection( &self, select: &Entity>>, previous: &Option, @@ -117,193 +113,3 @@ fn sync_selector( true } - -impl RepoDetailView { - pub(super) fn switch_ref( - &mut self, - kind: RefKind, - name: T, - window: &mut Window, - cx: &mut Context, - ) where - T: Into, - { - if self.refs.switching_ref { - return; - } - - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let name = name.into(); - let previous_branch = self.refs.branch_select.read(cx).selected_value(); - let previous_tag = self.refs.tag_select.read(cx).selected_value(); - - match kind { - RefKind::Branch => { - self.refs - .tag_select - .update(cx, |state, cx| state.clear_selection(cx)); - } - RefKind::Tag => { - self.refs - .branch_select - .update(cx, |state, cx| state.clear_selection(cx)); - } - } - - self.refs.switching_ref = true; - self.ref_generation += 1; - cx.notify(); - - let checkout_name = name.clone(); - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let result = cx - .background_spawn(async move { - match kind { - RefKind::Branch => { - signed_git::worktree_checkout_branch(&worktree, &checkout_name) - } - RefKind::Tag => { - signed_git::worktree_checkout_tag(&worktree, &checkout_name) - } - } - }) - .await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(()) => this.reload_worktree(cx), - Err(error) => { - this.error = Some(format!("Failed to check out {name}: {error}").into()); - this.refs.switching_ref = false; - this.refs.restore_selection( - &this.refs.branch_select, - &previous_branch, - window, - cx, - ); - this.refs.restore_selection( - &this.refs.tag_select, - &previous_tag, - window, - cx, - ); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - self.tasks.push(task); - } - - fn reload_worktree(&mut self, cx: &mut Context) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; - // Build the tree off the main thread, like [`Self::load_repo`]. - let tree = build_tree_items(&snapshot.entries); - let paths = sorted_worktree_paths(&snapshot.entries); - Ok::<_, Error>((snapshot, tree, paths)) - }) - .await; - - this.update(cx, |this, cx| { - this.refs.switching_ref = false; - - match result { - Ok((snapshot, tree, paths)) => { - this.head_commit = snapshot.head_commit; - let readme_path = snapshot.readme_path; - let readme = snapshot.readme; - this.files.update(cx, |files, cx| { - files.clear_previews(); - files.apply_entries(tree, paths, cx); - files.set_readme(readme_path, readme, cx); - }); - this.history.update(cx, |history, cx| history.reload(cx)); - } - Err(error) => { - this.error = Some(error.to_string().into()); - this.head_commit = None; - // The tree may show files that no longer exist. - this.files.update(cx, |files, cx| { - files.apply_entries(Vec::new(), Vec::new(), cx); - }); - } - } - - cx.notify(); - })?; - - Ok(()) - }); - - self.tasks.push(task); - } - - /// Refresh the file explorer, previews and commit list after the mirror caught up with the remote. - pub(super) fn catch_up_worktree(&mut self, cx: &mut Context) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { - let snapshot = signed_git::worktree_snapshot(&worktree)?; - let tree = build_tree_items(&snapshot.entries); - let paths = sorted_worktree_paths(&snapshot.entries); - Ok::<_, Error>((snapshot, tree, paths)) - }) - .await; - - this.update(cx, |this, cx| { - match result { - Ok((snapshot, tree, paths)) => { - let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id) - != this.head_commit.as_ref().map(|c| &c.id); - - let files_changed = this - .files - .update(cx, |files, cx| files.catch_up(&snapshot, tree, paths, cx)); - - // A fast-forward of a branch other than the checked-out - // one leaves the worktree untouched. Rebuilding the tree - // and re-parsing the README would flash the panel for - // nothing, so it is a no-op. - if !head_changed && !files_changed { - log::debug!("repo detail catch_up_worktree: no-op"); - return; - } - - this.head_commit = snapshot.head_commit; - - if head_changed { - this.history.update(cx, |history, cx| history.reload(cx)); - } - - cx.notify(); - } - Err(error) => { - this.error = Some(error.to_string().into()); - cx.notify(); - } - } - })?; - - Ok(()) - }); - - self.tasks.push(task); - } -} diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs deleted file mode 100644 index 8308984..0000000 --- a/crates/workspace/src/views/repo/store.rs +++ /dev/null @@ -1,88 +0,0 @@ -use gpui::{Context, Entity, Window}; -use signed_core::Announcement; -use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; - -use super::RepoDetailView; - -impl RepoDetailView { - pub(crate) fn apply_announcement( - &mut self, - announcement: Announcement, - cx: &mut Context, - ) { - let path = self.store.read(cx).path.clone(); - - if let Some(path) = path { - LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); - } - - self.store - .update(cx, |store, cx| store.announce(announcement, cx)); - - // The new address needs its ready-to-contribute statuses requested. - self.refresh_ready_statuses(cx); - cx.notify(); - } - - pub(super) fn attach_store( - &mut self, - store: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - self._subscriptions - .push(cx.observe_in(store, window, |this, store, window, cx| { - this.refresh_ready_statuses(cx); - if !this.repo_started && store.read(cx).announcement.is_some() { - this.load_repo(window, cx); - } - cx.notify(); - })); - self.refresh_ready_statuses(cx); - } - - fn refresh_ready_statuses(&mut self, cx: &mut Context) { - let Some(addr) = self.store.read(cx).addr().cloned() else { - return; - }; - - let head = self.store.read(cx).head.clone(); - let (requested, requested_head) = self.banners.ready_requested_at(); - - if requested && requested_head == &head { - return; - } - - self.banners.mark_ready_requested(head.clone()); - - let backend = Backend::global(cx); - let checkout = CheckoutsStore::global(cx); - - let owned = backend - .read(cx) - .current_user() - .is_some_and(|user| self.store.read(cx).is_author(&user)); - - checkout.update(cx, |store, cx| { - // The ready statuses keep the fast poll running while the panel is open. - // The sidebar's push watch alone polls slower. - store.request_statuses(&addr, head, cx); - - if owned { - store.request_push_statuses(&addr, cx); - } - }); - } - - pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { - let Some(addr) = self.store.read(cx).addr().cloned() else { - return false; - }; - - let checkouts = CheckoutsStore::global(cx).read(cx); - let ready_statuses = checkouts.ready_statuses_of(&addr); - let push_statuses = checkouts.push_statuses_of(&addr); - - self.banners.set_statuses(ready_statuses, push_statuses) - } -} -- 2.54.0 From 596998810f020a132d19c175010e555d2d54881a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 16:40:50 +0700 Subject: [PATCH 12/12] clean up --- crates/workspace/src/views/repo/files.rs | 14 +- crates/workspace/src/views/repo/history.rs | 9 - crates/workspace/src/views/repo/mod.rs | 22 +- docs/repo-view-refactor-plan.md | 291 --------------------- 4 files changed, 4 insertions(+), 332 deletions(-) delete mode 100644 docs/repo-view-refactor-plan.md diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs index 1ff85c0..ebd3fe9 100644 --- a/crates/workspace/src/views/repo/files.rs +++ b/crates/workspace/src/views/repo/files.rs @@ -59,7 +59,6 @@ pub(super) struct RepoFilesView { commits: HashMap, pending_commits: Vec, loading_commits: bool, - generation: u64, tasks: Vec>>, } @@ -80,7 +79,6 @@ impl RepoFilesView { commits: HashMap::new(), pending_commits: Vec::new(), loading_commits: false, - generation: 0, tasks: Vec::new(), } } @@ -142,7 +140,6 @@ impl RepoFilesView { self.md = None; self.code = None; self.readme_name = None; - self.generation += 1; } /// Refresh after the mirror caught up with the remote. @@ -466,7 +463,6 @@ impl RepoFilesView { let path = path.to_string(); self.load_commit(&path, cx); - let generation = self.generation; let task: Task> = cx.spawn_in(window, async move |this, cx| { let path_for_read = path.clone(); @@ -496,11 +492,6 @@ impl RepoFilesView { .await; this.update_in(cx, |this, window, cx| { - if generation != this.generation { - this.loading_files.remove(&path); - return; - } - this.loading_files.remove(&path); match content { @@ -616,7 +607,6 @@ impl RepoFilesView { self.loading_commits = true; let paths = std::mem::take(&mut self.pending_commits); - let generation = self.generation; let task: Task> = cx.spawn(async move |this, cx| { let rels: Vec = paths.iter().map(PathBuf::from).collect(); @@ -629,9 +619,7 @@ impl RepoFilesView { this.update(cx, |this, cx| { this.loading_commits = false; - if generation == this.generation - && let Ok(found) = result - { + if let Ok(found) = result { for (path, commit) in found { this.commits .insert(path.to_string_lossy().into_owned(), commit); diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs index 9e47eb9..4ec8db5 100644 --- a/crates/workspace/src/views/repo/history.rs +++ b/crates/workspace/src/views/repo/history.rs @@ -23,8 +23,6 @@ pub(super) struct RepoHistoryView { loading_all_commits: bool, scroll_handle: VirtualListScrollHandle, item_sizes: Rc>>, - /// Bumped on reload, so an in-flight walk of the previous HEAD is discarded. - generation: u64, tasks: Vec>>, } @@ -38,7 +36,6 @@ impl RepoHistoryView { loading_all_commits: false, scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), - generation: 0, tasks: Vec::new(), } } @@ -54,7 +51,6 @@ impl RepoHistoryView { /// Drop the current list and walk HEAD again. pub(super) fn reload(&mut self, cx: &mut Context) { - self.generation += 1; self.all_commits = None; self.loading_all_commits = false; self.load(cx); @@ -70,7 +66,6 @@ impl RepoHistoryView { }; self.loading_all_commits = true; - let generation = self.generation; let task: Task> = cx.spawn(async move |this, cx| { let result = cx @@ -78,10 +73,6 @@ impl RepoHistoryView { .await; this.update(cx, |this, cx| { - if generation != this.generation { - return; - } - if let Ok(list) = result { let count = list.commits.len(); this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 6673c54..eacdae1 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -91,7 +91,6 @@ pub struct RepoDetailView { error: Option, head_commit: Option, refs: RefSwitcher, - ref_generation: u64, banners: Banners, tasks: Vec>>, _subscriptions: Vec, @@ -191,7 +190,6 @@ impl RepoDetailView { error: None, head_commit: None, refs, - ref_generation: 0, tasks: Vec::new(), banners: Banners::default(), focus_handle: cx.focus_handle(), @@ -342,16 +340,13 @@ impl RepoDetailView { let Some(announcement) = announcement else { return; }; + self.repo_started = true; let cache = GitStore::global(cx).cache().clone(); let addr = announcement.addr(); let clone_urls: Vec = announcement.clone.clone(); - // Captured before the loads start. - // A branch/tag switch bumps the generation, discarding the refresh below. - let refresh_generation = self.ref_generation; - let disk = { let cache = cache.clone(); let addr = addr.clone(); @@ -363,7 +358,7 @@ impl RepoDetailView { }) }; - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let task: Task> = cx.spawn_in(window, async move |this, cx| { let disk = disk.await; let had_clone = matches!(&disk, Ok(Some(_))); @@ -393,7 +388,6 @@ impl RepoDetailView { // Refresh the clone from the network in the background. // When it completes, update the refs and commit list. - // Loads started before a branch/tag switch are discarded via the generation. if !had_clone { return Ok(()); } @@ -408,15 +402,10 @@ impl RepoDetailView { }; // 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. - // That covers own repo pushes from a checkout and updates fetched here. - // Fast-forward branches from the remote, like `git pull --ff-only`. - // Only the checked-out branch's worktree can change on disk. + let moved = match &worktree { Some(worktree) => { signed_git::fast_forward_branches(worktree).unwrap_or(false) @@ -441,10 +430,6 @@ impl RepoDetailView { .await; this.update_in(cx, |this, window, cx| { - if refresh_generation != this.ref_generation { - return; - } - if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { let branches: Vec = branches.iter().map(Into::into).collect(); let tags: Vec = tags.iter().map(Into::into).collect(); @@ -619,7 +604,6 @@ impl RepoDetailView { } self.refs.switching_ref = true; - self.ref_generation += 1; cx.notify(); let checkout_name = name.clone(); diff --git a/docs/repo-view-refactor-plan.md b/docs/repo-view-refactor-plan.md deleted file mode 100644 index 1a5350e..0000000 --- a/docs/repo-view-refactor-plan.md +++ /dev/null @@ -1,291 +0,0 @@ -# Repo view refactor plan - -## Goal - -Make `crates/workspace/src/views/repo/` easy to navigate and change: - -- Each concern owns its own state (its own struct fields), instead of all concerns sharing one 38-field struct. -- Shared UI moves to the module that consumes it, so sibling views stop importing from `views::repo`. -- No behavior change. No new global state. No new store. The GPUI patterns already used in the repo (`Entity` + observe, `TreeState`, `ComboboxState`, `VirtualListScrollHandle`) stay the only patterns used. - -## Constraints - -- Follow `.rules`: no `unwrap` in production, no silently discarded errors, full-word names, comments explain "why" only. -- Do not over-engineer. Files and History become entities because they already render and run async independently. Refs and Banners stay plain field groups on the shell. -- Keep the shell as the single load/reconcile point. The clone/worktree and `ref_generation` belong to the shell, not to child views. -- `signed_ui` does **not** depend on `signed_git` (verified in `crates/signed_ui/Cargo.toml`). Anything that takes a `signed_git` type cannot move there. - -## Current state (verified) - -| File | Lines | Content | -|---|---|---| -| `mod.rs` | ~376 | `RepoDetailView` struct (38 fields), constructors, `render`, panel impls, `display_name` | -| `store.rs` | ~98 | `attach_store`, `apply_announcement`, `refresh_ready_statuses`, `refresh_statuses` | -| `actions.rs` | ~208 | action methods + `open_repo_panel` / `open_repo_item` free functions | -| `loading.rs` | ~435 | `load_repo`, `apply_repo_data`, `sync_ref_selector`, `clone_to_folder`, `load_repo_data` | -| `refs.rs` | ~279 | `switch_ref`, `restore_selection`, `reload_worktree`, `catch_up_worktree` | -| `files.rs` | ~480 | file tree, previews, markdown/code state, eviction | -| `history.rs` | ~228 | commits tab render + per-file and full commit walks | -| `header.rs` | ~715 | header render, maintainers, fork row, clone URL | -| `banners.rs` | ~315 | ready/push suggestion banners | -| `about.rs` | ~224 | about dialog | -| `init_dialog.rs` | ~202 | publish-to-NIP-34 dialog | -| `helpers.rs` | ~722 | `pub(crate)` grab bag: tree building, diff rendering, discussion UI, share targets, commit rows | - -### Problems - -1. **`RepoDetailView` is a god object.** 38 fields across six concerns. All 12 files are `impl RepoDetailView`, so any file can read/write any field. The file split added navigation cost without encapsulation. -2. **`helpers.rs` is an inverted dependency hub.** `views/issues/detail.rs` and `views/pull_requests/*.rs` import from `views::repo::helpers` for discussion UI, diff rows and commit rows. Sibling views reaching into `repo` is backwards. -3. **Two regions have independent async + render lifecycles** (file browser, commit history) but live as shell fields, sharing `worktree` and `ref_generation` by hand. - -### Existing good pattern - -`IssuesView` (`views/issues/mod.rs`): own struct (~13 fields), `cx.observe(&store, ..)`, `rebuild()` into local state, `Render`, no shell fields. The refactor brings `RepoDetailView` in line with this. - -## Target structure - -```mermaid -graph TD - Shell["RepoDetailView shell\nstore, dock_area, tabs, header,\nload orchestration, worktree, generation"] --> Files["Entity\nfiles.rs"] - Shell --> History["Entity\nhistory.rs"] - Shell --> Refs["RefSwitcher (plain)\nrefs.rs"] - Shell --> Banners["Banners (plain)\nbanners.rs"] - Files --> Store["Entity"] - History --> Store -``` - -Field ownership after the refactor: - -| Concern | Fields | Owner | -|---|---|---| -| Files | `tree_state, worktree_paths, md, code, readme_name, selected_file, files, file_order, preview_bytes, loading_files, commits, pending_commits, loading_commits` | `RepoFilesView` | -| History | `all_commits, loading_all_commits, item_sizes, scroll_handle` | `RepoHistoryView` | -| Refs | `branch_select, tag_select, ref_branches, ref_tags, switching_ref` | `RefSwitcher` | -| Banners | `banner_dismissed, ready_requested, ready_head, ready_statuses, push_statuses` | `Banners` | -| Shell | `focus_handle, dock_area, store, repo_started, active_tab, loading, error, head_commit, worktree, ref_generation, _subscriptions` | `RepoDetailView` (11 fields) | - -Shared modules after Phase 1: - -| New / changed module | Contents | Consumers | -|---|---|---| -| `views/tree.rs` | `TreeItemSeed`, `tree_items`, `build_tree_items`, `sorted_worktree_paths` + the 3 tree tests | repo files/loading, commit_diff | -| `views/commit_diff/mod.rs` | adds `DiffRow`, `diff_rows`, `render_diff_row`, `render_diff_line`, `find_item`, `GUTTER_WIDTH`, `DIFF_ROW_HEIGHT`, `commit_row`, `COMMIT_ROW_HEIGHT` | commit_diff, PR new, repo history | -| `views/discussion.rs` | `sidebar_title`, `sidebar_section`, `comments_section`, `comment_form`, `issue_roots`, `pr_roots` | issues detail, PR detail | -| `signed_ui/src/ref_selector.rs` | `ref_selector_trigger` | repo header, PR new | -| `repo/files.rs` | `code_language`, `is_markdown_path` (only used there) | repo files | -| `repo/header.rs` | `ShareTargets`, `truncate_naddr_link` (only used there) | repo header | - -`views/repo/helpers.rs` is deleted at the end of Phase 1. - ---- - -## Phase 0 - baseline - -No code. Record the current state so each later phase can be compared. - -1. `cargo fmt --all -- --check` -2. `cargo check --offline --workspace --all-targets` -3. `cargo test --offline -p workspace` -4. `cargo clippy --offline -p workspace --all-targets` - -Do not run plain `cargo` without `--offline`; the sandbox fails the git fetch and it looks like a dependency error. - ---- - -## Phase 1 - extract shared modules (dissolve `helpers.rs`) - -Low risk, no state moves. Land it as one commit. - -### 1.1 Create `crates/workspace/src/views/tree.rs` - -Move from `repo/helpers.rs`: `TreeItemSeed`, `tree_items`, `build_tree_items`, `sorted_worktree_paths`, and the three tests (`builds_nested_tree_from_flat_entries`, `tree_builder_handles_deep_nesting`, `tree_builder_merges_shared_prefixes`). - -- Add `pub(crate) mod tree;` to `views/mod.rs`. -- Update imports in `repo/loading.rs`, `repo/refs.rs`, `commit_diff/mod.rs` to `crate::views::tree::...`. - -### 1.2 Move diff and commit-row rendering into `views/commit_diff/mod.rs` - -Move from `repo/helpers.rs`: `GUTTER_WIDTH`, `DIFF_ROW_HEIGHT`, `DiffRow`, `diff_rows`, `render_diff_row`, `render_diff_line`, `find_item`, `COMMIT_ROW_HEIGHT`, `commit_row`. - -- `commit_diff/mod.rs` already owns `DiffPane` and depends on `signed_git`, so this is its natural home and keeps `signed_ui` free of a `signed_git` dependency. -- Update imports in `views/pull_requests/new.rs` and `repo/history.rs`. - -### 1.3 Create `crates/workspace/src/views/discussion.rs` - -Move from `repo/helpers.rs`: `sidebar_title`, `sidebar_section`, `comments_section`, `comment_form`, `issue_roots`, `pr_roots`. - -- Add `pub(crate) mod discussion;` to `views/mod.rs`. -- Update imports in `views/issues/detail.rs` and `views/pull_requests/detail.rs`. After this, neither imports from `views::repo`. - -### 1.4 Move `ref_selector_trigger` into `signed_ui` - -It takes `CustomIconName` (from `assets`) and `ComboboxTriggerContext` (from `gpui_component`); both are already `signed_ui` dependencies, so no dependency changes. - -- Add `crates/signed_ui/src/ref_selector.rs`, export it from `lib.rs`. -- Update imports in `repo/header.rs` and `views/pull_requests/new.rs`. - -### 1.5 Move `code_language` and `is_markdown_path` into `repo/files.rs` - -Only `repo/files.rs` uses them. Keep them private there. - -### 1.6 Move `ShareTargets` and `truncate_naddr_link` into `repo/header.rs` - -Only `repo/header.rs` uses them. Keep them private there. - -### 1.7 Delete `repo/helpers.rs` - -Remove `pub(super) mod helpers;` from `repo/mod.rs`. Confirm no `use ...repo::helpers` remains anywhere: - -``` -grep -rn "repo::helpers" crates/workspace/src -``` - -### Phase 1 validation - -`cargo fmt --all`, `cargo check --offline -p workspace --all-targets`, `cargo test --offline -p workspace`, `cargo clippy --offline -p workspace --all-targets`. - ---- - -## Phase 2 - extract `Entity` - -Largest win: removes 14 fields and most of the preview logic from the shell. - -### 2.1 Define the view - -In `repo/files.rs`, replace `impl RepoDetailView` with `pub(super) struct RepoFilesView` holding: `tree_state`, `worktree`, `worktree_paths`, `md`, `code`, `readme_name`, `selected_file`, `files`, `file_order`, `preview_bytes`, `loading_files`, `commits`, `pending_commits`, `loading_commits`. - -Move the supporting types and helpers from the current `files.rs` into the view: `FileContent`, `MarkdownView`, `CodeView`, `MAX_PREVIEW_BYTES`, `MAX_PREVIEWED_FILES`, `MAX_PREVIEW_CACHE_BYTES`, `source_hash`, `preview_spinner`, `render_tree_item`, `render_tree_column`, `render_content_column`, `set_markdown`, `markdown_element`, `set_code`, `code_element`, `open_file`, `drop_preview_of`, `evict_previews`. - -Move from `repo/history.rs`: `load_commit`, `load_commits` (the per-file commit map). - -### 2.2 Define the view's interface - -- `pub(super) fn new(window: &mut Window, cx: &mut Context) -> Self` - creates the `TreeState`. -- `pub(super) fn set_worktree(&mut self, path: PathBuf)`. -- `pub(super) fn apply_entries(&mut self, tree: Vec, paths: Vec, window, cx)` - used by `load_repo` / `reload_worktree` / `catch_up_worktree`. -- `pub(super) fn set_readme(&mut self, path: Option, bytes: Option>, cx)`. -- `pub(super) fn clear_previews(&mut self)` - branch switch. -- `pub(super) fn catch_up(&mut self, snapshot, window, cx) -> bool` - rebuild tree, drop removed previews, re-render README; returns whether anything changed. -- `impl Render for RepoFilesView`. -- `pub(super) fn pane_title(&self) -> SharedString` - `selected_file` or `readme_name` or `"Overview"`. - -### 2.3 Move the clone loading/error display out of the file view - -`render_content_column` currently shows "Cloning repository..." / a load error from `self.loading` and `self.error`, which are shell state. Move that decision to the shell's `render`: while `self.loading`, render a spinner in the tab body; when `self.error` is set, the existing `Alert` already covers it. `render_content_column` then handles only file previews and the README. - -### 2.4 Wire the shell - -- Add `files: Entity` to `RepoDetailView`. -- In `new_common`, `let files = cx.new(|cx| RepoFilesView::new(window, cx));`. -- In `render`, the Files tab body becomes `self.files.clone()`. -- In `load_repo` (`loading.rs`) and `reload_worktree` / `catch_up_worktree` (`refs.rs`), replace direct field writes with calls on `self.files`. -- Remove the now-unused `files.rs` imports from `mod.rs` and the moved fields from the struct and constructor. - -### Phase 2 validation - -Same commands. Manual: open explore repo, click files in the tree, open the README, switch branch (previews clear), switch back, confirm no spinner sticks. - ---- - -## Phase 3 - extract `Entity` - -### 3.1 Define the view - -In `repo/history.rs`, replace the commits-tab methods with `pub(super) struct RepoHistoryView` holding: `store: Entity`, `dock_area: WeakEntity`, `worktree: Option`, `all_commits`, `loading_all_commits`, `item_sizes`, `scroll_handle`. - -Move: `render_commits_tab` (becomes `impl Render`), `load_all_commits`, `open_commit_diff`. - -### 3.2 Display name - -`open_commit_diff` uses the shell's `display_name`. Extract the `display_name` logic from `RepoDetailView` into a free function in `repo/mod.rs`: - -```rust -pub(super) fn repo_display_name(store: &RepoStore) -> SharedString -``` - -It keeps the local-path fallback that `RepoStore::name()` does not have. Use it in the shell's `Panel::title`, in the header, and in `RepoHistoryView::open_commit_diff`. - -### 3.3 Interface - -- `pub(super) fn new(store, dock_area, window, cx) -> Self`. -- `pub(super) fn set_worktree(&mut self, path: Option)`. -- `pub(super) fn reload(&mut self, cx)` - clears `all_commits` and starts the walk (called when HEAD changes or the branch switches). -- `impl Render for RepoHistoryView`. - -### 3.4 Wire the shell - -- Add `history: Entity` to `RepoDetailView`; create it in `new_common`. -- In `render`, tab 1 becomes `self.history.clone()`. -- Replace `self.all_commits` / `self.loading_all_commits` / `self.item_sizes` writes in `load_repo`, `reload_worktree`, `catch_up_worktree`, and the header-commit pill path with `self.history.update(..)` calls. -- Remove the moved fields from the struct and constructor. - -### Phase 3 validation - -Same commands. Manual: open the Commits tab, scroll a long history, click a commit (diff panel opens), switch branch and confirm the list reloads. - ---- - -## Phase 4 - group `RefSwitcher` and `Banners` - -Plain structs on the shell. No entity, no observer changes. - -### 4.1 `RefSwitcher` - -Move into a `struct RefSwitcher { branch_select, tag_select, ref_branches, ref_tags, switching_ref }` field on the shell. Update `refs.rs` and `loading.rs` methods to read/write `self.refs.*`. `switch_ref` stays on the shell because it fans out to files, history and `head_commit`. - -`ref_generation` stays on the shell: it is shared with the files and history loads. - -### 4.2 `Banners` - -Move into a `struct Banners { dismissed, ready_requested, ready_head, ready_statuses, push_statuses }` field. `banners.rs` and `store.rs` methods keep their `impl RepoDetailView` shape but read/write `self.banners.*`. - -### Phase 4 validation - -Same commands. Manual: the ready-to-contribute banner appears and dismisses, the push banner appears for an owned repo, dismissing survives a store refresh. - ---- - -## Phase 5 - fold `store.rs` and tidy - -1. Move `attach_store`, `apply_announcement`, `refresh_ready_statuses`, `refresh_statuses` into `mod.rs` and delete `repo/store.rs`. -2. Remove `mod store;` from `repo/mod.rs`. -3. Confirm `mod.rs` reads as a shell: struct, constructors, load coordination, `render`, panel impls. -4. Final validation: - -``` -cargo fmt --all -cargo check --offline --workspace --all-targets -cargo test --offline --workspace -cargo clippy --offline --workspace --all-targets -``` - -## Validation (manual smoke, after each phase) - -- Open a repo from the explore list, then open an issue and a PR. -- Deep-link straight to an issue / PR without visiting the repo panel. -- Open a local repository (never announced). -- Initialize a local repo to NIP-34, confirm it leaves the sidebar's local section. -- Clone to folder; clone again before the first clone completes. -- Switch a branch and a tag; confirm previews and the commit list reset. -- Owned repo with unpushed commits: push banner, push, republish banner. - -## Boundary test for "done" - -- No file can touch fields it does not own. -- `repo/mod.rs` is a shell, roughly 200 lines. -- `grep -rn "views::repo::helpers" crates/workspace/src` returns nothing. -- `views/issues` and `views/pull_requests` have no `use ...views::repo`. - -## Non-goals - -- No behavior change; no UI redesign. -- No new global state, no new store, no changes to `signed_state` or `dock`. -- No more `impl RepoDetailView` chapters. New files own structs, not fragments of one struct. -- Do not move `commit_row` into `signed_ui`: it takes `signed_git::FileCommit` and `signed_ui` does not depend on `signed_git`. - -## Risks and open questions - -- **Async generation.** `ref_generation` discards stale loads. It stays on the shell; when the shell pushes a snapshot into a child view, the child must not start a new load that outlives the generation. Simplest rule: only the shell starts loads, child views only render and own per-file preview fetches keyed to the current worktree. -- **Files owns the per-file commit walk.** `load_commit`/`load_commits` move with the preview state, so the shell no longer coordinates them. Confirm the README commit lookup still works after the move. -- **History is small.** After moving `load_commit`/`load_commits` to Files, `history.rs` is ~150 lines. If an entity feels heavy for that, a plain `struct History` field is an acceptable fallback; the field ownership still improves. -- **`RepoStore::name()` vs `display_name`.** `RepoStore::name()` returns `Unknown` for local repos. The extracted `repo_display_name` must keep the local-path fallback so titles are unchanged. -- 2.54.0