From b3991810b6b5304f2db8cd23c5911d0c41b2b8d1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 13 Sep 2026 13:36:27 +0700 Subject: [PATCH] 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.