diff --git a/Cargo.lock b/Cargo.lock index 9d23ca2..a67be4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3561,7 +3561,6 @@ dependencies = [ "gpui_macros", "gpui_shared_string", "gpui_util", - "hdrhistogram", "heapless 0.9.3", "http_client", "image", @@ -3745,20 +3744,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "gpui-fps" -version = "0.1.0" -source = "git+https://github.com/longbridge/gpui-component?rev=39c2c86dbee7ad445591462f8675f74082e10828#39c2c86dbee7ad445591462f8675f74082e10828" -dependencies = [ - "gpui", - "instant", - "libc", - "objc2-core-foundation", - "objc2-io-kit", - "sysinfo 0.37.2", - "windows 0.58.0", -] - [[package]] name = "gpui_apple" version = "0.1.0" @@ -4096,16 +4081,6 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "hdrhistogram" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" -dependencies = [ - "byteorder", - "num-traits", -] - [[package]] name = "heapless" version = "0.8.0" @@ -6035,16 +6010,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - [[package]] name = "objc2-io-surface" version = "0.3.2" @@ -8003,7 +7968,6 @@ version = "0.1.0-alpha" dependencies = [ "nostr", "serde", - "serde_json", ] [[package]] @@ -8526,20 +8490,6 @@ dependencies = [ "windows 0.57.0", ] -[[package]] -name = "sysinfo" -version = "0.37.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.61.3", -] - [[package]] name = "system-configuration" version = "0.6.1" @@ -10907,7 +10857,6 @@ dependencies = [ "gpui", "gpui-base", "gpui-component", - "gpui-fps", "log", "nostr", "paths", @@ -11333,7 +11282,7 @@ dependencies = [ "rand 0.8.8", "screencapturekit", "screencapturekit-sys", - "sysinfo 0.31.4", + "sysinfo", "tao-core-video-sys", "windows 0.61.3", "windows-capture", diff --git a/Cargo.toml b/Cargo.toml index 2327cfc..359ca4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } # GPUI Kit gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828", features = ["tree-sitter-languages"], } gpui-base = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" } -gpui-fps = { git = "https://github.com/longbridge/gpui-component", rev = "39c2c86dbee7ad445591462f8675f74082e10828" } dock = { path = "crates/dock" } settings = { path = "crates/settings" } diff --git a/crates/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 2bd907b..4770353 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1,7 +1,6 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::time::Duration; use anyhow::{Error, bail}; use bitcoin_hashes::sha1::Hash as Sha1Hash; @@ -22,9 +21,6 @@ use crate::git_store::GitStore; use crate::refresh::{RefreshGate, RefreshRequest}; use crate::repos::RepoListStore; -/// Delay between a refresh request and the actual re-query. -const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); - /// Maximum size of one patch event. /// /// NIP-34 suggests patches when each event is under 60kb. @@ -35,8 +31,18 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; /// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses. /// Always derived from the local database. pub struct RepoStore { - addr: RepoAddr, + /// NIP-34 address. `None` while the repository is local-only. + addr: Option, + /// Latest announcement. Seeded from the open-time hint, replaced by the + /// database's latest on the first pass. `None` while local-only. pub announcement: Option, + /// Local working copy. The scan path for a local repository, kept when it is + /// later announced so the panel keeps its worktree. + pub path: Option, + /// The first local pass has been applied. + /// + /// Views distinguish "no data yet" from a genuinely empty repository with it. + pub loaded: bool, /// Branch pointed to by `HEAD` in the latest state announcement. pub head: Option, pub issues: Vec, @@ -54,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. /// @@ -80,54 +85,21 @@ 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, - _subscription: Subscription, + /// Backend subscription of an announced repository. `None` while local-only. + _subscription: Option, } impl RepoStore { - pub fn new(addr: RepoAddr, announced_relays: Vec, cx: &mut Context) -> Self { - let backend = Backend::global(cx); - - let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { - let relevant = match event { - BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| { - // Deletions may target any event of this repository. - let deletion = - update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; - let coordinate = update.coordinate.as_ref() == Some(&this.addr); - let author = update.author == this.addr.public_key; - let kind = update.kind == Kind::GitRepoAnnouncement; - // NIP-22 comments carry no `a` tag. - // Coordinate matching fails for them. - // Any comment may reference this repository's roots. - let comment = update.kind == Kind::Comment; - // Status events may omit their `a` tag, NIP-34. - // Any status event may reference a root of this repository. - let status = RepoStatus::from_kind(update.kind).is_some(); - - deletion || coordinate || (author && kind) || comment || status - }), - BackendEvent::Published(event) => { - let kind = event.kind == Kind::GitRepoAnnouncement; - let author = event.pubkey == this.addr.public_key; - let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); - // Locally published deletions may target any event of this repository. - // Refresh so they take effect immediately, like relay deletions. - let deletion = - event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; - - coordinate || (kind && author) || deletion - } - _ => false, - }; - - if relevant { - this.refresh(cx); - } - }); - + pub fn new(addr: RepoAddr, hint: Option, cx: &mut Context) -> Self { let weak = cx.entity().downgrade(); + let subscription = Self::subscribe_backend(cx); + + let announced_relays = hint + .as_ref() + .map(|announcement| announcement.relays.clone()) + .unwrap_or_default(); + cx.defer(move |cx| { let result = weak.update(cx, |this, cx| { this.subscribe_remote(cx); @@ -141,8 +113,10 @@ impl RepoStore { }); Self { - addr, - announcement: None, + addr: Some(addr), + announcement: hint, + path: None, + loaded: false, head: None, issues: Vec::new(), patches: Vec::new(), @@ -160,13 +134,101 @@ impl RepoStore { repo_relays: HashSet::new(), root_fetches: HashSet::new(), refresh: RefreshGate::default(), - _subscription: subscription, + _subscription: Some(subscription), } } - /// Returns the repository's address. - pub fn addr(&self) -> &RepoAddr { - &self.addr + /// Local repository discovered by the scan, not announced to NIP-34 yet. + pub fn new_local(path: PathBuf) -> Self { + Self { + addr: None, + announcement: None, + path: Some(path), + loaded: true, + head: None, + issues: Vec::new(), + patches: Vec::new(), + pull_requests: Vec::new(), + comments: Vec::new(), + status_by_root: HashMap::new(), + open_issue_count: 0, + open_pr_count: 0, + version: 0, + last_error: None, + last_warning: None, + last_push_warning: None, + pushing: false, + cloning: false, + repo_relays: HashSet::new(), + root_fetches: HashSet::new(), + refresh: RefreshGate::default(), + _subscription: None, + } + } + + /// Switch a local repository to its NIP-34 mode, keeping its path. + pub fn announce(&mut self, announcement: Announcement, cx: &mut Context) { + self.addr = Some(announcement.addr()); + self.announcement = Some(announcement.clone()); + self.loaded = false; + + if self._subscription.is_none() { + self._subscription = Some(Self::subscribe_backend(cx)); + } + + self.subscribe_remote(cx); + self.connect_announced_relays(&announcement.relays, cx); + self.refresh(cx); + } + + fn subscribe_backend(cx: &mut Context) -> Subscription { + let backend = Backend::global(cx); + + cx.subscribe(&backend, |this, _backend, event, cx| { + let Some(addr) = this.addr.as_ref() else { + return; + }; + + let relevant = match event { + BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| { + // Deletions may target any event of this repository. + let deletion = + update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; + let coordinate = update.coordinate.as_ref() == Some(addr); + let author = update.author == addr.public_key; + let kind = update.kind == Kind::GitRepoAnnouncement; + // NIP-22 comments carry no `a` tag. + // Coordinate matching fails for them. + // Any comment may reference this repository's roots. + let comment = update.kind == Kind::Comment; + // Status events may omit their `a` tag, NIP-34. + // Any status event may reference a root of this repository. + let status = RepoStatus::from_kind(update.kind).is_some(); + + deletion || coordinate || (author && kind) || comment || status + }), + BackendEvent::Published(event) => { + let kind = event.kind == Kind::GitRepoAnnouncement; + let author = event.pubkey == addr.public_key; + let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr); + // Locally published deletions may target any event of this repository. + // Refresh so they take effect immediately, like relay deletions. + let deletion = + event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; + + coordinate || (kind && author) || deletion + } + _ => false, + }; + + if relevant { + this.refresh(cx); + } + }) + } + + pub fn addr(&self) -> Option<&RepoAddr> { + self.addr.as_ref() } /// Returns the repository's name, or `Unknown` when not known. @@ -198,6 +260,10 @@ impl RepoStore { /// Fetch this repository's events from the relays in its NIP-34 `relays` tag. fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + return; + }; + let new: Vec = relays .iter() .filter(|url| !self.repo_relays.contains(*url)) @@ -210,17 +276,18 @@ impl RepoStore { self.repo_relays.extend(new.iter().cloned()); let backend = Backend::global(cx); - let addr = self.addr.clone(); backend.update(cx, |backend, cx| { backend.connect_repo_relays(new, Self::repo_filters(&addr), cx); }); } - /// Fetch this repository's events from the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + return; + }; + let backend = Backend::global(cx); - let addr = self.addr.clone(); backend.update(cx, |backend, cx| { backend.subscribe_bootstrap(Self::repo_filters(&addr), cx); @@ -228,25 +295,30 @@ impl RepoStore { } /// Re-query the local database and update all fields. + /// + /// Runs immediately. The backend pump already batches the relay events that + /// trigger a refresh, so no per-store debounce is needed. pub fn refresh(&mut self, cx: &mut Context) { + if self.addr.is_none() { + return; + } + if self.refresh.request() != RefreshRequest::Schedule { return; } - cx.spawn(async move |this, cx| { - cx.background_executor().timer(REFRESH_DEBOUNCE).await; - - this.update(cx, |this, cx| this.run_refresh(cx)) - }) - .detach(); + self.run_refresh(cx); } fn run_refresh(&mut self, cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + return; + }; + self.refresh.begin(); let backend = Backend::global(cx); let client = backend.read(cx).client(); - let addr = self.addr.clone(); let work = cx.background_spawn(async move { let (announcements, states, activity, deletion_events) = async { @@ -397,7 +469,37 @@ impl RepoStore { }; let again = this.update(cx, |this, cx| { - this.announcement = announcement; + // Compare before moving the freshly queried data in, so a pass + // that found nothing new does not notify observers. The store + // is polled in bursts while a sync is in flight; notifying on + // every identical pass would re-render the repository panel + // several times for no visible change. + // + // The first pass is the exception: it must notify even when it + // found nothing, so views can leave their loading state and show + // the empty result. + // + // Keep the open-time hint until that first pass has confirmed what + // the database holds; afterwards the database is the truth, + // including a deletion. + let keep_hint = announcement.is_none() && !this.loaded; + + let first_pass = !this.loaded; + let head_changed = state + .as_ref() + .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); + let changed = first_pass + || (!keep_hint && this.announcement != announcement) + || head_changed + || this.issues != issues + || this.patches != patches + || this.pull_requests != pull_requests + || this.comments != comments + || this.status_by_root != status_by_root; + + if !keep_hint { + this.announcement = announcement; + } // The announcement may list relays for this repository's activity. // Connect to any we have not fetched from yet. @@ -419,6 +521,7 @@ impl RepoStore { this.status_by_root = status_by_root; this.open_issue_count = open_issue_count; this.open_pr_count = open_pr_count; + this.loaded = true; this.version = this.version.wrapping_add(1); // Comments and statuses without an `a` tag. @@ -454,7 +557,9 @@ impl RepoStore { }); } - cx.notify(); + if changed { + cx.notify(); + } this.refresh.finish() })?; @@ -475,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 } @@ -502,13 +605,19 @@ impl RepoStore { /// The author is the public key of the repository address. /// Only the author may manage pull requests, close, reopen or merge. pub fn is_author(&self, user: &PublicKey) -> bool { - &self.addr.public_key == user + self.addr + .as_ref() + .is_some_and(|addr| &addr.public_key == user) } - /// Open an issue on this repository. pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let builder = GitIssue { - repository: self.addr.clone(), + repository: addr, content, subject, labels: Vec::new(), @@ -518,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() @@ -540,6 +648,11 @@ impl RepoStore { content: String, cx: &mut Context, ) { + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let relay_hint = self .announcement .as_ref() @@ -547,12 +660,11 @@ impl RepoStore { .cloned(); self.publish( - comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content), + comment_builder(root, parent, relay_hint.as_ref(), &addr, content), cx, ); } - /// Open a pull request on this repository. #[allow(clippy::too_many_arguments)] pub fn open_pull_request( &mut self, @@ -568,6 +680,11 @@ impl RepoStore { self.last_error = None; self.last_warning = None; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let series: Vec = signed_git::split_patch_series(&patch) .into_iter() .map(str::to_owned) @@ -611,8 +728,7 @@ impl RepoStore { // The author's npub names their GRASP-06 namespace, `/prs/...`. let author_npub = user.to_bech32().unwrap(); - let addr = self.addr.clone(); - let owner = self.addr.public_key; + let owner = addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let repo_id = addr.identifier.clone(); let base_npub = owner.to_bech32().unwrap(); @@ -728,7 +844,7 @@ impl RepoStore { let clone = pr_clone_urls(prs_urls, base_clone); let builder = GitPullRequest { - repository: this.addr.clone(), + repository: addr.clone(), content: description, subject, labels: Vec::new(), @@ -947,8 +1063,11 @@ impl RepoStore { .map(|p| p.id) }); - let addr = self.addr.clone(); - let owner = self.addr.public_key; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let owner = addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let root = root.clone(); let clone: Vec = self @@ -976,9 +1095,9 @@ impl RepoStore { }); } - let builder = this.update(cx, |this, _cx| { + let builder = this.update(cx, |_this, _cx| { let builder = GitPullRequestUpdate { - repository: this.addr.clone(), + repository: addr.clone(), pull_request_event: root.id, pull_request_author: root.pubkey, current_commit, @@ -1035,6 +1154,11 @@ impl RepoStore { pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let maintainers = self .announcement .as_ref() @@ -1060,19 +1184,23 @@ impl RepoStore { let builder = EventBuilder::new(status.kind(), "").tags([ root_ref, - Tag::public_key(self.addr.public_key), + Tag::public_key(addr.public_key), Tag::public_key(root.pubkey), - Tag::coordinate(self.addr.clone(), None), + Tag::coordinate(addr, None), ]); self.publish(builder, cx); } - /// Merge a pull request. pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context) { self.last_error = None; self.last_warning = None; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let is_author = Backend::global(cx) .read(cx) .current_user() @@ -1083,7 +1211,6 @@ impl RepoStore { } let cache = GitStore::global(cx).cache().clone(); - let addr = self.addr.clone(); let clone_urls: Vec = self .announcement @@ -1152,17 +1279,17 @@ impl RepoStore { /// The latest announcement of this repository, /// for operations that need its clone URLs and relays. fn action_announcement(&self, cx: &App) -> Option { + let addr = self.addr.as_ref()?; self.announcement.clone().or_else(|| { RepoListStore::global(cx) .read(cx) .announcements .iter() - .find(|announcement| announcement.addr() == self.addr) + .find(|announcement| announcement.addr() == *addr) .cloned() }) } - /// 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!( @@ -1207,7 +1334,6 @@ impl RepoStore { }) } - /// Push the unpushed commits of the local checkout at `path`. pub fn push_checkout( &mut self, path: PathBuf, @@ -1219,6 +1345,10 @@ impl RepoStore { ))); } + let Some(addr) = self.addr.clone() else { + return self.action_error("This repository is not published to Nostr yet", cx); + }; + let Some(announcement) = self.action_announcement(cx) else { return self.action_error("Repository announcement is not loaded yet", cx); }; @@ -1226,7 +1356,6 @@ impl RepoStore { // The state event's `HEAD` stays the announced default branch. // The checkout may be on a side branch. let head = self.head.clone(); - let addr = self.addr.clone(); self.pushing = true; self.last_error = None; @@ -1274,7 +1403,9 @@ impl RepoStore { /// Only the repository owner may delete it. The lists update when the /// deletion events arrive. pub fn delete_repository(&mut self, cx: &mut Context) -> Task> { - let addr = self.addr.clone(); + let Some(addr) = self.addr.clone() else { + return self.action_error("This repository is not published to Nostr yet", cx); + }; self.last_error = None; let backend = Backend::global(cx); @@ -1304,12 +1435,16 @@ impl RepoStore { "A clone of this repository is already in progress" ))); } + + let Some(addr) = self.addr.clone() else { + return self.action_error("This repository is not published to Nostr yet", cx); + }; + let Some(announcement) = self.action_announcement(cx) else { return self.action_error("Repository announcement is not loaded yet", cx); }; let clone_urls = announcement.clone.clone(); - let addr = self.addr.clone(); self.cloning = true; self.last_error = None; @@ -1328,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); @@ -1346,7 +1480,11 @@ impl RepoStore { }) } - /// Fail an operation whose announcement is not loaded yet. + fn not_announced(&mut self, cx: &mut Context) { + self.last_error = Some("This repository is not published to Nostr yet".into()); + cx.notify(); + } + fn action_error( &mut self, message: impl Into, @@ -1369,11 +1507,15 @@ impl RepoStore { euc: Option<&str>, cx: &mut Context, ) { + let Some(addr) = self.addr.clone() else { + return; + }; + let mut tags = vec![ Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"), - Tag::public_key(self.addr.public_key), + Tag::public_key(addr.public_key), Tag::public_key(root.pubkey), - Tag::coordinate(self.addr.clone(), None), + Tag::coordinate(addr, None), ]; if let Some(euc) = euc @@ -1466,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) @@ -1474,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], @@ -1641,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(); @@ -1683,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 b4a88f7..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; @@ -117,11 +111,6 @@ impl LocalReposStore { } } -/// Delay between a refresh request and the actual re-query. -/// -/// Bursts of events, e.g. sync progress ticks, collapse into one query. -const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); - /// How far back activity events count toward a repository's last activity. const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400); @@ -154,19 +143,16 @@ pub struct RepoListStore { /// Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, /// Latest known activity timestamp per repository. - /// Covers announcements, state updates, patches, PRs, issues and statuses. pub last_activity: Arc>, /// Issues, pull requests and commits per repository. /// /// 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() } @@ -175,9 +161,9 @@ 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(); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { @@ -217,17 +203,12 @@ impl RepoListStore { } }); - let weak = cx.entity().downgrade(); cx.defer(move |cx| { - let result = weak.update(cx, |this, cx| { + weak.update(cx, |this, cx| { this.subscribe_remote(cx); - // Query the local database right away. - // The list never waits for the relay syncs started above to finish. - this.refresh_initial(cx); - }); - if let Err(error) = result { - log::warn!("repo list store dropped before bootstrap could run: {error}"); - } + this.refresh(cx); + }) + .ok(); }); Self { @@ -248,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); @@ -259,33 +239,18 @@ impl RepoListStore { }); } - /// One-shot initial load. - /// - /// Query the local database immediately, no debounce. - /// Stored announcements appear as soon as the app opens. - fn refresh_initial(&mut self, cx: &mut Context) { - debug_assert!(!self.refresh.debouncing()); - if self.refresh.running() { - self.refresh.request(); - return; - } - self.run_refresh(cx); - } - /// Re-query the local database. + /// + /// Runs immediately. The backend pump already batches the relay events that + /// trigger a refresh, so no per-store debounce is needed. pub fn refresh(&mut self, cx: &mut Context) { if self.refresh.request() != RefreshRequest::Schedule { return; } - cx.spawn(async move |this, cx| { - cx.background_executor().timer(REFRESH_DEBOUNCE).await; - this.update(cx, |this, cx| this.run_refresh(cx)) - }) - .detach(); + self.run_refresh(cx); } - /// One query and apply cycle, the debounced entry point. 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/lib.rs b/crates/signed_ui/src/lib.rs index 7f63693..7c63981 100644 --- a/crates/signed_ui/src/lib.rs +++ b/crates/signed_ui/src/lib.rs @@ -2,6 +2,7 @@ mod dropdown_button; mod nav_item; mod pixel_avatar; mod placeholder; +mod ref_selector; mod segment_button; mod setting; mod status_badge; @@ -17,6 +18,7 @@ pub use dropdown_button::DropdownButton; pub use nav_item::NavItem; pub use pixel_avatar::PixelAvatar; pub use placeholder::placeholder; +pub use ref_selector::ref_selector_trigger; pub use segment_button::{CountBadge, SegmentButton}; pub use setting::{SelectOption, setting_block, setting_row}; pub use status_badge::status_badge; diff --git a/crates/signed_ui/src/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/ref_selector.rs b/crates/signed_ui/src/ref_selector.rs new file mode 100644 index 0000000..1b508d0 --- /dev/null +++ b/crates/signed_ui/src/ref_selector.rs @@ -0,0 +1,41 @@ +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{AnyElement, App, SharedString, div}; +use gpui_component::combobox::{Caret, ComboboxTriggerContext}; +use gpui_component::searchable_list::SearchableVec; +use gpui_component::{ActiveTheme, Icon, Sizable, h_flex}; + +/// The kind icon, the selection or placeholder, and the caret. `Combobox` +/// replaces its default trigger entirely, the only way to show an icon inside it. +pub fn ref_selector_trigger( + ctx: &ComboboxTriggerContext>, + icon: CustomIconName, + cx: &App, +) -> AnyElement { + let muted = cx.theme().muted_foreground; + + h_flex() + .w_full() + .min_w_0() + .gap_1() + .items_center() + .child(Icon::new(icon).small().flex_shrink_0()) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .when(ctx.selection().is_empty(), |this| this.text_color(muted)) + .child( + ctx.selection() + .first() + .map(|(_, item)| item.clone()) + .or_else(|| ctx.placeholder().cloned()) + .unwrap_or_default(), + ), + ) + .child(Caret::new(ctx.size()).text_color(muted)) + .into_any_element() +} diff --git a/crates/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/Cargo.toml b/crates/workspace/Cargo.toml index 59d09f5..f093cde 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -18,7 +18,6 @@ utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true gpui-base.workspace = true -gpui-fps.workspace = true gix.workspace = true nostr.workspace = true diff --git a/crates/workspace/src/views/repo_detail/diff.rs b/crates/workspace/src/views/commit_diff/mod.rs similarity index 75% rename from crates/workspace/src/views/repo_detail/diff.rs rename to crates/workspace/src/views/commit_diff/mod.rs index bcd6474..98ad225 100644 --- a/crates/workspace/src/views/repo_detail/diff.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -13,34 +13,25 @@ use gpui_component::resizable::{resizable_panel, v_resizable}; use gpui_component::scroll::{ScrollableElement, Scrollbar}; use gpui_component::spinner::Spinner; use gpui_component::tag::Tag; -use gpui_component::tree::{TreeEntry, TreeState, tree}; +use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; -use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff}; +use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; -use super::helpers::{ - DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items, -}; +use crate::views::tree::{build_tree_items, tree_items}; -/// 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 +47,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 +75,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 +85,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 +103,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 +119,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 +148,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 +165,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 +291,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 +334,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 +381,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| { @@ -535,3 +508,180 @@ impl Render for CommitDiffView { .child(resizable_panel().child(body)) } } + +const GUTTER_WIDTH: f32 = 44.; +const DIFF_ROW_HEIGHT: f32 = 20.; + +#[derive(Clone, Copy)] +enum DiffRow { + Hunk { + old_start: u32, + old_lines: u32, + new_start: u32, + new_lines: u32, + }, + Line { + hunk: usize, + line: usize, + }, +} + +fn diff_rows(file: &FileDiff) -> Vec { + let mut rows = Vec::new(); + for (hunk_ix, hunk) in file.hunks.iter().enumerate() { + rows.push(DiffRow::Hunk { + old_start: hunk.old_start, + old_lines: hunk.old_lines, + new_start: hunk.new_start, + new_lines: hunk.new_lines, + }); + rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line { + hunk: hunk_ix, + line, + })); + } + rows +} + +fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { + match row { + DiffRow::Hunk { + old_start, + old_lines, + new_start, + new_lines, + } => div() + .px_2() + .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .bg(cx.theme().muted) + .border_y(px(1.)) + .border_color(cx.theme().border) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!( + "@@ -{},{} +{},{} @@", + old_start, old_lines, new_start, new_lines + ))) + .into_any_element(), + DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx), + } +} + +fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { + let bg = match line.kind { + DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), + DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)), + DiffLineKind::Context => None, + }; + let gutter = cx.theme().muted_foreground; + + // Fixed height and nowrap, the virtual list assumes every row has the same height. + // Long lines are clipped instead of wrapped. + h_flex() + .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .items_center() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .when_some(bg, |this, bg| this.bg(bg)) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.old.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.new.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_color(cx.theme().foreground) + .child(line.text.clone()), + ) + .into_any_element() +} + +fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { + let id = id?; + items.iter().find_map(|item| { + if item.id.as_ref() == id { + Some(item) + } else { + find_item(&item.children, Some(id)) + } + }) +} + +pub(crate) const COMMIT_ROW_HEIGHT: f32 = 56.; + +pub(crate) fn commit_row( + ix: usize, + commit: &FileCommit, + on_click: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + h_flex() + .id(ix) + .px_4() + .h(px(COMMIT_ROW_HEIGHT)) + .w_full() + .gap_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .hover(|this| this.bg(cx.theme().list_hover)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .justify_center() + .child( + h_flex() + .gap_2() + .items_center() + .overflow_hidden() + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.id.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.author.clone()) + .child(relative_time_secs(commit.time)), + ), + ) + .on_click(move |_event, window, cx| on_click(window, cx)) + .into_any_element() +} diff --git a/crates/workspace/src/views/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/discussion.rs b/crates/workspace/src/views/discussion.rs new file mode 100644 index 0000000..8f52a56 --- /dev/null +++ b/crates/workspace/src/views/discussion.rs @@ -0,0 +1,217 @@ +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{AnyElement, App, Entity, SharedString, div, px}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Textarea, TextareaState}; +use gpui_component::tag::Tag; +use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; +use nostr::prelude::{Event, EventId, PublicKey}; +use signed_state::{ProfileStore, RepoStore}; +use signed_ui::UserAvatar; +use utils::relative_time; + +pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] { + &store.issues +} + +pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] { + &store.pull_requests +} + +fn sidebar_title(text: &str, cx: &App) -> AnyElement { + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(text.to_string()) + .into_any_element() +} + +pub(crate) fn sidebar_section( + store: &Entity, + id: EventId, + roots: fn(&RepoStore) -> &[Event], + top_gap: bool, + cx: &App, +) -> AnyElement { + let store = store.read(cx); + let Some(root) = roots(store).iter().find(|event| event.id == id) else { + // The caller bails out when the root is missing. + return div().into_any_element(); + }; + let profile_store = ProfileStore::global(cx); + + // Participants, the root author plus everyone who commented. + let mut participants: Vec = vec![root.pubkey]; + participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); + participants.sort_by_key(PublicKey::to_hex); + participants.dedup(); + + // Labels are NIP-34 `t` hashtag tags on the event. + let labels: Vec = root.tags.hashtags().map(|tag| tag.to_string()).collect(); + + v_flex() + .w(px(240.)) + .h_full() + .flex_none() + .px_4() + .gap_4() + .border_l(px(1.)) + .border_color(cx.theme().sidebar_border) + .child( + v_flex() + .when(top_gap, |this| this.mt_4()) + .gap_2() + .child(sidebar_title("Participants", cx)) + .children(participants.iter().map(|pubkey| { + let profile = profile_store.read(cx).get(pubkey); + let name = profile.name(); + let picture = profile.picture(); + + h_flex() + .gap_1() + .items_center() + .child(UserAvatar::new(name.clone()).picture(picture)) + .child(div().text_sm().truncate().text_ellipsis().child(name)) + .into_any_element() + })), + ) + .child( + v_flex() + .gap_2() + .child(sidebar_title("Labels", cx)) + .map(|this| { + if labels.is_empty() { + this.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("None yet."), + ) + } else { + this.child(h_flex().gap_1().children({ + let mut items = vec![]; + + for label in labels.iter() { + items.push( + Tag::secondary() + .outline() + .xsmall() + .child(SharedString::from(label)), + ); + } + + items + })) + } + }), + ) + .into_any_element() +} + +pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { + let store = store.read(cx); + let comments: Vec<&Event> = store.comments_of(&root).collect(); + let title = SharedString::from(format!("Discussions {}", comments.len())); + + v_flex() + .gap_4() + .child(div().text_xs().font_semibold().child(title)) + .children(comments.iter().map(|comment| { + let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); + let author = profile.name(); + let picture = profile.picture(); + let age = relative_time(comment.created_at); + let content = SharedString::from(comment.content.as_str()); + + v_flex() + .gap_1() + .p_3() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .child( + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .gap_1() + .child(UserAvatar::new(author.clone()).picture(picture)) + .child(author), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("commented"), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(age)), + ), + ) + .child(div().text_sm().child(content)) + })) + .into_any_element() +} + +/// `roots` selects the root's list within the store, issues or pull requests. +pub(crate) fn comment_form( + store: &Entity, + root: EventId, + roots: fn(&RepoStore) -> &[Event], + comment_input: &Entity, + button_id: &'static str, + cx: &App, +) -> AnyElement { + let comment_input = comment_input.clone(); + let store = store.clone(); + + v_flex() + .gap_2() + .child( + Textarea::new(&comment_input) + .h_24() + .text_color(cx.theme().muted_foreground) + .bg(cx.theme().muted), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(CustomIconName::Markdown).small()) + .child("Markdown is supported"), + ) + .child( + Button::new(button_id) + .primary() + .label("Comment") + .tooltip("Post comment") + .on_click(move |_event, window, cx| { + let content = comment_input.read(cx).value().trim().to_string(); + if content.is_empty() { + return; + } + let Some(root) = roots(store.read(cx)) + .iter() + .find(|event| event.id == root) + .cloned() + else { + return; + }; + store.update(cx, |store, cx| { + store.comment(&root, content, cx); + }); + comment_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + }), + ), + ) + .into_any_element() +} diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index 01d7fee..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()); @@ -393,16 +371,6 @@ impl InboxView { return; }; - let Some(announcement) = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|announcement| announcement.addr() == address) - .cloned() - else { - return; - }; - let item = match kind { Some(Kind::GitIssue) => RepoItem::Issue(root), Some(Kind::GitPullRequest) => RepoItem::PullRequest(root), @@ -410,7 +378,7 @@ impl InboxView { _ => return, }; - open_repo_item(&self.dock_area, &announcement, item, window, cx); + open_repo_item(&self.dock_area, &address, None, item, window, cx); } fn render_entry(&self, ix: usize, cx: &Context) -> AnyElement { @@ -455,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?; @@ -467,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")); @@ -491,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() @@ -610,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"; @@ -630,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/repo_detail/issue_detail.rs b/crates/workspace/src/views/issues/detail.rs similarity index 91% rename from crates/workspace/src/views/repo_detail/issue_detail.rs rename to crates/workspace/src/views/issues/detail.rs index 08b23e2..ecb78cb 100644 --- a/crates/workspace/src/views/repo_detail/issue_detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -1,8 +1,8 @@ use dock::{BasePanel, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ - App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div, - relative, + App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Subscription, + Window, div, relative, }; use gpui_component::input::TextareaState; use gpui_component::scroll::ScrollableElement; @@ -13,16 +13,14 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; +use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section}; -/// Detail panel of a single issue. pub struct IssueDetailView { - /// Repo store holding the issues and their statuses. + focus_handle: FocusHandle, store: Entity, issue_id: EventId, - /// Input state of the comment textarea. comment_input: Entity, - focus_handle: FocusHandle, + _subscription: Subscription, } impl IssueDetailView { @@ -35,11 +33,14 @@ impl IssueDetailView { let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); + let subscription = cx.observe(&store, |_this, _store, cx| cx.notify()); + Self { focus_handle: cx.focus_handle(), store, issue_id, comment_input, + _subscription: subscription, } } } @@ -81,7 +82,12 @@ impl Render for IssueDetailView { let store = self.store.read(cx); let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else { - return placeholder("Issue not found", cx); + // The store has not applied its first pass yet, the issue may still arrive. + return if store.loaded { + placeholder("Issue not found", cx) + } else { + placeholder("Loading issue...", cx) + }; }; let (title, author, picture, status, age, issue_id, content) = { diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/issues/mod.rs similarity index 86% rename from crates/workspace/src/views/repo_detail/issues.rs rename to crates/workspace/src/views/issues/mod.rs index 6820606..a3741ed 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -4,8 +4,8 @@ use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, WeakEntity, Window, div, px, size, + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, + Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; @@ -21,24 +21,20 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::issue_detail::IssueDetailView; +pub(super) mod detail; + +use self::detail::IssueDetailView; -/// Height of one issue row in the virtual list. const ISSUE_ROW_HEIGHT: f32 = 73.; -/// 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, @@ -50,37 +46,37 @@ 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>>, - /// The filtered issue count [`Self::item_sizes`] was built for. - issue_len: usize, - /// Indices into the store's `issues` matching [`Self::filter`]. visible_issues: Vec, - /// Header counts `(total, open, closed)`, rebuilt with [`Self::visible_issues`]. counts: (usize, usize, usize), - /// Store version and filter the cached rows/counts were built from. - cache_key: Option<(u64, IssueFilter)>, - /// Virtual list state of the issues list. + // A filter change notifies even when the visible rows are unchanged, + // e.g. switching between two empty filters. + synced_filter: IssueFilter, scroll_handle: VirtualListScrollHandle, + _subscription: Subscription, } impl IssuesView { pub fn new( dock_area: WeakEntity, store: Entity, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Self { let repo_name = store.read(cx).name(); + let subscription = cx.observe(&store, |this, _store, cx| { + this.rebuild(cx); + }); + + cx.defer_in(window, |this, _window, cx| { + this.rebuild(cx); + }); + Self { focus_handle: cx.focus_handle(), dock_area, @@ -88,14 +84,60 @@ impl IssuesView { repo_name, filter: IssueFilter::Open, item_sizes: Rc::new(Vec::new()), - issue_len: 0, visible_issues: Vec::new(), counts: (0, 0, 0), - cache_key: None, + synced_filter: IssueFilter::Open, scroll_handle: VirtualListScrollHandle::new(), + _subscription: subscription, } } + fn rebuild(&mut self, cx: &mut Context) { + let filter = self.filter; + + let (visible_issues, counts) = { + let store = self.store.read(cx); + let mut counts = (0usize, 0usize, 0usize); + + let visible_issues: Vec = store + .issues + .iter() + .enumerate() + .filter_map(|(ix, issue)| { + let status = store.status_of(issue); + counts.0 += 1; + match status { + RepoStatus::Open => counts.1 += 1, + RepoStatus::Closed => counts.2 += 1, + RepoStatus::Draft | RepoStatus::Applied => {} + } + filter.matches(status).then_some(ix) + }) + .collect(); + + (visible_issues, counts) + }; + + let filter_changed = self.synced_filter != filter; + let visible_issues_changed = self.visible_issues != visible_issues; + let counts_changed = self.counts != counts; + + if !filter_changed && !visible_issues_changed && !counts_changed { + return; + } + + self.item_sizes = Rc::new(vec![ + size(px(0.), px(ISSUE_ROW_HEIGHT)); + visible_issues.len() + ]); + + self.synced_filter = filter; + self.visible_issues = visible_issues; + self.counts = counts; + + cx.notify(); + } + /// Open the detail panel of `issue_id` in the dock area. fn open_issue_detail( &mut self, @@ -176,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() @@ -197,7 +238,7 @@ impl IssuesView { .selected(self.filter == IssueFilter::All) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = IssueFilter::All; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -207,7 +248,7 @@ impl IssuesView { .selected(self.filter == IssueFilter::Open) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = IssueFilter::Open; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -217,7 +258,7 @@ impl IssuesView { .selected(self.filter == IssueFilter::Closed) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = IssueFilter::Closed; - cx.notify(); + this.rebuild(cx); })), ), ) @@ -234,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...")); @@ -322,41 +362,7 @@ impl Focusable for IssuesView { impl Render for IssuesView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let filter = self.filter; - - // Rows and counts are rebuilt only when the store refreshed or filter changed. - let version = self.store.read(cx).version(); - - if self.cache_key != Some((version, filter)) { - let store = self.store.read(cx); - let mut counts = (0usize, 0usize, 0usize); - self.visible_issues = store - .issues - .iter() - .enumerate() - .filter_map(|(ix, issue)| { - let status = store.status_of(issue); - counts.0 += 1; - match status { - RepoStatus::Open => counts.1 += 1, - RepoStatus::Closed => counts.2 += 1, - RepoStatus::Draft | RepoStatus::Applied => {} - } - filter.matches(status).then_some(ix) - }) - .collect(); - self.counts = counts; - self.cache_key = Some((version, filter)); - } - let count = self.visible_issues.len(); - - // The virtual list's item count comes from `item_sizes`. - // Rebuild it whenever the filtered issue count changes. - if count != self.issue_len { - self.issue_len = count; - self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]); - } - let sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index eb79c7e..ba027ad 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -1,11 +1,17 @@ +mod commit_diff; mod dialog_state; +pub(crate) mod discussion; mod inbox; -mod repo_detail; +mod issues; +mod pull_requests; +mod repo; mod repo_list; +mod send_patch; pub(crate) mod sidebar; +pub(crate) mod tree; pub use inbox::InboxView; -pub use repo_detail::RepoDetailView; -pub(crate) use repo_detail::{RepoItem, open_repo_item, open_repo_panel}; +pub use repo::RepoDetailView; +pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel}; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; diff --git a/crates/workspace/src/views/repo_detail/pull_request_detail.rs b/crates/workspace/src/views/pull_requests/detail.rs similarity index 71% rename from crates/workspace/src/views/repo_detail/pull_request_detail.rs rename to crates/workspace/src/views/pull_requests/detail.rs index 86ed7ba..392ad41 100644 --- a/crates/workspace/src/views/repo_detail/pull_request_detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -6,7 +6,7 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, WeakEntity, Window, div, px, relative, size, + SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; @@ -20,9 +20,9 @@ use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; -use nostr::prelude::{Event, EventId, Kind}; +use nostr::prelude::{Event, EventId, Kind, Url}; use signed_core::{ - activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update, + RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update, merge_base_of, pull_request_patch, }; use signed_git::{FileCommit, patch_commits, patch_diffs}; @@ -30,45 +30,57 @@ use signed_state::{Backend, GitStore, ProfileStore, RepoStore}; use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; -use super::diff::{CommitDiffView, DiffPane}; -use super::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; +use crate::views::commit_diff::{CommitDiffView, DiffPane}; +use crate::views::discussion::{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"; + +/// A store refresh re-binds the panel, and reloads only when these change. +#[derive(Clone, PartialEq, Eq)] +struct PrBinding { + description: String, + patch: String, + tip: Option, + base: Option, + clone_urls: Vec, + addr: RepoAddr, + has_patch_link: bool, +} + /// Detail panel of a single pull request. pub struct PullRequestDetailView { focus_handle: FocusHandle, - /// 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, /// The patch is being parsed on a background task. loading: bool, error: Option, - /// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits. + /// Root PR inputs the in-flight diff load was started for. + bound: Option, + /// Generation of the in-flight diff load. Stale results are discarded. + load_generation: u64, + /// 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, + /// The dock caches item panels, so without this observer a panel opened + /// before the store loaded would stay on its placeholder. + _subscription: Subscription, } impl PullRequestDetailView { @@ -85,9 +97,11 @@ impl PullRequestDetailView { let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); + let subscription = cx.observe(&store, |this, _store, cx| this.sync(cx)); + // Defer loading until the window is ready, like the commit diff view. - cx.defer_in(window, |this, window, cx| { - this.load(window, cx); + cx.defer_in(window, |this, _window, cx| { + this.sync(cx); }); Self { @@ -103,156 +117,217 @@ impl PullRequestDetailView { commits: Vec::new(), loading: true, error: None, + bound: None, + load_generation: 0, active_tab: 0, pane, commit_item_sizes: Rc::new(Vec::new()), commit_scroll_handle: VirtualListScrollHandle::new(), + _subscription: subscription, } } - /// Snapshot the PR events from the store. - fn load(&mut self, window: &mut Window, cx: &mut Context) { + /// Snapshot the root PR from the store and reload the diff when it changed. + /// + /// Re-runs on construction and on every store refresh. Item panels are + /// cached by the dock, so this is the only way a panel opened before the + /// store's first pass learns about its PR. + fn sync(&mut self, cx: &mut Context) { + let loaded = self.store.read(cx).loaded; + + let binding = { + let store = self.store.read(cx); + + store.addr().and_then(|addr| { + store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + .map(|root| { + let update = latest_update(store.pull_requests.iter(), root); + + let tip = update + .and_then(current_commit_of) + .or_else(|| current_commit_of(root)); + + let base = update + .and_then(merge_base_of) + .or_else(|| merge_base_of(root)); + + let clone_urls = clone_urls_of(root) + .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) + .unwrap_or_default(); + + PrBinding { + description: root.content.clone(), + patch: pull_request_patch(root, store.patches.iter()), + tip, + base, + clone_urls, + addr: addr.clone(), + has_patch_link: root.tags.event_ids().next().is_some(), + } + }) + }) + }; + + let Some(binding) = binding else { + self.sync_missing(loaded, cx); + return; + }; + + if self.bound.as_ref() == Some(&binding) { + return; + } + + self.bound = Some(binding.clone()); + self.load_diff(binding, cx); + } + + /// The store does not hold the root PR yet, or at all. + /// + /// Loading until the first pass is applied, not found afterwards. + fn sync_missing(&mut self, loaded: bool, cx: &mut Context) { + self.bound = None; + + if !loaded { + if !self.loading || self.error.is_some() { + self.loading = true; + self.error = None; + cx.notify(); + } + return; + } + + if self.error.as_deref() != Some(NOT_FOUND) { + self.loading = false; + self.error = Some(NOT_FOUND.into()); + cx.notify(); + } + } + + /// Load the bound PR's changed files and commits. + /// + /// Nostr-backed pull requests parse the patch series, git-backed ones fetch + /// the clone and diff the `merge-base..tip` range. + fn load_diff(&mut self, binding: PrBinding, cx: &mut Context) { self.loading = true; self.error = None; + self.description = binding.description.clone().into(); + self.current_commit = binding.tip.clone().map(SharedString::from); cx.notify(); let cache = GitStore::global(cx).cache().clone(); - let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = { - let store = self.store.read(cx); + self.load_generation = self.load_generation.wrapping_add(1); + let generation = self.load_generation; - let Some(root) = store - .pull_requests - .iter() - .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) - else { - self.loading = false; - self.error = Some("Pull request not found".into()); - cx.notify(); - return; + let PrBinding { + patch, + tip, + base, + clone_urls, + addr, + has_patch_link, + .. + } = binding; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let nostr_diff = cx + .background_spawn({ + let patch = patch.clone(); + async move { patch_diffs(&patch) } + }) + .await; + + let nostr_commits = cx + .background_spawn({ + let patch = patch.clone(); + async move { patch_commits(&patch) } + }) + .await; + + // PRs without patch events, e.g. published by ngit, carry their changes in git. + // Fetch the clone and diff the `merge-base..tip` range. + let use_nostr = match &nostr_diff { + Ok(diff) => has_patch_link || !diff.files.is_empty(), + Err(_) => true, }; - let update = latest_update(store.pull_requests.iter(), root); + let git = if use_nostr { + None + } else { + let cache = cache.clone(); + let addr = addr.clone(); + let clone_urls = clone_urls.clone(); + let base = base.clone(); + let tip = tip.clone(); - let tip = update - .and_then(current_commit_of) - .or_else(|| current_commit_of(root)); + Some( + cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; - let base = update - .and_then(merge_base_of) - .or_else(|| merge_base_of(root)); + let workdir = repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? + .to_path_buf(); - let clone_urls = clone_urls_of(root) - .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())); + let tip = + tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; - ( - root.content.clone(), - pull_request_patch(root, store.patches.iter()), - tip, - base, - clone_urls.unwrap_or_default(), - store.addr().clone(), - root.tags.event_ids().next().is_some(), - ) - }; + let base = match base { + Some(base) => base, + // No `merge-base` tag. Use the merge base of the tip and the default branch. + None => { + let head = repo + .head_id() + .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; + let tip_id = repo.rev_parse_single(tip.as_bytes())?; + repo.merge_base(tip_id, head)?.to_string() + } + }; - self.description = description.into(); + let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; + let commits = + signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; - let task: gpui::Task> = - cx.spawn_in(window, async move |this, cx| { - let nostr_diff = cx - .background_spawn({ - let patch = patch.clone(); - async move { patch_diffs(&patch) } + Ok::<_, anyhow::Error>((diff, commits, workdir)) }) - .await; + .await, + ) + }; - let nostr_commits = cx - .background_spawn({ - let patch = patch.clone(); - async move { patch_commits(&patch) } - }) - .await; + let (diff, commits, worktree) = match git { + Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)), + Some(Err(error)) => (Err(error), Vec::new(), None), + None => (nostr_diff, nostr_commits, None), + }; - // PRs without patch events, e.g. published by ngit, carry their changes in git. - // Fetch the clone and diff the `merge-base..tip` range. - let use_nostr = match &nostr_diff { - Ok(diff) => has_patch_link || !diff.files.is_empty(), - Err(_) => true, - }; + this.update(cx, |this, cx| { + // A newer binding superseded this load. + if this.load_generation != generation { + return; + } - let git = if use_nostr { - None - } else { - let cache = cache.clone(); - let addr = addr.clone(); - let clone_urls = clone_urls.clone(); - let base = merge_base.clone(); - let tip = current_commit.clone(); + this.loading = false; + this.worktree = worktree; + this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]); + this.commits = commits; - Some( - cx.background_spawn(async move { - let repo = cache.ensure_clone(&addr, &clone_urls)?; - - let workdir = repo - .workdir() - .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? - .to_path_buf(); - - let tip = tip - .ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; - - let base = match base { - Some(base) => base, - // No `merge-base` tag. Use the merge base of the tip and the default branch. - None => { - let head = repo - .head_id() - .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; - let tip_id = repo.rev_parse_single(tip.as_bytes())?; - repo.merge_base(tip_id, head)?.to_string() - } - }; - - let diff = - signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?; - let commits = - signed_git::worktree_commit_range_commits(&workdir, &base, &tip)?; - - Ok::<_, anyhow::Error>((diff, commits, workdir)) - }) - .await, - ) - }; - - let (diff, commits, worktree) = match git { - Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)), - Some(Err(error)) => (Err(error), Vec::new(), None), - None => (nostr_diff, nostr_commits, None), - }; - - this.update_in(cx, |this, _window, cx| { - this.loading = false; - this.worktree = worktree; - this.current_commit = current_commit.map(SharedString::from); - this.commit_item_sizes = - Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]); - this.commits = commits; - - match diff { - Ok(diff) => { - this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); - } - Err(error) => { - this.error = Some(error.to_string().into()); - } + match diff { + Ok(diff) => { + this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); } + Err(error) => { + this.error = Some(error.to_string().into()); + } + } - cx.notify(); - })?; + cx.notify(); + })?; - Ok(()) - }); + Ok(()) + }); task.detach(); } @@ -435,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() @@ -485,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, @@ -540,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) = { @@ -642,7 +710,6 @@ impl PullRequestDetailView { } } -/// Open the update pull request dialog. fn open_update_pull_request_dialog( store: Entity, root: Event, @@ -703,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. @@ -761,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/repo_detail/pull_requests.rs b/crates/workspace/src/views/pull_requests/mod.rs similarity index 84% rename from crates/workspace/src/views/repo_detail/pull_requests.rs rename to crates/workspace/src/views/pull_requests/mod.rs index f5cab0e..a1a4949 100644 --- a/crates/workspace/src/views/repo_detail/pull_requests.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -4,8 +4,8 @@ use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, WeakEntity, Window, div, px, size, + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, + Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; use gpui_base::Button as BaseButton; use gpui_component::alert::Alert; @@ -19,31 +19,26 @@ use signed_state::{ProfileStore, RepoStore}; use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge}; use utils::relative_time; -use super::RepoAction; -use super::new_pull_request::open_new_pull_panel; -use super::pull_request_detail::PullRequestDetailView; +pub(super) mod detail; +pub(super) mod new; + +use self::detail::PullRequestDetailView; +use self::new::open_new_pull_panel; use super::send_patch::open_send_patch_panel; +use crate::views::repo::RepoAction; -/// Height of one pull request row in the virtual list. const ROW_HEIGHT: f32 = 73.; -/// 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, @@ -57,37 +52,39 @@ 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>>, - /// The filtered pull request count [`Self::item_sizes`] was built for. - pr_len: usize, /// Indices into the store's `pull_requests` matching [`Self::filter`]. visible_prs: Vec, /// Header counts `(total, open, closed, draft, merged)`. counts: (usize, usize, usize, usize, usize), - /// Store version and filter the cached rows/counts were built from. - cache_key: Option<(u64, PullRequestFilter)>, - /// Virtual list state of the pull requests list. + // A filter change notifies even when the visible rows are unchanged, + // e.g. switching between two empty filters. + synced_filter: PullRequestFilter, scroll_handle: VirtualListScrollHandle, + _subscription: Subscription, } impl PullRequestsView { pub fn new( dock_area: WeakEntity, store: Entity, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Self { let repo_name = store.read(cx).name(); + let subscription = cx.observe(&store, |this, _store, cx| { + this.rebuild(cx); + }); + + cx.defer_in(window, |this, _window, cx| { + this.rebuild(cx); + }); + Self { focus_handle: cx.focus_handle(), dock_area, @@ -95,15 +92,61 @@ impl PullRequestsView { repo_name, filter: PullRequestFilter::Open, item_sizes: Rc::new(Vec::new()), - pr_len: 0, visible_prs: Vec::new(), counts: (0, 0, 0, 0, 0), - cache_key: None, + synced_filter: PullRequestFilter::Open, scroll_handle: VirtualListScrollHandle::new(), + _subscription: subscription, } } - /// Open the detail panel of `pr_id` in the dock area. + fn rebuild(&mut self, cx: &mut Context) { + let filter = self.filter; + + let (visible_prs, counts) = { + let store = self.store.read(cx); + let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize); + + let visible_prs: Vec = store + .pull_requests + .iter() + .enumerate() + .filter_map(|(ix, pr)| { + if pr.kind != Kind::GitPullRequest { + return None; + } + + let status = store.status_of(pr); + counts.0 += 1; + + match status { + RepoStatus::Open => counts.1 += 1, + RepoStatus::Closed => counts.2 += 1, + RepoStatus::Draft => counts.3 += 1, + RepoStatus::Applied => counts.4 += 1, + } + + filter.matches(status).then_some(ix) + }) + .collect(); + + (visible_prs, counts) + }; + + let filter_changed = self.synced_filter != filter; + + if !filter_changed && self.visible_prs == visible_prs && self.counts == counts { + return; + } + + self.synced_filter = filter; + self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); visible_prs.len()]); + self.visible_prs = visible_prs; + self.counts = counts; + + cx.notify(); + } + fn open_pull_request_detail( &mut self, pr_id: EventId, @@ -129,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; @@ -196,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() @@ -217,7 +256,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::All) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::All; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -227,7 +266,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::Open) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Open; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -237,7 +276,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::Closed) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Closed; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -247,7 +286,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::Draft) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Draft; - cx.notify(); + this.rebuild(cx); })), ) .child( @@ -257,7 +296,7 @@ impl PullRequestsView { .selected(self.filter == PullRequestFilter::Merged) .on_click(cx.listener(|this, _event, _window, cx| { this.filter = PullRequestFilter::Merged; - cx.notify(); + this.rebuild(cx); })), ), ) @@ -330,55 +369,11 @@ impl Focusable for PullRequestsView { impl Render for PullRequestsView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let filter = self.filter; - - // Rows and counts are rebuilt only when the store refreshed or filter changed. - let version = self.store.read(cx).version(); - - if self.cache_key != Some((version, filter)) { - let store = self.store.read(cx); - let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize); - self.visible_prs = store - .pull_requests - .iter() - .enumerate() - .filter_map(|(ix, pr)| { - if pr.kind != Kind::GitPullRequest { - return None; - } - - let status = store.status_of(pr); - counts.0 += 1; - - match status { - RepoStatus::Open => counts.1 += 1, - RepoStatus::Closed => counts.2 += 1, - RepoStatus::Draft => counts.3 += 1, - RepoStatus::Applied => counts.4 += 1, - } - - filter.matches(status).then_some(ix) - }) - .collect(); - - self.counts = counts; - self.cache_key = Some((version, filter)); - } - let count = self.visible_prs.len(); - - // The virtual list's item count comes from `item_sizes`. - // Rebuild it whenever the filtered pull request count changes. - if count != self.pr_len { - self.pr_len = count; - self.item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); count]); - } - let sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); let view = cx.entity().clone(); - // 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/repo_detail/new_pull_request.rs b/crates/workspace/src/views/pull_requests/new.rs similarity index 92% rename from crates/workspace/src/views/repo_detail/new_pull_request.rs rename to crates/workspace/src/views/pull_requests/new.rs index b2b4a07..2241dee 100644 --- a/crates/workspace/src/views/repo_detail/new_pull_request.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -27,26 +27,19 @@ use signed_git::{ worktree_commit_range_commits, worktree_commit_range_diff, }; use signed_state::{Backend, CheckoutsStore, GitStore, RepoListStore, RepoStore}; -use signed_ui::{CountBadge, placeholder}; +use signed_ui::{CountBadge, placeholder, ref_selector_trigger}; -use super::commits::{COMMIT_ROW_HEIGHT, commit_row}; -use super::diff::{CommitDiffView, DiffPane}; -use super::helpers::ref_selector_trigger; +use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row}; -/// 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, @@ -54,33 +47,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, @@ -91,20 +78,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 @@ -113,7 +95,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() @@ -132,9 +113,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, @@ -163,7 +141,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( @@ -180,9 +157,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, @@ -208,7 +182,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, @@ -306,7 +279,24 @@ impl NewPullRequestView { ), ]; - let mut view = Self { + cx.defer_in(window, |this, window, cx| { + let Some(addr) = this.store.read(cx).addr().cloned() else { + return; + }; + + let Some(path) = CheckoutsStore::global(cx) + .read(cx) + .associations_of(&addr) + .into_iter() + .next() + else { + return; + }; + + this.apply_folder_path(path, window, cx); + }); + + Self { focus_handle: cx.focus_handle(), dock_area, store, @@ -331,23 +321,9 @@ impl NewPullRequestView { scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), _subscriptions: subscriptions, - }; - - // Prefill with the store's freshest associated checkout, no folder dialog. - let addr = view.store.read(cx).addr().clone(); - if let Some(path) = CheckoutsStore::global(cx) - .read(cx) - .associations_of(&addr) - .into_iter() - .next() - { - view.apply_folder_path(path, window, cx); } - - view } - /// Whether a compare source, a checkout or a fork, is applied. fn has_source(&self) -> bool { self.repo_path.is_some() || self.fork.is_some() } @@ -384,7 +360,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, @@ -415,9 +390,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(); @@ -446,7 +419,6 @@ impl NewPullRequestView { task.detach(); } - /// Apply a picked checkout, filling the selectors and loading the compare. fn apply_checkout( &mut self, path: String, @@ -497,11 +469,12 @@ impl NewPullRequestView { // Remember this folder as a checkout of the target repository. // The next panel pre-fills it. - let addr = self.store.read(cx).addr().clone(); - let checkout_store = CheckoutsStore::global(cx); - checkout_store.update(cx, |store, cx| { - store.record(PathBuf::from(&path), addr, cx); - }); + if let Some(addr) = self.store.read(cx).addr().cloned() { + let checkout_store = CheckoutsStore::global(cx); + checkout_store.update(cx, |store, cx| { + store.record(PathBuf::from(&path), addr, cx); + }); + } let branches = self.branches.clone(); let base = SharedString::from(base.clone()); @@ -523,20 +496,21 @@ impl NewPullRequestView { self.reload_compare(window, cx); } - /// The base repository of the panel, its address and announced EUC. - /// - /// Used to find fork candidates. - fn base_repo(&self, cx: &App) -> (RepoAddr, Option) { + /// Used to find fork candidates. `None` while the repository is not announced. + fn base_repo(&self, cx: &App) -> Option<(RepoAddr, Option)> { let store = self.store.read(cx); + let addr = store.addr()?.clone(); let euc = store.announcement.as_ref().and_then(|a| a.euc.clone()); - (store.addr().clone(), euc) + Some((addr, euc)) } /// Announced forks of the target repository a compare can use, own first. /// /// Re-read whenever the picker opens. fn fork_candidates(&self, cx: &App) -> Vec { - let (base, euc) = self.base_repo(cx); + let Some((base, euc)) = self.base_repo(cx) else { + return Vec::new(); + }; let user = Backend::global(cx).read(cx).current_user(); let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); fork_candidates(&announcements, &base, euc.as_deref(), user) @@ -545,7 +519,6 @@ impl NewPullRequestView { .collect() } - /// Compare against an announced fork. fn choose_fork( &mut self, announcement: Announcement, @@ -557,7 +530,9 @@ impl NewPullRequestView { .as_ref() .is_some_and(|fork| fork.announcement.addr() == announcement.addr()); - let (base, _euc) = self.base_repo(cx); + let Some((base, _euc)) = self.base_repo(cx) else { + return; + }; let cache = GitStore::global(cx).cache().clone(); let mirror_path = cache.repo_path(&base); let namespace = fork_namespace(&announcement); @@ -598,9 +573,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. @@ -669,7 +641,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, @@ -774,7 +745,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; @@ -868,7 +838,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; @@ -950,7 +919,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; @@ -1108,7 +1076,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)), @@ -1119,14 +1086,17 @@ impl NewPullRequestView { } } - /// Build the compare-source menu. fn source_menu( &self, cx: &Context, ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static { let view = cx.entity().downgrade(); - let addr = self.store.read(cx).addr().clone(); - let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr); + let associated = self + .store + .read(cx) + .addr() + .map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr)) + .unwrap_or_default(); let active_path = (self.fork.is_none()) .then(|| self.repo_path.clone()) @@ -1172,7 +1142,6 @@ impl NewPullRequestView { } } - /// The title and description inputs. fn render_inputs(&self, _cx: &mut Context) -> AnyElement { v_flex() .px_4() @@ -1183,7 +1152,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()); @@ -1255,7 +1223,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() @@ -1341,8 +1308,7 @@ impl NewPullRequestView { } } -/// Open the new pull request panel in the center dock. -pub(super) fn open_new_pull_panel( +pub(crate) fn open_new_pull_panel( dock_area: WeakEntity, store: Entity, window: &mut Window, diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo/about.rs similarity index 91% rename from crates/workspace/src/views/repo_detail/about.rs rename to crates/workspace/src/views/repo/about.rs index d9e7568..3b8b7e4 100644 --- a/crates/workspace/src/views/repo_detail/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 new file mode 100644 index 0000000..e48c855 --- /dev/null +++ b/crates/workspace/src/views/repo/actions.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use dock::{DockArea, add_center_panel, panel_handle}; +use gpui::prelude::*; +use gpui::{App, Entity, WeakEntity, Window}; +use gpui_base::dock::PanelView; +use nostr::prelude::EventId; +use signed_core::{Announcement, RepoAddr}; +use signed_state::RepoStore; + +use super::RepoDetailView; +use crate::views::issues::detail::IssueDetailView; +use crate::views::pull_requests::detail::PullRequestDetailView; + +/// Open repository as a panel in the dock's center. +pub(crate) fn open_repo_panel( + dock_area: &WeakEntity, + addr: &RepoAddr, + hint: Option<&Announcement>, + window: &mut Window, + cx: &mut App, +) -> Entity { + let detail = cx + .new(|cx| RepoDetailView::new(dock_area.clone(), addr.clone(), hint.cloned(), window, cx)); + + if let Some(dock_area) = dock_area.upgrade() { + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(detail.clone()), window, cx); + }); + } + + detail +} + +/// The nostr store of `addr`'s repository, without opening a repository panel. +fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity { + cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx)) +} + +/// An item of a repository to open from outside its detail panel. +pub(crate) enum RepoItem { + Issue(EventId), + PullRequest(EventId), + Patch, +} + +/// The repository store is built here. +pub(crate) fn open_repo_item( + dock_area: &WeakEntity, + addr: &RepoAddr, + hint: Option<&Announcement>, + item: RepoItem, + window: &mut Window, + cx: &mut App, +) { + let panel: Arc = + match item { + RepoItem::Issue(issue_id) => { + let store = repo_store(addr, hint, cx); + panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) + } + RepoItem::PullRequest(pr_id) => { + let store = repo_store(addr, hint, cx); + panel_handle(cx.new(|cx| { + PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx) + })) + } + RepoItem::Patch => return, + }; + + let Some(dock_area) = dock_area.upgrade() else { + return; + }; + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel, window, cx); + }); +} diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs new file mode 100644 index 0000000..06a4967 --- /dev/null +++ b/crates/workspace/src/views/repo/banners.rs @@ -0,0 +1,46 @@ +use std::collections::HashSet; +use std::path::PathBuf; + +use signed_state::CheckoutStatus; + +#[derive(Default)] +pub(super) struct Banners { + dismissed: HashSet<(PathBuf, String)>, + ready_requested: bool, + /// Re-requested only when the announced HEAD or the base default changes. + ready_head: Option, + ready_statuses: Vec, + push_statuses: Vec, +} + +impl Banners { + pub(super) fn dismissal(&self, status: &CheckoutStatus) -> bool { + self.dismissed + .contains(&(status.path.clone(), status.branch.clone())) + } + + pub(super) fn dismiss(&mut self, status: &CheckoutStatus) { + self.dismissed + .insert((status.path.clone(), status.branch.clone())); + } + + pub(super) fn ready_requested_at(&self) -> (bool, &Option) { + (self.ready_requested, &self.ready_head) + } + + pub(super) fn mark_ready_requested(&mut self, head: Option) { + self.ready_requested = true; + self.ready_head = head; + } + + pub(super) fn set_statuses( + &mut self, + ready: Vec, + push: Vec, + ) -> bool { + let changed = ready != self.ready_statuses || push != self.push_statuses; + self.ready_statuses = ready; + self.push_statuses = push; + changed + } +} diff --git a/crates/workspace/src/views/repo/files.rs b/crates/workspace/src/views/repo/files.rs new file mode 100644 index 0000000..ebd3fe9 --- /dev/null +++ b/crates/workspace/src/views/repo/files.rs @@ -0,0 +1,737 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::Error; +use gpui::prelude::*; +use gpui::{AnyElement, Context, Entity, Render, SharedString, Task, WeakEntity, Window, div, px}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Editor, EditorState}; +use gpui_component::list::ListItem; +use gpui_component::spinner::Spinner; +use gpui_component::text::{TextView, TextViewState}; +use gpui_component::tree::{TreeEntry, TreeState, tree}; +use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; +use signed_git::{FileCommit, WorktreeSnapshot}; +use signed_ui::{placeholder, tree_row}; + +use crate::views::tree::{TreeItemSeed, tree_items}; + +const TREE_WIDTH: f32 = 240.; +const MAX_PREVIEW_BYTES: usize = 1024 * 1024; +const MAX_PREVIEWED_FILES: usize = 32; +const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024; + +enum FileContent { + Text(String), + Binary, + TooLarge, + Failed(String), +} + +struct MarkdownView { + /// `None` means the repository README. + path: Option, + state: Entity, + /// Hash of the source, so the same document is not re-parsed on a refresh. + source_hash: u64, +} + +struct CodeView { + /// Source path, relative to the worktree root. + path: SharedString, + state: Entity, + /// Hash of the source, so the same document is not re-parsed on a refresh. + source_hash: u64, +} + +pub(super) struct RepoFilesView { + tree_state: Entity, + worktree: Option, + worktree_paths: Vec, + md: Option, + code: Option, + readme_name: Option, + selected_file: Option, + files: HashMap, + file_order: VecDeque, + preview_bytes: usize, + loading_files: HashSet, + commits: HashMap, + pending_commits: Vec, + loading_commits: bool, + tasks: Vec>>, +} + +impl RepoFilesView { + pub(super) fn new(cx: &mut Context) -> Self { + Self { + tree_state: cx.new(|cx| TreeState::new(cx)), + worktree: None, + worktree_paths: Vec::new(), + md: None, + code: None, + readme_name: None, + selected_file: None, + files: HashMap::new(), + file_order: VecDeque::new(), + preview_bytes: 0, + loading_files: HashSet::new(), + commits: HashMap::new(), + pending_commits: Vec::new(), + loading_commits: false, + tasks: Vec::new(), + } + } + + pub(super) fn set_worktree(&mut self, path: PathBuf) { + self.worktree = Some(path); + } + + pub(super) fn apply_entries( + &mut self, + tree: Vec, + paths: Vec, + cx: &mut Context, + ) { + self.worktree_paths = paths; + self.tree_state.update(cx, |state, cx| { + state.set_items(tree_items(tree, false), cx); + }); + } + + /// Point the README pane at `path`/`bytes`, or clear it when absent. + /// + /// Returns whether the pane changed. + pub(super) fn set_readme( + &mut self, + path: Option, + bytes: Option>, + cx: &mut Context, + ) -> bool { + let Some((path, bytes)) = path.zip(bytes) else { + let changed = self.readme_name.is_some() || self.md.is_some(); + self.readme_name = None; + self.md = None; + return changed; + }; + + let name: SharedString = path.to_string_lossy().into(); + let mut changed = self.readme_name.as_ref() != Some(&name); + self.readme_name = Some(name); + self.load_commit(&path.to_string_lossy(), cx); + + if let Ok(text) = String::from_utf8(bytes) { + changed |= self.set_markdown(None, &text, cx); + } + + changed + } + + /// Drop every cached preview and the README, e.g. on a branch switch. + pub(super) fn clear_previews(&mut self) { + self.selected_file = None; + self.files.clear(); + self.file_order.clear(); + self.preview_bytes = 0; + self.loading_files.clear(); + self.commits.clear(); + self.pending_commits.clear(); + self.loading_commits = false; + self.md = None; + self.code = None; + self.readme_name = None; + } + + /// Refresh after the mirror caught up with the remote. + /// + /// Unlike a branch switch this keeps the selection and previews: it rebuilds + /// the tree, drops previews of files the refresh removed and re-renders the + /// README when it is on screen. + /// + /// Returns whether the tree, a preview or the README changed. + pub(super) fn catch_up( + &mut self, + snapshot: &WorktreeSnapshot, + tree: Vec, + paths: Vec, + cx: &mut Context, + ) -> bool { + let mut changed = false; + + if paths != self.worktree_paths { + self.apply_entries(tree, paths, cx); + changed = true; + } + + let present: HashSet = snapshot + .entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + + let mut previewed: Vec = Vec::new(); + + previewed.extend(self.files.keys().cloned()); + previewed.extend(self.selected_file.clone().map(|path| path.to_string())); + + if let Some(path) = self.md.as_ref().and_then(|md| md.path.clone()) { + previewed.push(path.to_string()); + } + + if let Some(path) = self.code.as_ref().map(|code| code.path.clone()) { + previewed.push(path.to_string()); + } + + previewed.sort(); + previewed.dedup(); + + for path in previewed { + if !present.contains(&path) { + self.drop_preview_of(&path); + changed = true; + } + } + + if self.selected_file.is_none() { + changed |= self.set_readme(snapshot.readme_path.clone(), snapshot.readme.clone(), cx); + } + + changed + } + + fn pane_title(&self) -> SharedString { + self.selected_file + .clone() + .or_else(|| self.readme_name.clone()) + .unwrap_or_else(|| "Overview".into()) + } + + fn render_tree_item( + ix: usize, + entry: &TreeEntry, + selected: bool, + view: &WeakEntity, + ) -> ListItem { + let view = view.clone(); + let id = entry.item().id.clone(); + + tree_row(ix, entry, selected, move |window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| this.open_file(&id, window, cx)); + } + }) + } + + fn render_tree_column( + tree_state: Entity, + view: WeakEntity, + cx: &mut Context, + ) -> impl IntoElement { + v_flex() + .h_full() + .w(px(TREE_WIDTH)) + .p_2() + .flex_none() + .border_r_1() + .border_color(cx.theme().border) + .child(div().flex_1().min_h_0().child(tree( + &tree_state, + move |ix, entry, selected, _window, _cx| { + Self::render_tree_item(ix, entry, selected, &view) + }, + ))) + } + + fn render_content_column( + &self, + pane_title: SharedString, + cx: &mut Context, + ) -> impl IntoElement { + let body: AnyElement = if let Some(path) = self.selected_file.clone() { + match self.files.get(path.as_ref()) { + Some(FileContent::Text(_)) => { + if is_markdown_path(path.as_ref()) { + self.markdown_element(Some(path.as_ref()), cx) + } else { + self.code_element(path.as_ref(), cx) + } + } + Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx), + Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx), + Some(FileContent::Failed(message)) => placeholder(message, cx), + None => preview_spinner(), + } + } else if self.readme_name.is_some() { + self.markdown_element(None, cx) + } else { + placeholder("No README found", cx) + }; + + // Latest commit for the current pane, the selected file or the README. + let commit = match &self.selected_file { + Some(path) => self.commits.get(path.as_ref()), + None => self + .readme_name + .as_ref() + .and_then(|name| self.commits.get(name.as_ref())), + }; + + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child( + h_flex() + .px_3() + .h_9() + .gap_2() + .bg(cx.theme().muted) + .border_b(px(1.)) + .border_color(cx.theme().border) + .text_color(cx.theme().muted_foreground) + .child( + div() + .text_xs() + .font_semibold() + .text_ellipsis() + .whitespace_nowrap() + .child(pane_title), + ) + .when_some(commit, |this, commit| { + this.child( + h_flex() + .flex_1() + .gap_1() + .child( + Button::new("commit") + .xsmall() + .text() + .label(commit.id.clone()), + ) + .child( + div() + .max_w(px(250.)) + .text_xs() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + }), + ) + .child(div().id("repo-content").flex_1().min_h_0().child(body)) + } + + fn set_markdown( + &mut self, + path: Option, + text: &str, + cx: &mut Context, + ) -> bool { + let hash = source_hash(text); + + if let Some(md) = &self.md + && md.path == path + && md.source_hash == hash + { + return false; + } + + let state = cx.new(|cx| TextViewState::markdown("", cx)); + state.update(cx, |state, cx| state.push_str(text, cx)); + + self.md = Some(MarkdownView { + path, + state, + source_hash: hash, + }); + + true + } + + fn markdown_element(&self, path: Option<&str>, _cx: &mut Context) -> AnyElement { + let Some(md) = &self.md else { + return preview_spinner(); + }; + + let ready = match path { + Some(path) => md.path.as_deref() == Some(path), + None => md.path.is_none(), + }; + + if !ready { + return preview_spinner(); + } + + TextView::new(&md.state) + .selectable(true) + .scrollable(true) + .p_4() + .text_sm() + .into_any_element() + } + + fn set_code( + &mut self, + path: SharedString, + text: &str, + window: &mut Window, + cx: &mut Context, + ) { + let hash = source_hash(text); + + if let Some(code) = &self.code + && code.path == path + && code.source_hash == hash + { + return; + } + + let language = code_language(path.as_ref()).unwrap_or("text"); + let state = cx.new(|cx| { + EditorState::new(window, cx) + .language(language) + .default_value(text) + .line_number(true) + .folding(true) + }); + + self.code = Some(CodeView { + path, + state, + source_hash: hash, + }); + } + + fn code_element(&self, path: &str, _cx: &mut Context) -> AnyElement { + let Some(code) = &self.code else { + return preview_spinner(); + }; + if code.path.as_ref() != path { + return preview_spinner(); + } + + Editor::new(&code.state) + .readonly(true) + .bordered(false) + .rounded_none() + .h_full() + .text_sm() + .into_any_element() + } + + fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { + self.selected_file = Some(path.into()); + + if self.files.contains_key(path) { + if let Some(FileContent::Text(text)) = self.files.get(path) { + let text = text.clone(); + if is_markdown_path(path) { + if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) { + self.set_markdown(Some(path.into()), &text, cx); + } + } else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) { + self.set_code(path.into(), &text, window, cx); + } + } + cx.notify(); + return; + } + if self.loading_files.contains(path) { + cx.notify(); + return; + } + + let rel = Path::new(path); + let unsafe_path = rel.is_absolute() + || rel.components().any(|c| { + matches!( + c, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }); + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + if unsafe_path { + return; + } + + self.loading_files.insert(path.to_string()); + let path = path.to_string(); + + self.load_commit(&path, cx); + + let task: Task> = cx.spawn_in(window, async move |this, cx| { + let path_for_read = path.clone(); + let content = cx + .background_spawn(async move { + let full = worktree.join(&path_for_read); + + let metadata = match std::fs::metadata(&full) { + Ok(metadata) => metadata, + Err(error) => return Err(anyhow::anyhow!("{}", error)), + }; + + if metadata.len() > MAX_PREVIEW_BYTES as u64 { + return Ok(FileContent::TooLarge); + } + + let bytes = match std::fs::read(&full) { + Ok(bytes) => bytes, + Err(error) => return Err(anyhow::anyhow!("{}", error)), + }; + + match String::from_utf8(bytes) { + Ok(text) => Ok(FileContent::Text(text)), + Err(_) => Ok(FileContent::Binary), + } + }) + .await; + + this.update_in(cx, |this, window, cx| { + this.loading_files.remove(&path); + + match content { + Ok(kind) => { + if let FileContent::Text(text) = &kind { + if is_markdown_path(&path) { + let same = this.md.as_ref().map(|md| md.path.as_deref()) + == Some(Some(path.as_str())); + if !same { + this.set_markdown(Some(path.clone().into()), text, cx); + } + } else { + let same = this.code.as_ref().map(|code| code.path.as_str()) + == Some(path.as_str()); + if !same { + this.set_code(path.clone().into(), text, window, cx); + } + } + this.preview_bytes += text.len(); + } + this.files.insert(path.clone(), kind); + this.file_order.push_back(path); + this.evict_previews(); + } + Err(error) => { + this.files + .insert(path, FileContent::Failed(error.to_string())); + } + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + fn drop_preview_of(&mut self, path: &str) { + if let Some(FileContent::Text(text)) = self.files.remove(path) { + self.preview_bytes -= text.len(); + } + + self.commits.remove(path); + + if self.selected_file.as_deref() == Some(path) { + self.selected_file = None; + } + + if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) { + self.md = None; + } + + if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) { + self.code = None; + } + } + + fn evict_previews(&mut self) { + while (self.files.len() > MAX_PREVIEWED_FILES + || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) + && self.file_order.len() > 1 + { + let path = self.file_order.pop_front().expect("non-empty"); + + if Some(path.as_str()) == self.selected_file.as_deref() { + self.file_order.push_back(path); + continue; + } + + if let Some(FileContent::Text(text)) = self.files.remove(&path) { + self.preview_bytes -= text.len(); + } + + if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) { + self.md = None; + } + + if self + .code + .as_ref() + .is_some_and(|code| code.path.as_ref() == path.as_str()) + { + self.code = None; + } + + self.commits.remove(&path); + } + } + + fn load_commit(&mut self, path: &str, cx: &mut Context) { + if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { + return; + } + + self.pending_commits.push(path.to_string()); + + if !self.loading_commits { + self.load_commits(cx); + } + } + + fn load_commits(&mut self, cx: &mut Context) { + if self.pending_commits.is_empty() || self.loading_commits { + return; + } + + let Some(worktree) = self.worktree.clone() else { + self.pending_commits.clear(); + return; + }; + + self.loading_commits = true; + + let paths = std::mem::take(&mut self.pending_commits); + + let task: Task> = cx.spawn(async move |this, cx| { + let rels: Vec = paths.iter().map(PathBuf::from).collect(); + let result = cx + .background_spawn( + async move { signed_git::worktree_last_commits(&worktree, &rels) }, + ) + .await; + + this.update(cx, |this, cx| { + this.loading_commits = false; + + if let Ok(found) = result { + for (path, commit) in found { + this.commits + .insert(path.to_string_lossy().into_owned(), commit); + } + } + + if !this.pending_commits.is_empty() { + this.load_commits(cx); + } + + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } +} + +impl Render for RepoFilesView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tree_state = self.tree_state.clone(); + let view = cx.entity().downgrade(); + let pane_title = self.pane_title(); + + h_flex() + .flex_1() + .w_full() + .overflow_hidden() + .child(Self::render_tree_column(tree_state, view, cx)) + .child(self.render_content_column(pane_title, cx)) + } +} + +fn source_hash(text: &str) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + +fn preview_spinner() -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() +} + +/// The markdown fence language for a file path, or `None` for plain text. +fn code_language(path: &str) -> Option<&'static str> { + let name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + + // Some common files are recognized by name rather than extension. + match name { + "Makefile" | "makefile" => return Some("make"), + "CMakeLists.txt" => return Some("cmake"), + _ => {} + } + + let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "rs" => "rust", + "toml" => "toml", + "json" | "jsonc" => "json", + "py" => "python", + "js" | "mjs" | "cjs" => "javascript", + "ts" | "mts" | "cts" => "typescript", + "tsx" | "jsx" => "tsx", + "go" => "go", + "c" | "h" => "c", + "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp", + "cs" => "csharp", + "java" => "java", + "kt" | "kts" | "ktm" => "kotlin", + "swift" => "swift", + "php" | "phtml" => "php", + "rb" => "ruby", + "sh" | "bash" | "zsh" => "bash", + "yml" | "yaml" => "yaml", + "css" | "scss" | "sass" => "css", + "html" | "htm" => "html", + "lua" => "lua", + "sql" => "sql", + "proto" | "protobuf" => "proto", + "cmake" => "cmake", + "zig" => "zig", + "ex" | "exs" => "elixir", + "graphql" | "gql" => "graphql", + "diff" | "patch" => "diff", + "svelte" => "svelte", + "astro" => "astro", + "scala" => "scala", + _ => return None, + }) +} + +/// Whether a file path has a markdown extension. +fn is_markdown_path(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + matches!( + ext.to_ascii_lowercase().as_str(), + "md" | "markdown" | "mdown" | "mkdn" + ) + }) +} diff --git a/crates/workspace/src/views/repo/history.rs b/crates/workspace/src/views/repo/history.rs new file mode 100644 index 0000000..4ec8db5 --- /dev/null +++ b/crates/workspace/src/views/repo/history.rs @@ -0,0 +1,201 @@ +use std::path::PathBuf; +use std::rc::Rc; + +use anyhow::Error; +use dock::{DockArea, add_center_panel, panel_handle}; +use gpui::prelude::*; +use gpui::{Context, Entity, Pixels, Render, Size, Task, WeakEntity, Window, div, px, size}; +use gpui_component::scroll::Scrollbar; +use gpui_component::spinner::Spinner; +use gpui_component::{ActiveTheme, Sizable, VirtualListScrollHandle, v_flex, v_virtual_list}; +use signed_git::CommitList; +use signed_state::RepoStore; +use signed_ui::placeholder; + +use super::repo_display_name; +use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, commit_row}; + +pub(super) struct RepoHistoryView { + store: Entity, + dock_area: WeakEntity, + worktree: Option, + all_commits: Option, + loading_all_commits: bool, + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, + tasks: Vec>>, +} + +impl RepoHistoryView { + pub(super) fn new(store: Entity, dock_area: WeakEntity) -> Self { + Self { + store, + dock_area, + worktree: None, + all_commits: None, + loading_all_commits: false, + scroll_handle: VirtualListScrollHandle::new(), + item_sizes: Rc::new(Vec::new()), + tasks: Vec::new(), + } + } + + pub(super) fn set_worktree(&mut self, path: Option) { + self.worktree = path; + } + + /// Number of commits reachable from HEAD, for the Commits tab badge. + pub(super) fn commit_count(&self) -> Option { + self.all_commits.as_ref().map(|list| list.total) + } + + /// Drop the current list and walk HEAD again. + pub(super) fn reload(&mut self, cx: &mut Context) { + self.all_commits = None; + self.loading_all_commits = false; + self.load(cx); + } + + fn load(&mut self, cx: &mut Context) { + if self.loading_all_commits || self.all_commits.is_some() { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + self.loading_all_commits = true; + + let task: Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) + .await; + + this.update(cx, |this, cx| { + if let Ok(list) = result { + let count = list.commits.len(); + this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); + this.all_commits = Some(list); + } + + this.loading_all_commits = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + pub(super) fn open_commit_diff( + &mut self, + commit_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + let Some(worktree) = self.worktree.clone() else { + return; + }; + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + // Same display name as the repo detail panel's title. + let repo_name = repo_display_name(self.store.read(cx)); + + let panel = + cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); + } +} + +impl Render for RepoHistoryView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(list) = self.all_commits.as_ref() else { + return if self.loading_all_commits { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() + } else { + placeholder("Failed to load commits", cx) + }; + }; + + if list.commits.is_empty() { + return placeholder("No commits found", cx); + } + + let view = cx.entity().clone(); + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); + let shown = list.commits.len(); + let total = list.total; + + v_flex() + .relative() + .flex_1() + .w_full() + .min_h_0() + .child( + v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| { + let view = cx.entity().downgrade(); + let commits = this + .all_commits + .as_ref() + .map(|list| list.commits.as_slice()) + .unwrap_or(&[]); + + range + .map(|ix| { + let id = commits[ix].id.clone(); + let view = view.clone(); + + commit_row( + ix, + &commits[ix], + move |window, cx| { + if let Some(view) = view.upgrade() { + view.update(cx, |this, cx| { + this.open_commit_diff(&id, window, cx) + }); + } + }, + cx, + ) + }) + .collect() + }) + .track_scroll(&scroll_handle) + .size_full(), + ) + .when(shown < total, |this| { + this.child( + div() + .py_2() + .w_full() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("Showing {shown} of {total} commits")), + ) + }) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&self.scroll_handle)), + ) + .into_any_element() + } +} diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo/init_dialog.rs similarity index 96% rename from crates/workspace/src/views/repo_detail/init_dialog.rs rename to crates/workspace/src/views/repo/init_dialog.rs index 02083bf..afcb4e3 100644 --- a/crates/workspace/src/views/repo_detail/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_detail/mod.rs b/crates/workspace/src/views/repo/mod.rs similarity index 56% rename from crates/workspace/src/views/repo_detail/mod.rs rename to crates/workspace/src/views/repo/mod.rs index 1ff0b63..eacdae1 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -1,8 +1,6 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::{Component, Path, PathBuf}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use std::rc::Rc; -use std::sync::Arc; -use std::time::Duration; use anyhow::Error; use assets::CustomIconName; @@ -11,97 +9,96 @@ use gix::Repository; use gpui::prelude::*; use gpui::{ Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, - Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, WeakEntity, - Window, div, px, relative, size, transparent_white, + Focusable, PathPromptOptions, Render, SharedString, Subscription, Task, WeakEntity, Window, + div, px, relative, transparent_white, }; -use gpui_base::dock::PanelView; use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_component::alert::Alert; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::{Combobox, ComboboxEvent, ComboboxState}; -use gpui_component::menu::DropdownMenu; -use gpui_component::searchable_list::SearchableVec; -use gpui_component::tree::TreeState; +use gpui_component::combobox::{Combobox, ComboboxEvent}; +use gpui_component::menu::{DropdownMenu, PopupMenu}; +use gpui_component::spinner::Spinner; use gpui_component::{ - ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, - VirtualListScrollHandle, h_flex, v_flex, + ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, }; -use nostr::prelude::{EventId, RelayUrl, ToBech32, Url}; -use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; -use signed_git::{CommitList, FileCommit}; +use nostr::nips::nip19::Nip19Coordinate; +use nostr::prelude::{RelayUrl, ToBech32, Url}; +use signed_core::{Announcement, RepoAddr, RepoStatus}; +use signed_git::FileCommit; use signed_state::{ Backend, CheckoutStatus, CheckoutsStore, GitStore, LocalReposStore, ProfileStore, RepoListStore, RepoStore, pr_proposes_checkout, }; -use signed_ui::{CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row}; +use signed_ui::{ + CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate, + ref_selector_trigger, +}; mod about; -mod browser; -mod commits; -mod diff; -mod helpers; +mod actions; +mod banners; +mod files; +mod history; mod init_dialog; -mod issue_detail; -mod issues; -mod new_pull_request; -mod pull_request_detail; -mod pull_requests; -mod send_patch; +mod refs; -use about::open_about_dialog; -use browser::{ - CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, - MarkdownView, -}; -use commits::COMMIT_ROW_HEIGHT; -use diff::CommitDiffView; -use helpers::{ - ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, ref_selector_trigger, - tree_items, -}; -use issue_detail::IssueDetailView; -use issues::{IssuesView, open_new_issue_dialog}; -use pull_request_detail::PullRequestDetailView; -use pull_requests::PullRequestsView; -use send_patch::open_send_patch_panel; +pub(crate) use actions::{RepoItem, open_repo_item, open_repo_panel}; -use crate::views::repo_detail::new_pull_request::open_new_pull_panel; +use self::banners::Banners; +use self::files::RepoFilesView; +use self::history::RepoHistoryView; +use self::refs::RefSwitcher; +use crate::views::issues::{IssuesView, open_new_issue_dialog}; +use crate::views::pull_requests::PullRequestsView; +use crate::views::pull_requests::new::open_new_pull_panel; +use crate::views::repo::about::open_about_dialog; +use crate::views::send_patch::open_send_patch_panel; +use crate::views::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; -/// 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(super)` because the pull-request list panel shares this action set. -/// It offers the New-PR and Send-patch actions in its own dropdown. #[derive(Clone, Action, PartialEq, Eq)] -#[action(namespace = repo_detail, no_json)] -pub(super) enum RepoAction { - /// Open the new issue dialog. +#[action(namespace = repo, no_json)] +pub(crate) enum RepoAction { NewIssue, - /// Open the new pull request dialog. NewPR, - /// Open the send patch panel. SendPatch, - /// Open the about dialog. About, - /// Re-push the repository to its grasp servers. Push, - /// Delete the repository from nostr, owner only. Delete, } -/// Everything loaded from the local clone for the explorer. -/// The tree seeds, README, refs and HEAD commit. -/// Computed on a background thread, see [`load_repo_data`]. -/// Applied on the main thread. +pub struct RepoDetailView { + focus_handle: FocusHandle, + dock_area: WeakEntity, + store: Entity, + /// A repository opened by address alone starts without an announcement. + repo_started: bool, + /// The Files tab, which owns the explorer, previews and the per-file commit map. + files: Entity, + /// The checked-out worktree path, shared by the Files tab and the commit list. + worktree: Option, + /// The active tab, either 0 (Files tree) or 1 (Commits). + active_tab: usize, + history: Entity, + loading: bool, + error: Option, + head_commit: Option, + refs: RefSwitcher, + banners: Banners, + tasks: Vec>>, + _subscriptions: Vec, +} + struct RepoData { tree: Vec, + entries: Vec, readme_path: Option, readme: Option>, worktree: Option, @@ -111,194 +108,65 @@ struct RepoData { head_commit: Option, } -/// Detail view of a repository, header, stats and metadata. -/// A file explorer with README preview, cloned from the announcement's `clone` URLs. -pub struct RepoDetailView { - focus_handle: FocusHandle, - /// Dock area the detail view lives in. - /// New panels, commit diffs, are added there. - dock_area: WeakEntity, - /// Snapshot taken at open time. - /// Shown until the store's first refresh completes. - /// Also a fallback while the store has no announcement. - /// `None` for local repositories that haven't been published yet. - initial: Option, - /// Per-repository nostr store, holding announcement, issues, PRs and statuses. - /// `None` until a local repository is initialized to NIP-34. - store: Option>, - /// Path of the local repository when opened from the scan. - /// `None` once it is initialized to NIP-34, or for announced repositories. - local_path: Option, - /// File explorer state, the worktree of the local clone. - tree_state: Entity, - /// Root of the local clone, for reading files on demand. - worktree: Option, - /// Markdown document currently in the preview pane, README or a file. - md: Option, - /// Code file currently in the preview pane. - code: Option, - readme_name: Option, - /// Currently previewed file, a relative path, and its contents. - selected_file: Option, - files: HashMap, - /// Paths of cached previews, oldest first. - /// Feeds the eviction caps in [`Self::evict_previews`]. - file_order: VecDeque, - /// Total text bytes held by [`Self::files`]. - preview_bytes: usize, - /// Reads in flight, to avoid duplicate loads. - loading_files: HashSet, - /// Latest commit touching a previewed file or the README, keyed by path. - commits: HashMap, - /// Paths queued for the next batched commit query, see [`Self::load_commits`]. - pending_commits: Vec, - /// A batched commit query is in flight. - loading_commits: bool, - /// Active header tab, 0 = Files tree, 1 = Commits. - active_tab: usize, - /// Commits reachable from HEAD, newest first. - /// `None` until the walk finishes or fails. - /// [`CommitList`] caps the list, `total` feeds the tab badge. - all_commits: Option, - /// Commit walk in flight. - loading_all_commits: bool, - /// Virtual list state of the Commits tab. - scroll_handle: VirtualListScrollHandle, - item_sizes: Rc>>, - /// A clone/fetch is in flight. - loading: bool, - error: Option, - /// Commit HEAD currently points to, shown in the header button. - head_commit: Option, - /// Branch selector in the header, local branches, searchable. - branch_select: Entity>>, - /// Tag selector in the header, tags, searchable. - tag_select: Entity>>, - /// A branch/tag switch is in flight, checkout plus explorer reload. - switching_ref: bool, - /// Bumped on every branch/tag switch. - /// In-flight loads with an older generation are discarded when they complete. - ref_generation: u64, - /// Subscriptions keeping the selectors' confirm events alive. - _subscriptions: Vec, - /// `(path, branch)` ready-suggestions dismissed by the user, per panel. - banner_dismissed: HashSet<(PathBuf, String)>, - /// The announced HEAD the ready statuses were last requested with. - /// Whether they were requested at all. - /// Re-requested only when the HEAD, the base default, changes. - /// e.g. when the store's first refresh lands. - ready_requested: bool, - ready_head: Option, - /// The global checkouts store's ready-to-contribute statuses of this - /// repository, last seen when they drove a render. - /// - /// The store notifies on any recompute pass; the observer re-renders this - /// panel only when these slices changed. - ready_statuses: Vec, - /// The global checkouts store's ready-to-push statuses of this repository, - /// last seen when they drove a render. - push_statuses: Vec, - /// Upstream repository, from this fork's `u` tag, the user asked to open. - /// Its announcement is still being fetched. - pending_upstream: Option, -} - impl RepoDetailView { - /// Open a repository announced. - /// - /// The store connects to the announcement's relays and loads issues, PRs and statuses. pub fn new( dock_area: WeakEntity, - initial: Announcement, + addr: RepoAddr, + hint: Option, window: &mut Window, cx: &mut Context, ) -> Self { - // The announcement we opened from already carries the NIP-34 `relays` tag. - // The store connects to those relays immediately, no bootstrap fetch wait. - let addr = initial.addr(); - let relays = initial.relays.clone(); - let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); - - let mut view = Self::new_common( - dock_area, - Some(initial), - Some(store.clone()), - None, - window, - cx, - ); - view.attach_store(&store, cx); - view + let store = cx.new(|cx| RepoStore::new(addr, hint, cx)); + Self::new_common(dock_area, store, window, cx) } - /// Open a local repository discovered by the scan. pub fn new_local( dock_area: WeakEntity, local_path: PathBuf, window: &mut Window, cx: &mut Context, ) -> Self { - Self::new_common(dock_area, None, None, Some(local_path), window, cx) + let store = cx.new(move |_cx| RepoStore::new_local(local_path)); + 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, - initial: Option, - store: Option>, - local_path: Option, + store: Entity, window: &mut Window, cx: &mut Context, ) -> Self { - let tree_state = cx.new(|cx| TreeState::new(cx)); - - // Empty until the clone completes, then filled with the local refs. - let branch_select: Entity>> = cx.new(|cx| { - ComboboxState::new( - SearchableVec::new(Vec::::new()), - Vec::new(), - window, - cx, - ) - .searchable(true) - }); - let tag_select: Entity>> = cx.new(|cx| { - ComboboxState::new( - SearchableVec::new(Vec::::new()), - Vec::new(), - window, - cx, - ) - .searchable(true) - }); + let checkouts = CheckoutsStore::global(cx); + let files = cx.new(RepoFilesView::new); + let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone())); + let refs = RefSwitcher::new(window, cx); let mut subscriptions = vec![ - cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| { - // `Change` fires only when the selection actually changed. - // Picking the already-selected branch emits nothing. - // A confirmed value always means a switch. - if let ComboboxEvent::Change(values) = event - && let Some(name) = values.first() - { - this.switch_ref(RefKind::Branch, name.clone(), window, cx); - } - }), - cx.subscribe_in(&tag_select, window, |this, _state, event, window, cx| { - if let ComboboxEvent::Change(values) = event - && let Some(name) = values.first() - { - this.switch_ref(RefKind::Tag, name.clone(), window, cx); - } - }), + cx.subscribe_in( + &refs.branch_select, + window, + |this, _state, event, window, cx| { + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Branch, name.clone(), window, cx); + } + }, + ), + cx.subscribe_in( + &refs.tag_select, + window, + |this, _state, event, window, cx| { + if let ComboboxEvent::Change(values) = event + && let Some(name) = values.first() + { + this.switch_ref(RefKind::Tag, name.clone(), window, cx); + } + }, + ), ]; - // The ready-to-contribute and ready-to-push banners are driven by the - // global checkouts store. It notifies on every recompute; compare the - // statuses of this repository so unrelated updates (the sidebar badges, - // other open panels) do not re-render this panel. - let checkouts = CheckoutsStore::global(cx); + // The ready-to-contribute and ready-to-push banners are driven by the global checkouts store. subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { if this.refresh_statuses(cx) { cx.notify(); @@ -310,59 +178,140 @@ impl RepoDetailView { this.load_repo(window, cx); }); - Self { - initial, + let mut view = Self { dock_area, - store, - local_path, - tree_state, + store: store.clone(), + repo_started: false, + files, worktree: None, - md: None, - code: None, - readme_name: None, - selected_file: None, - files: HashMap::new(), - file_order: VecDeque::new(), - preview_bytes: 0, - loading_files: HashSet::new(), - commits: HashMap::new(), - pending_commits: Vec::new(), - loading_commits: false, active_tab: 0, - all_commits: None, - loading_all_commits: false, - scroll_handle: VirtualListScrollHandle::new(), - item_sizes: Rc::new(Vec::new()), + history, loading: true, error: None, head_commit: None, - branch_select, - tag_select, - switching_ref: false, - ref_generation: 0, - banner_dismissed: HashSet::new(), - ready_requested: false, - ready_head: None, - ready_statuses: Vec::new(), - push_statuses: Vec::new(), - pending_upstream: None, + refs, + tasks: Vec::new(), + banners: Banners::default(), focus_handle: cx.focus_handle(), _subscriptions: subscriptions, + }; + + view.attach_store(&store, window, cx); + view + } + + pub(crate) fn apply_announcement( + &mut self, + announcement: Announcement, + cx: &mut Context, + ) { + let path = self.store.read(cx).path.clone(); + + if let Some(path) = path { + LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); } + + self.store + .update(cx, |store, cx| store.announce(announcement, cx)); + + // The new address needs its ready-to-contribute statuses requested. + self.refresh_ready_statuses(cx); + cx.notify(); + } + + pub(super) fn attach_store( + &mut self, + store: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + self._subscriptions + .push(cx.observe_in(store, window, |this, store, window, cx| { + this.refresh_ready_statuses(cx); + if !this.repo_started && store.read(cx).announcement.is_some() { + this.load_repo(window, cx); + } + cx.notify(); + })); + self.refresh_ready_statuses(cx); + } + + fn refresh_ready_statuses(&mut self, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let head = self.store.read(cx).head.clone(); + let (requested, requested_head) = self.banners.ready_requested_at(); + + if requested && requested_head == &head { + return; + } + + self.banners.mark_ready_requested(head.clone()); + + let backend = Backend::global(cx); + let checkout = CheckoutsStore::global(cx); + + let owned = backend + .read(cx) + .current_user() + .is_some_and(|user| self.store.read(cx).is_author(&user)); + + checkout.update(cx, |store, cx| { + // The ready statuses keep the fast poll running while the panel is open. + // The sidebar's push watch alone polls slower. + store.request_statuses(&addr, head, cx); + + if owned { + store.request_push_statuses(&addr, cx); + } + }); + } + + pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return false; + }; + + let checkouts = CheckoutsStore::global(cx).read(cx); + let ready_statuses = checkouts.ready_statuses_of(&addr); + let push_statuses = checkouts.push_statuses_of(&addr); + + self.banners.set_statuses(ready_statuses, push_statuses) + } + + /// The latest announcement of the repository, + /// + /// `None` while local-only or until the store's first pass loads it. + fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { + self.store.read(cx).announcement.as_ref() } /// Load the repository and populate the file explorer. - /// - /// A local, not yet published, repository opens straight from disk. - /// An announced repository's clone, if any, loads first without touching the network. - fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; cx.notify(); + let (addr, announcement, local_path) = { + let store = self.store.read(cx); + ( + store.addr().cloned(), + store.announcement.clone(), + store.path.clone(), + ) + }; + // Local repositories live on disk at their scan path. // No clone step or network refresh applies here. - if let Some(local_path) = self.local_path.clone() { + if addr.is_none() { + self.repo_started = true; + + let Some(local_path) = local_path else { + return; + }; + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let data = cx .background_spawn(async move { @@ -383,22 +332,20 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); return; } - let Some(initial) = self.initial.as_ref() else { + let Some(announcement) = announcement else { return; }; - let cache = GitStore::global(cx).cache().clone(); - let addr = initial.addr(); - let clone_urls: Vec = initial.clone.clone(); + self.repo_started = true; - // Captured before the loads start. - // A branch/tag switch bumps the generation, discarding the refresh below. - let refresh_generation = self.ref_generation; + let cache = GitStore::global(cx).cache().clone(); + let addr = announcement.addr(); + let clone_urls: Vec = announcement.clone.clone(); let disk = { let cache = cache.clone(); @@ -411,11 +358,10 @@ impl RepoDetailView { }) }; - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let task: Task> = cx.spawn_in(window, async move |this, cx| { let disk = disk.await; let had_clone = matches!(&disk, Ok(Some(_))); - // No local clone yet, so clone from the network then load. let data = match disk { Ok(Some(data)) => Ok(data), Ok(None) => { @@ -442,7 +388,6 @@ impl RepoDetailView { // Refresh the clone from the network in the background. // When it completes, update the refs and commit list. - // Loads started before a branch/tag switch are discarded via the generation. if !had_clone { return Ok(()); } @@ -450,21 +395,17 @@ impl RepoDetailView { let refresh = { let cache = cache.clone(); let addr = addr.clone(); + cx.background_spawn(async move { let Some(repo) = cache.open(&addr)? else { return Ok::<_, Error>(None); }; // Best-effort, a fetch failure, e.g. offline, keeps the cached state. - // The state is already shown. signed_git::fetch_all(&repo).ok(); let worktree = repo.workdir().map(Path::to_path_buf); - // A fetch never moves a mirror's local branches. - // A push landing on the grasp servers would never show up. - // That covers own repo pushes from a checkout and updates fetched here. - // Fast-forward branches from the remote, like `git pull --ff-only`. - // Only the checked-out branch's worktree can change on disk. + let moved = match &worktree { Some(worktree) => { signed_git::fast_forward_branches(worktree).unwrap_or(false) @@ -489,55 +430,49 @@ impl RepoDetailView { .await; this.update_in(cx, |this, window, cx| { - if refresh_generation != this.ref_generation { - return; - } - if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh { let branches: Vec = branches.iter().map(Into::into).collect(); let tags: Vec = tags.iter().map(Into::into).collect(); - this.branch_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(branches), window, cx); - if let Some(branch) = current_branch { - let branch: SharedString = branch.into(); - state.set_selected_values(&[branch], window, cx); - } - }); - - this.tag_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(tags), window, cx); - }); + let branches_changed = this.refs.set_branches( + branches, + current_branch.map(Into::into), + window, + cx, + ); + let tags_changed = this.refs.set_tags(tags, window, cx); let new_head_commit = head_commit.as_ref().map(|c| &c.id); let current_head_commit = this.head_commit.as_ref().map(|c| &c.id); + let head_changed = new_head_commit != current_head_commit; this.head_commit = head_commit; - if head_changed || this.all_commits.is_none() { - this.all_commits = None; - this.loading_all_commits = false; - this.load_all_commits(cx); + if head_changed { + this.history.update(cx, |history, cx| history.reload(cx)); } if moved { this.catch_up_worktree(cx); } - cx.notify(); + if branches_changed || tags_changed || head_changed { + cx.notify(); + } } })?; Ok(()) }); - task.detach(); + self.tasks.push(task); } - /// Apply the loaded repository data. fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context) { + log::debug!("repo detail: apply_repo_data"); let RepoData { tree, + entries, readme_path, readme, worktree, @@ -546,50 +481,37 @@ impl RepoDetailView { current_branch, head_commit, } = data; + let Some(worktree) = worktree else { self.error = Some("Repository has no worktree".into()); return; }; - self.worktree = Some(worktree); + self.worktree = Some(worktree.clone()); self.head_commit = head_commit; - self.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); + + self.files.update(cx, |files, cx| { + files.set_worktree(worktree.clone()); + files.apply_entries(tree, sorted_worktree_paths(&entries), cx); + files.set_readme(readme_path, readme, cx); + }); + + self.history.update(cx, |history, cx| { + history.set_worktree(Some(worktree)); + history.reload(cx); }); - // Populate the branch/tag selectors with the local refs. - // Select the branch HEAD points to. let branches: Vec = branches.into_iter().map(Into::into).collect(); let tags: Vec = tags.into_iter().map(Into::into).collect(); - self.branch_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(branches), window, cx); - if let Some(branch) = current_branch { - let branch: SharedString = branch.into(); - state.set_selected_values(&[branch], window, cx); - } - }); + self.refs + .set_branches(branches, current_branch.map(Into::into), window, cx); - self.tag_select.update(cx, |state, cx| { - state.set_items(SearchableVec::from(tags), window, cx); - }); - - self.load_all_commits(cx); - - if let Some((path, bytes)) = readme_path.zip(readme) { - self.readme_name = Some(path.to_string_lossy().into()); - self.load_commit(&path.to_string_lossy(), cx); - if let Ok(text) = String::from_utf8(bytes) { - self.set_markdown(None, &text, cx); - } - } + self.refs.set_tags(tags, window, cx); } - /// Clone the repository into a user-chosen folder outside the cache. - fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; + pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { + let store = self.store.clone(); let name = { let Some(announcement) = self.announcement(cx) else { @@ -597,7 +519,6 @@ impl RepoDetailView { }; let addr = announcement.addr(); // Directory name, the display name falling back to the repo id. - // Both are sanitized to a safe single path component. let name = announcement .name .as_ref() @@ -605,6 +526,7 @@ impl RepoDetailView { .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| addr.identifier.clone()); let name = signed_git::sanitize_path_component(&name); + if name.is_empty() { "repository".to_owned() } else { @@ -649,199 +571,14 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); } - /// Preview the file at `path`, relative to the worktree root. - fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { - self.selected_file = Some(path.into()); - - if self.files.contains_key(path) { - // The file is cached, but the markdown or code state may hold a different file. - // Re-point it at this one, the parse runs on a background task either way. - // Without this, the pane would show a spinner forever. - if let Some(FileContent::Text(text)) = self.files.get(path) { - let text = text.clone(); - if is_markdown_path(path) { - if self.md.as_ref().map(|md| md.path.as_deref()) != Some(Some(path)) { - self.set_markdown(Some(path.into()), &text, cx); - } - } else if self.code.as_ref().map(|code| code.path.as_str()) != Some(path) { - self.set_code(path.into(), &text, window, cx); - } - } - cx.notify(); - return; - } - if self.loading_files.contains(path) { - cx.notify(); - return; - } - - // Paths come from our own tree walk, but never trust them. - // Refuse anything that could escape the worktree. - let rel = Path::new(path); - let unsafe_path = rel.is_absolute() - || rel.components().any(|c| { - matches!( - c, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }); - - let Some(worktree) = self.worktree.clone() else { - return; - }; - - if unsafe_path { - return; - } - - self.loading_files.insert(path.to_string()); - let path = path.to_string(); - - self.load_commit(&path, cx); - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - let path_for_read = path.clone(); - let content = cx - .background_spawn(async move { - let full = worktree.join(&path_for_read); - // Refuse oversized files before reading them. - // Reading a multi-gigabyte file just to classify it is wasteful. - // It would burn disk and memory bandwidth. - let metadata = match std::fs::metadata(&full) { - Ok(metadata) => metadata, - Err(error) => return Err(anyhow::anyhow!("{}", error)), - }; - if metadata.len() > MAX_PREVIEW_BYTES as u64 { - return Ok(FileContent::TooLarge); - } - let bytes = match std::fs::read(&full) { - Ok(bytes) => bytes, - Err(error) => return Err(anyhow::anyhow!("{}", error)), - }; - match String::from_utf8(bytes) { - Ok(text) => Ok(FileContent::Text(text)), - Err(_) => Ok(FileContent::Binary), - } - }) - .await; - - this.update_in(cx, |this, window, cx| { - // The worktree was switched while this file was reading. - // The result belongs to the previous branch. - // Clear the in-flight marker either way. - // Otherwise the path could never be loaded again. - if generation != this.ref_generation { - this.loading_files.remove(&path); - return; - } - this.loading_files.remove(&path); - match content { - Ok(kind) => { - if let FileContent::Text(text) = &kind { - if is_markdown_path(&path) { - let same = this.md.as_ref().map(|md| md.path.as_deref()) - == Some(Some(path.as_str())); - if !same { - this.set_markdown(Some(path.clone().into()), text, cx); - } - } else { - let same = this.code.as_ref().map(|code| code.path.as_str()) - == Some(path.as_str()); - if !same { - this.set_code(path.clone().into(), text, window, cx); - } - } - this.preview_bytes += text.len(); - } - this.files.insert(path.clone(), kind); - this.file_order.push_back(path); - this.evict_previews(); - } - Err(error) => { - this.files - .insert(path, FileContent::Failed(error.to_string())); - } - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Queue `path` for the per-file commit query. - /// Requests are batched into one history walk, see [`Self::load_commits`]. - fn load_commit(&mut self, path: &str, cx: &mut Context) { - if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) { - return; - } - self.pending_commits.push(path.to_string()); - if !self.loading_commits { - self.load_commits(cx); - } - } - - /// Walk history once for every queued path on a background task. - /// Cache the latest commit touching each path in [`Self::commits`]. - /// That feeds the file header in the content column. - /// Batching shares one walk across paths queued while the previous walk ran. - fn load_commits(&mut self, cx: &mut Context) { - if self.pending_commits.is_empty() || self.loading_commits { - return; - } - let Some(worktree) = self.worktree.clone() else { - self.pending_commits.clear(); - return; - }; - - self.loading_commits = true; - let paths = std::mem::take(&mut self.pending_commits); - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let rels: Vec = paths.iter().map(PathBuf::from).collect(); - let result = cx - .background_spawn( - async move { signed_git::worktree_last_commits(&worktree, &rels) }, - ) - .await; - - this.update(cx, |this, cx| { - this.loading_commits = false; - if generation == this.ref_generation - && let Ok(found) = result - { - for (path, commit) in found { - this.commits - .insert(path.to_string_lossy().into_owned(), commit); - } - } - // Paths queued while the walk was in flight start the next batch. - // A stale walk, branch switched mid-flight, must not strand them. - // This runs under the current generation regardless of the result. - if !this.pending_commits.is_empty() { - this.load_commits(cx); - } - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Walk all commits reachable from HEAD on a background task. - /// For the Commits tab and its total-count badge. - /// [`CommitList`] caps the list, only the newest commits are materialized. - fn load_all_commits(&mut self, cx: &mut Context) { - if self.loading_all_commits || self.all_commits.is_some() { + fn switch_ref(&mut self, kind: RefKind, name: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { + if self.refs.switching_ref { return; } @@ -849,257 +586,24 @@ impl RepoDetailView { return; }; - self.loading_all_commits = true; - let generation = self.ref_generation; - - let task: gpui::Task> = cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) - .await; - - this.update(cx, |this, cx| { - // A stale walk, branch switched mid-flight, must not leave the flag set. - // Otherwise the Commits tab would spin forever. - if generation != this.ref_generation { - this.loading_all_commits = false; - return; - } - if let Ok(list) = result { - let count = list.commits.len(); - this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); - this.all_commits = Some(list); - } - this.loading_all_commits = false; - cx.notify(); - })?; - - Ok(()) - }); - - task.detach(); - } - - /// Open a new panel showing the diff of `commit_id`. - fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { - let Some(worktree) = self.worktree.clone() else { - return; - }; - - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - // Same display name as the repo detail panel's title. - let repo_name = self.display_name(cx); - - let panel = - cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit_id.into(), window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - /// Re-push the repository's refs to its announced grasp servers. - fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - - self.error = None; - cx.notify(); - - store - .update(cx, |store, cx| store.push_repository(cx)) - .detach(); - } - - /// Push the unpushed commits of the local checkout at `path`. - fn push_unpushed_checkout( - &mut self, - path: PathBuf, - window: &mut Window, - cx: &mut Context, - ) { - let Some(store) = self.store.clone() else { - return; - }; - - if store.read(cx).pushing { - return; - } - - self.error = None; - cx.notify(); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - // The store owns the push, its busy flag and error reporting. - let push = this.update_in(cx, |_this, _window, cx| { - store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) - })?; - - // The remote moved, refresh the mirror browsing. - // Failures already surfaced in the store's error banner. - if let Ok(()) = push.await { - this.update_in(cx, |this, window, cx| { - this.load_repo(window, cx); - })?; - } - - Ok(()) - }); - - task.detach(); - } - - /// Delete the repository from nostr, announcement, state and activity. - fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - store - .update(cx, |store, cx| store.delete_repository(cx)) - .detach(); - } - - /// Open the issues list panel in the dock area. - fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - /// Open the pull requests list panel in the dock area. - fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - let Some(dock_area) = self.dock_area.upgrade() else { - return; - }; - - let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); - - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); - } - - /// Open the upstream repository, the `u` tag of this fork's announcement. - /// The upstream announcement may not be in the local database yet. - /// Subscribe for it and open the panel as soon as it lands. - fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { - if self.pending_upstream.is_some() { - return; - } - - let Some(announcement) = self.announcement(cx).cloned() else { - return; - }; - - let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else { - return; - }; - - if let Some(found) = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == addr) - .cloned() - { - open_repo_panel(&self.dock_area, &found, window, &mut *cx); - return; - } - - let backend = Backend::global(cx); - backend.update(cx, |backend, cx| { - backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx); - }); - self.pending_upstream = Some(addr); - - let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { - for _ in 0..60 { - cx.background_executor() - .timer(Duration::from_millis(250)) - .await; - - let opened = this.update_in(cx, |this, window, cx| { - let Some(addr) = this.pending_upstream.clone() else { - return true; - }; - let found = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == addr) - .cloned(); - match found { - Some(found) => { - this.pending_upstream = None; - open_repo_panel(&this.dock_area, &found, window, &mut *cx); - true - } - None => false, - } - })?; - - if opened { - return Ok(()); - } - } - - this.update(cx, |this, _cx| this.pending_upstream = None)?; - Ok(()) - }); - - task.detach(); - } - - /// Check out `name`, a branch or tag picked in the header. - /// Refresh the explorer once the switch completes. - fn switch_ref( - &mut self, - kind: RefKind, - name: SharedString, - window: &mut Window, - cx: &mut Context, - ) { - if self.switching_ref { - return; - } - let Some(worktree) = self.worktree.clone() else { - return; - }; - - // Branches and tags are mutually exclusive states of HEAD. - // Selecting one clears the other selector. - // Remember the previous selections to restore them if the checkout fails. - let previous_branch = self.branch_select.read(cx).selected_value(); - let previous_tag = self.tag_select.read(cx).selected_value(); + let name = name.into(); + let previous_branch = self.refs.branch_select.read(cx).selected_value(); + let previous_tag = self.refs.tag_select.read(cx).selected_value(); match kind { RefKind::Branch => { - self.tag_select + self.refs + .tag_select .update(cx, |state, cx| state.clear_selection(cx)); } RefKind::Tag => { - self.branch_select + self.refs + .branch_select .update(cx, |state, cx| state.clear_selection(cx)); } } - self.switching_ref = true; - // In-flight loads of the previous branch are discarded when they complete. - self.ref_generation += 1; + + self.refs.switching_ref = true; cx.notify(); let checkout_name = name.clone(); @@ -1122,9 +626,19 @@ impl RepoDetailView { Ok(()) => this.reload_worktree(cx), Err(error) => { this.error = Some(format!("Failed to check out {name}: {error}").into()); - this.switching_ref = false; - this.restore_selection(&this.branch_select, &previous_branch, window, cx); - this.restore_selection(&this.tag_select, &previous_tag, window, cx); + this.refs.switching_ref = false; + this.refs.restore_selection( + &this.refs.branch_select, + &previous_branch, + window, + cx, + ); + this.refs.restore_selection( + &this.refs.tag_select, + &previous_tag, + window, + cx, + ); } } cx.notify(); @@ -1133,27 +647,9 @@ impl RepoDetailView { Ok(()) }); - task.detach(); + self.tasks.push(task); } - /// Restore a selector to `previous`, or clear it after a failed switch. - fn restore_selection( - &self, - select: &Entity>>, - previous: &Option, - window: &mut Window, - cx: &mut Context, - ) { - select.update(cx, |state, cx| match previous { - Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), - None => state.clear_selection(cx), - }); - } - - /// Refresh the file explorer, preview pane and commit list after a successful switch. - /// The selectors were already updated by [`Self::switch_ref`]. - /// [`Self::switching_ref`] stays set until this reload finishes. - /// A second switch cannot interleave. fn reload_worktree(&mut self, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; @@ -1165,72 +661,47 @@ impl RepoDetailView { let snapshot = signed_git::worktree_snapshot(&worktree)?; // Build the tree off the main thread, like [`Self::load_repo`]. let tree = build_tree_items(&snapshot.entries); - Ok::<_, Error>((snapshot, tree)) + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) }) .await; this.update(cx, |this, cx| { - this.switching_ref = false; + this.refs.switching_ref = false; + match result { - Ok((snapshot, tree)) => { + Ok((snapshot, tree, paths)) => { this.head_commit = snapshot.head_commit; - // Rebuild the tree from scratch. - // Entries of the previous branch are gone. - // The expansion state goes with them. - this.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); + let readme_path = snapshot.readme_path; + let readme = snapshot.readme; + this.files.update(cx, |files, cx| { + files.clear_previews(); + files.apply_entries(tree, paths, cx); + files.set_readme(readme_path, readme, cx); }); - - // Drop cached previews and commits of the old branch. - this.selected_file = None; - this.files.clear(); - this.file_order.clear(); - this.preview_bytes = 0; - this.loading_files.clear(); - this.commits.clear(); - this.pending_commits.clear(); - this.loading_commits = false; - this.md = None; - this.code = None; - this.readme_name = None; - this.all_commits = None; - this.loading_all_commits = false; - - if let Some((path, bytes)) = snapshot.readme_path.zip(snapshot.readme) { - this.readme_name = Some(path.to_string_lossy().into()); - this.load_commit(&path.to_string_lossy(), cx); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - this.load_all_commits(cx); + this.history.update(cx, |history, cx| history.reload(cx)); } Err(error) => { this.error = Some(error.to_string().into()); this.head_commit = None; // The tree may show files that no longer exist. - this.tree_state.update(cx, |state, cx| { - state.set_items(Vec::new(), cx); + this.files.update(cx, |files, cx| { + files.apply_entries(Vec::new(), Vec::new(), cx); }); } } + cx.notify(); })?; Ok(()) }); - task.detach(); + self.tasks.push(task); } - /// Refresh the file explorer, previews and commit list after the mirror - /// caught up with the remote. - /// - /// The checked-out branch fast-forwarded in place, so unlike - /// [`Self::reload_worktree`] this keeps the panel's selection and previews: - /// it rebuilds the tree, drops previews of files the refresh removed and - /// re-renders the README when it is on screen. - fn catch_up_worktree(&mut self, cx: &mut Context) { + /// Refresh the file explorer, previews and commit list after the mirror caught up with the remote. + pub(super) fn catch_up_worktree(&mut self, cx: &mut Context) { let Some(worktree) = self.worktree.clone() else { return; }; @@ -1240,177 +711,165 @@ impl RepoDetailView { .background_spawn(async move { let snapshot = signed_git::worktree_snapshot(&worktree)?; let tree = build_tree_items(&snapshot.entries); - Ok::<_, Error>((snapshot, tree)) + let paths = sorted_worktree_paths(&snapshot.entries); + Ok::<_, Error>((snapshot, tree, paths)) }) .await; this.update(cx, |this, cx| { match result { - Ok((snapshot, tree)) => { + Ok((snapshot, tree, paths)) => { let head_changed = snapshot.head_commit.as_ref().map(|c| &c.id) != this.head_commit.as_ref().map(|c| &c.id); + let files_changed = this + .files + .update(cx, |files, cx| files.catch_up(&snapshot, tree, paths, cx)); + + // A fast-forward of a branch other than the checked-out + // one leaves the worktree untouched. Rebuilding the tree + // and re-parsing the README would flash the panel for + // nothing, so it is a no-op. + if !head_changed && !files_changed { + log::debug!("repo detail catch_up_worktree: no-op"); + return; + } + this.head_commit = snapshot.head_commit; - this.tree_state.update(cx, |state, cx| { - state.set_items(tree_items(tree, false), cx); - }); - - // Drop previews of files the refresh removed from the worktree, - // everything else stays put. - let present: HashSet = snapshot - .entries - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(); - - let mut previewed: Vec = Vec::new(); - previewed.extend(this.files.keys().cloned()); - previewed.extend(this.selected_file.clone().map(|p| p.to_string())); - - if let Some(path) = this.md.as_ref().and_then(|md| md.path.clone()) { - previewed.push(path.to_string()); - } - - if let Some(path) = this.code.as_ref().map(|code| code.path.clone()) { - previewed.push(path.to_string()); - } - - previewed.sort(); - previewed.dedup(); - - for path in previewed { - if !present.contains(&path) { - this.drop_preview_of(&path); - } - } - - // Re-render the README when it is on screen, i.e. when no file preview is open. - if this.selected_file.is_none() { - match snapshot.readme_path.zip(snapshot.readme) { - Some((path, bytes)) => { - this.readme_name = Some(path.to_string_lossy().into()); - if let Ok(text) = String::from_utf8(bytes) { - this.set_markdown(None, &text, cx); - } - } - None => { - this.md = None; - this.readme_name = None; - } - } - } if head_changed { - this.all_commits = None; - this.loading_all_commits = false; - this.load_all_commits(cx); + this.history.update(cx, |history, cx| history.reload(cx)); } + + cx.notify(); } Err(error) => { this.error = Some(error.to_string().into()); + cx.notify(); } } - cx.notify(); })?; Ok(()) }); - task.detach(); + self.tasks.push(task); } - /// Drop the cached preview, editor and commit state of `path`. - fn drop_preview_of(&mut self, path: &str) { - if let Some(FileContent::Text(text)) = self.files.remove(path) { - self.preview_bytes -= text.len(); - } - self.commits.remove(path); - if self.selected_file.as_deref() == Some(path) { - self.selected_file = None; - } - if self.md.as_ref().and_then(|md| md.path.as_deref()) == Some(path) { - self.md = None; - } - if self.code.as_ref().map(|code| code.path.as_ref()) == Some(path) { - self.code = None; - } + pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { + self.error = None; + cx.notify(); + + let task = self.store.update(cx, |store, cx| store.push_repository(cx)); + self.tasks.push(task); } - /// Drop the oldest previews beyond the cache caps. - /// Keep the currently selected file. - /// An evicted file's parsed editor state drops with its entry. - /// Re-opening it re-parses on a background task. - fn evict_previews(&mut self) { - while (self.files.len() > MAX_PREVIEWED_FILES - || self.preview_bytes > MAX_PREVIEW_CACHE_BYTES) - && self.file_order.len() > 1 - { - let path = self.file_order.pop_front().expect("non-empty"); - if Some(path.as_str()) == self.selected_file.as_deref() { - self.file_order.push_back(path); - continue; + pub(super) fn push_unpushed_checkout( + &mut self, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let store = self.store.clone(); + + if store.read(cx).pushing { + return; + } + + self.error = None; + cx.notify(); + + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { + let push = this.update_in(cx, |_this, _window, cx| { + store.update(cx, |store, cx| store.push_checkout(path.clone(), cx)) + })?; + + if let Ok(()) = push.await { + this.update_in(cx, |this, window, cx| { + this.load_repo(window, cx); + })?; } - if let Some(FileContent::Text(text)) = self.files.remove(&path) { - self.preview_bytes -= text.len(); - } - if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) { - self.md = None; - } - if self - .code - .as_ref() - .is_some_and(|code| code.path.as_ref() == path.as_str()) - { - self.code = None; - } - self.commits.remove(&path); + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Delete the repository from nostr, announcement, state and activity. + pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { + let task = self + .store + .update(cx, |store, cx| store.delete_repository(cx)); + self.tasks.push(task); + } + + pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { + if self.store.read(cx).addr().is_none() { + return; } + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let store = self.store.clone(); + let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); } - /// The latest announcement from the store or the open-time snapshot. - /// `None` for local repositories that haven't been published yet. - fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { - let store = self.store.as_ref()?; - store - .read(cx) - .announcement - .as_ref() - .or(self.initial.as_ref()) - } - - /// Display name, the announcement's name or ID for announced repositories. - /// The directory name for local ones. - fn display_name(&self, cx: &App) -> SharedString { - if let Some(path) = &self.local_path { - return SharedString::from( - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()), - ); + pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { + if self.store.read(cx).addr().is_none() { + return; } - self.announcement(cx) - .map(|announcement| { - announcement - .name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) - }) - .unwrap_or_default() + + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let store = self.store.clone(); + let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); + + dock_area.update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }); } - /// The NIP-34 header, actions and issues/PR counts. - /// Or the local header with an Init button for an unpublished repository. - fn render_header(&mut self, cx: &mut Context) -> AnyElement { - if self.local_path.is_some() { + /// Open the upstream repository, the `u` tag of this fork's announcement. + /// + /// The announcement may not be in the local database yet. The panel opens + /// from the address and fills in when the store loads it; the store's + /// `subscribe_remote` fetches it from the bootstrap relays. + pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self + .announcement(cx) + .and_then(|announcement| announcement.upstream.as_ref()) + .and_then(|upstream| upstream.addr.clone()) + else { + return; + }; + + open_repo_panel(&self.dock_area, &addr, None, window, &mut *cx); + } + + /// Open the dialog that publishes the local repository to NIP-34. + pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(local_path) = self.store.read(cx).path.clone() else { + return; + }; + let view = cx.entity().downgrade(); + init_dialog::open(local_path, view, window, cx); + } + + pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { + if self.store.read(cx).addr().is_none() { return self.render_local_header(cx); } - let Some(store_entity) = self.store.as_ref() else { - return div().into_any_element(); - }; - - let store = store_entity.read(cx); + let store = self.store.read(cx); let issue_count = SharedString::from(store.issue_count().to_string()); let pr_count = SharedString::from(store.pull_request_count().to_string()); @@ -1418,12 +877,10 @@ impl RepoDetailView { let pushing = store.pushing; let cloning = store.cloning; - let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else { + let Some(source) = store.announcement.as_ref() else { return div().into_any_element(); }; - // Derived NIP-34 header data, share targets and clone commands. - // Rebuilt per frame: two bech32 encodes and a couple of format strings. let nip05 = ProfileStore::global(cx) .read(cx) .get(&source.owner) @@ -1440,7 +897,7 @@ impl RepoDetailView { let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); let git_commands = Rc::new(announcement.clone_urls()); - let name = self.display_name(cx); + let name = repo_display_name(self.store.read(cx)); let description = announcement.description(); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); @@ -1448,19 +905,18 @@ impl RepoDetailView { .on_action( cx.listener(|this, action: &RepoAction, window, cx| match action { RepoAction::NewIssue => { - if let Some(store) = this.store.clone() { - open_new_issue_dialog(store, window, cx); - } + open_new_issue_dialog(this.store.clone(), window, cx); } RepoAction::NewPR => { - if let Some(store) = this.store.clone() { - open_new_pull_panel(this.dock_area.clone(), store, window, cx); - } + open_new_pull_panel(this.dock_area.clone(), this.store.clone(), window, cx); } RepoAction::SendPatch => { - if let Some(store) = this.store.clone() { - open_send_patch_panel(this.dock_area.clone(), store, window, cx); - } + open_send_patch_panel( + this.dock_area.clone(), + this.store.clone(), + window, + cx, + ); } RepoAction::About => { if let Some(announcement) = this.announcement(cx) { @@ -1791,12 +1247,12 @@ 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 name = repo_display_name(self.store.read(cx)); let path = self - .local_path + .store + .read(cx) + .path .as_ref() .map(|path| path.display().to_string()) .unwrap_or_default(); @@ -1854,427 +1310,9 @@ impl RepoDetailView { .into_any_element() } - /// Open the dialog guiding the user through publishing the local repository to NIP-34. - fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(local_path) = self.local_path.clone() else { - return; - }; - let view = cx.entity().downgrade(); - init_dialog::open(local_path, view, window, cx); - } - - /// Switch the repository into its NIP-34 mode after a successful init. - /// Creates the nostr store for the announced repository. - /// Drops the local scan identity. - /// The worktree is unchanged, so the explorer keeps its loaded content. - pub(crate) fn apply_announcement( - &mut self, - announcement: Announcement, - cx: &mut Context, - ) { - // The repository is no longer a bare local repo. - // Drop it from the scan results so it leaves the sidebar's local section. - if let Some(path) = self.local_path.take() { - LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); - } - let store = - cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); - // Re-render on store refreshes, issues, PRs and statuses. - // Keep the ready-to-contribute statuses of this repository requested. - self.attach_store(&store, cx); - self.store = Some(store); - self.initial = Some(announcement); - cx.notify(); - } - - /// Observe the repository's store, re-render on refreshes. - /// Request the ready-to-contribute statuses for it. - fn attach_store(&mut self, store: &Entity, cx: &mut Context) { - self._subscriptions - .push(cx.observe(store, |this, _store, cx| { - this.refresh_ready_statuses(cx); - cx.notify(); - })); - self.refresh_ready_statuses(cx); - } - - /// Request the statuses of this repository again when the announced HEAD changes. - /// The HEAD is the base the checkouts are compared against. - /// Owned repositories are watched for unpushed commits. - /// Other repositories for ready-to-contribute checkouts. - fn refresh_ready_statuses(&mut self, cx: &mut Context) { - let Some(entity) = self.store.clone() else { - return; - }; - - let head = entity.read(cx).head.clone(); - - if self.ready_requested && self.ready_head == head { - return; - } - - self.ready_requested = true; - self.ready_head = head.clone(); - - let addr = entity.read(cx).addr().clone(); - let backend = Backend::global(cx); - let checkout = CheckoutsStore::global(cx); - - let owned = backend - .read(cx) - .current_user() - .is_some_and(|user| entity.read(cx).is_author(&user)); - - checkout.update(cx, |store, cx| { - // The ready statuses keep the fast poll running while the panel is open. - // The sidebar's push watch alone polls slower. - store.request_statuses(&addr, head, cx); - - if owned { - store.request_push_statuses(&addr, cx); - } - }); - } - - /// The ready-to-push statuses of this repository in the global checkouts - /// store changed since they last drove a render. - /// - /// Updates the cached slices. `None` store (a local, not yet published, - /// repository) has no statuses. - fn refresh_statuses(&mut self, cx: &mut Context) -> bool { - let Some(entity) = self.store.clone() else { - return false; - }; - let addr = entity.read(cx).addr().clone(); - let checkouts = CheckoutsStore::global(cx).read(cx); - let ready_statuses = checkouts.ready_statuses_of(&addr); - let push_statuses = checkouts.push_statuses_of(&addr); - - let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses; - self.ready_statuses = ready_statuses; - self.push_statuses = push_statuses; - changed - } - - /// The first checkout ready for a pull request on this repository. - /// Not covered by an open PR of the signed-in user. - /// Not dismissed in this panel. - /// The repository's own checkouts are not suggested here. - /// Their work is pushed, see [`Self::push_suggestion`]. - fn ready_suggestion(&self, cx: &App) -> Option { - let store = self.store.as_ref()?; - let addr = store.read(cx).addr().clone(); - let user = Backend::global(cx).read(cx).current_user()?; - if store.read(cx).is_author(&user) { - return None; - } - - let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr); - - 'status: for status in statuses { - if self - .banner_dismissed - .contains(&(status.path.clone(), status.branch.clone())) - { - continue; - } - let store = store.read(cx); - for pr in &store.pull_requests { - if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) - { - continue 'status; - } - } - return Some(status); - } - - None - } - - /// The first checkout of this owned repository with unpushed commits. - /// - /// Not dismissed in this panel. - fn push_suggestion(&self, cx: &App) -> Option { - let entity = self.store.as_ref()?; - let user = Backend::global(cx).read(cx).current_user()?; - - if !entity.read(cx).is_author(&user) { - return None; - } - - let addr = entity.read(cx).addr().clone(); - let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr); - - statuses.into_iter().find(|status| { - !self - .banner_dismissed - .contains(&(status.path.clone(), status.branch.clone())) - }) - } - - /// The ready-to-push banner of an owned repository. - /// - /// A local checkout has unpushed commits, with a Push action and a dismiss control. - fn render_push_banner(&self, cx: &Context) -> Option { - let status = self.push_suggestion(cx)?; - let key = (status.path.clone(), status.branch.clone()); - let path = status.path.clone(); - // The push busy flag lives on the store; it disables the banner's triggers. - let pushing = self - .store - .as_ref() - .is_some_and(|store| store.read(cx).pushing); - - let commits = if status.ahead == 1 { - SharedString::from("1 commit") - } else { - SharedString::from(format!("{} commits", status.ahead)) - }; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_center() - .justify_between() - .bg(cx.theme().muted) - .child( - h_flex() - .gap_2() - .text_sm() - .text_color(cx.theme().info) - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(status.branch), - ) - .child("has") - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(commits), - ) - .child("ready to push"), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("push-checkout-banner") - .icon(IconName::ArrowUp) - .label("Push") - .small() - .info() - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(move |this, _event, window, cx| { - this.push_unpushed_checkout(path.clone(), window, cx); - })), - ) - .child( - Button::new("close-repo") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .disabled(pushing) - .on_click(cx.listener(move |this, _ev, _window, cx| { - this.banner_dismissed.insert(key.clone()); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - /// Warning after a push that only some grasp servers accepted. - fn render_push_warning_banner(&self, cx: &Context) -> Option { - let store = self.store.as_ref()?; - let store = store.read(cx); - let warning = store.last_push_warning.clone()?; - let pushing = store.pushing; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_start() - .justify_between() - .bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08)) - .child( - h_flex() - .gap_2() - .min_w_0() - .flex_1() - .items_start() - .child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0()) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_color(cx.theme().warning) - .child(SharedString::from(warning)), - ), - ) - .child( - h_flex() - .gap_1() - .flex_shrink_0() - .child( - Button::new("republish-after-partial-push") - .icon(CustomIconName::Init) - .label("Republish") - .small() - .info() - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(|this, _event, window, cx| { - this.push_repository(window, cx); - })), - ) - .child( - Button::new("dismiss-push-warning") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .disabled(pushing) - .on_click(cx.listener(|this, _ev, _window, cx| { - if let Some(store) = this.store.clone() { - store.update(cx, |store, _| { - store.last_push_warning = None; - }); - } - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - /// The ready-to-contribute banner of the repository panel. - fn render_ready_banner(&self, cx: &Context) -> Option { - let status = self.ready_suggestion(cx)?; - let key = (status.path.clone(), status.branch.clone()); - - let commits = if status.ahead == 1 { - SharedString::from("1 commit") - } else { - SharedString::from(format!("{} commits", status.ahead)) - }; - - Some( - h_flex() - .p_4() - .gap_2() - .w_full() - .items_center() - .justify_between() - .bg(cx.theme().muted) - .child( - h_flex() - .gap_2() - .text_sm() - .text_color(cx.theme().info) - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(status.branch), - ) - .child("is") - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(commits), - ) - .child("ahead of") - .child( - h_flex() - .px_1() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().info) - .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) - .text_xs() - .font_semibold() - .font_family(cx.theme().mono_font_family.clone()) - .child(status.base), - ), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("create-pr-from-banner") - .icon(IconName::Plus) - .label("Create") - .small() - .info() - .on_click(cx.listener(|this, _event, window, cx| { - if let Some(store) = this.store.clone() { - open_new_pull_panel( - this.dock_area.clone(), - store, - window, - cx, - ); - } - })), - ) - .child( - Button::new("dismiss-ready-banner") - .icon(IconName::Close) - .tooltip("Dismiss") - .small() - .ghost() - .on_click(cx.listener(move |this, _ev, _window, cx| { - this.banner_dismissed.insert(key.clone()); - cx.notify(); - })), - ), - ) - .into_any_element(), - ) - } - - /// The tab row shared by both header variants. - /// Files and Commits tabs, the HEAD commit button and the branch/tag selectors. fn render_header_tabs(&self, cx: &mut Context) -> AnyElement { - let commits_count = self.all_commits.as_ref().map(|list| list.total); - let worktree_empty = self.switching_ref || self.worktree.is_none(); + let commits_count = self.history.read(cx).commit_count(); + let worktree_empty = self.refs.switching_ref || self.worktree.is_none(); h_flex() .items_center() @@ -2371,13 +1409,15 @@ impl RepoDetailView { .on_click(cx.listener(|this, _event, window, cx| { if let Some(commit) = &this.head_commit { let id = commit.id.clone(); - this.open_commit_diff(&id, window, cx); + this.history.update(cx, |history, cx| { + history.open_commit_diff(&id, window, cx) + }); } })), ) .child( div().w(px(120.)).child( - Combobox::new(&self.branch_select) + Combobox::new(&self.refs.branch_select) .placeholder("Branch") .appearance(false) .menu_width(px(200.)) @@ -2391,7 +1431,7 @@ impl RepoDetailView { ) .child( div().w(px(120.)).child( - Combobox::new(&self.tag_select) + Combobox::new(&self.refs.tag_select) .placeholder("Tag") .appearance(false) .menu_width(px(200.)) @@ -2450,126 +1490,322 @@ impl RepoDetailView { ) .into_any_element() } -} -impl BasePanel for RepoDetailView { - fn panel_name(&self) -> &'static str { - "repo_detail" + fn ready_suggestion(&self, cx: &App) -> Option { + let store = self.store.read(cx); + let addr = store.addr()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if store.is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr); + + 'status: for status in statuses { + if self.banners.dismissal(&status) { + continue; + } + for pr in &store.pull_requests { + if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) + { + continue 'status; + } + } + return Some(status); + } + + None } -} -impl Panel for RepoDetailView { - fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.display_name(cx) + /// The first checkout of this owned repository with unpushed commits. + /// + /// Not dismissed in this panel. + fn push_suggestion(&self, cx: &App) -> Option { + let store = self.store.read(cx); + let addr = store.addr()?; + let user = Backend::global(cx).read(cx).current_user()?; + + if !store.is_author(&user) { + return None; + } + + let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr); + + statuses + .into_iter() + .find(|status| !self.banners.dismissal(status)) } -} -impl EventEmitter for RepoDetailView {} + pub(super) fn render_push_banner(&self, cx: &Context) -> Option { + let status = self.push_suggestion(cx)?; + let path = status.path.clone(); + let branch = status.branch.clone(); + // The push busy flag lives on the store; it disables the banner's triggers. + let pushing = self.store.read(cx).pushing; -impl Focusable for RepoDetailView { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} + let commits = if status.ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", status.ahead)) + }; -impl Render for RepoDetailView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let tree_state = self.tree_state.clone(); - let view = cx.entity().downgrade(); - - let pane_title = self - .selected_file - .clone() - .or_else(|| self.readme_name.clone()) - .unwrap_or_else(|| "Overview".into()); - - let banner = self - .render_ready_banner(cx) - .or_else(|| self.render_push_banner(cx)); - - // View-level load/switch errors, plus the errors of the store-owned - // operations, republish, checkout push, delete and clone-to-folder. - let error = self.error.clone().or_else(|| { - self.store - .as_ref() - .and_then(|store| store.read(cx).last_error.clone().map(SharedString::from)) - }); - - v_flex() - .image_cache(gpui::retain_all("repo")) - .id("repo") - .size_full() - .when_some(banner, |this, banner| this.child(banner)) - .when_some(self.render_push_warning_banner(cx), |this, banner| { - this.child(banner) - }) - .child(self.render_header(cx)) - .when_some(error, |this, error| { - this.child( - Alert::error("repo-error", error) - .banner() - .on_close(cx.listener(|this, _event, _window, cx| { - this.error = None; - if let Some(store) = this.store.clone() { - store.update(cx, |store, _| store.last_error = None); - } - cx.notify(); - })), - ) - }) - .map(|this| match self.active_tab { - 0 => this.child( + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().info) + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(branch), + ) + .child("has") + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(commits), + ) + .child("ready to push"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("push-checkout-banner") + .icon(IconName::ArrowUp) + .label("Push") + .small() + .info() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(move |this, _event, window, cx| { + this.push_unpushed_checkout(path.clone(), window, cx); + })), + ) + .child( + Button::new("close-repo") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .disabled(pushing) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.banners.dismiss(&status); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { + let store = self.store.read(cx); + let warning = store.last_push_warning.clone()?; + let pushing = store.pushing; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_start() + .justify_between() + .bg(cx.theme().warning.mix_oklab(transparent_white(), 0.08)) + .child( + h_flex() + .gap_2() + .min_w_0() .flex_1() - .w_full() - .overflow_hidden() - .child(Self::render_tree_column(tree_state, view, cx)) - .child(self.render_content_column(pane_title, cx)) - .into_any_element(), - ), - _ => this.child(self.render_commits_tab(cx)), - }) + .items_start() + .child(Icon::new(IconName::TriangleAlert).small().flex_shrink_0()) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_color(cx.theme().warning) + .child(SharedString::from(warning)), + ), + ) + .child( + h_flex() + .gap_1() + .flex_shrink_0() + .child( + Button::new("republish-after-partial-push") + .icon(CustomIconName::Init) + .label("Republish") + .small() + .info() + .loading(pushing) + .disabled(pushing) + .on_click(cx.listener(|this, _event, window, cx| { + this.push_repository(window, cx); + })), + ) + .child( + Button::new("dismiss-push-warning") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .disabled(pushing) + .on_click(cx.listener(|this, _ev, _window, cx| { + this.store.update(cx, |store, _| { + store.last_push_warning = None; + }); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + pub(super) fn render_ready_banner(&self, cx: &Context) -> Option { + let status = self.ready_suggestion(cx)?; + let branch = status.branch.clone(); + let base = status.base.clone(); + + let commits = if status.ahead == 1 { + SharedString::from("1 commit") + } else { + SharedString::from(format!("{} commits", status.ahead)) + }; + + Some( + h_flex() + .p_4() + .gap_2() + .w_full() + .items_center() + .justify_between() + .bg(cx.theme().muted) + .child( + h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().info) + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(branch), + ) + .child("is") + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(commits), + ) + .child("ahead of") + .child( + h_flex() + .px_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().info) + .bg(cx.theme().info.mix_oklab(transparent_white(), 0.04)) + .text_xs() + .font_semibold() + .font_family(cx.theme().mono_font_family.clone()) + .child(base), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("create-pr-from-banner") + .icon(IconName::Plus) + .label("Create") + .small() + .info() + .on_click(cx.listener(|this, _event, window, cx| { + open_new_pull_panel( + this.dock_area.clone(), + this.store.clone(), + window, + cx, + ); + })), + ) + .child( + Button::new("dismiss-ready-banner") + .icon(IconName::Close) + .tooltip("Dismiss") + .small() + .ghost() + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.banners.dismiss(&status); + cx.notify(); + })), + ), + ) + .into_any_element(), + ) + } + + /// The Files tab body, or the clone/initial-load spinner. + fn render_files_tab(&self, cx: &mut Context) -> AnyElement { + if self.loading { + return v_flex() + .flex_1() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child(Spinner::new().small()) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Cloning repository..."), + ) + .into_any_element(); + } + + self.files.clone().into_any_element() } } -/// Read the worktree state of `repo`, no network. -/// -/// Entries, README, refs and HEAD commit. -fn load_repo_data(repo: &Repository) -> Result { - let entries = signed_git::worktree_entries(repo)?; - let tree = build_tree_items(&entries); - let readme_path = signed_git::find_readme(repo)?; - let readme = match &readme_path { - Some(path) => signed_git::worktree_read(repo, path)?, - None => None, - }; - let worktree = repo.workdir().map(Path::to_path_buf); - // Ref listing is auxiliary UI. - // A broken ref must not prevent the explorer from loading. - // Failures degrade to empty selectors. - let (branches, tags, current_branch) = match &worktree { - Some(_) => ( - signed_git::repo_branches(repo).unwrap_or_default(), - signed_git::repo_tags(repo).unwrap_or_default(), - signed_git::current_branch(repo).unwrap_or(None), - ), - None => (Vec::new(), Vec::new(), None), - }; - let head_commit = signed_git::head_commit(repo).unwrap_or(None); - - Ok(RepoData { - tree, - readme_path, - readme, - worktree, - branches, - tags, - current_branch, - head_commit, - }) -} - -/// The `nostr://...` clone URL of an announcement, NIP-34. fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { let owner = announcement.owner; let user = nip05 @@ -2587,10 +1823,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()?; @@ -2635,72 +1867,196 @@ fn fork_row(announcement: &Announcement, cx: &mut Context) -> Op }) } -/// Open `announcement` as a repository panel in the dock's center. -pub(crate) fn open_repo_panel( - dock_area: &WeakEntity, - announcement: &Announcement, - window: &mut Window, - cx: &mut App, -) -> Entity { - let detail = - cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx)); +struct ShareTargets { + /// NIP-19 `naddr1...` of the announcement, with its announced relays. + naddr: String, + /// Hex ID of the announcement event itself. + event_id: String, + /// NIP-34 coordinate `30617::`. + coordinate: String, + /// `https://gitworkshop.dev/` + gitworkshop: String, + /// `https://ditto.pub/` + ditto: String, +} - if let Some(dock_area) = dock_area.upgrade() { - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(detail.clone()), window, cx); - }); +impl ShareTargets { + fn from_announcement(announcement: &Announcement) -> Self { + let addr = announcement.addr(); + let coordinate = addr.to_string(); + let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) + .to_bech32() + .expect("a complete coordinate always encodes to naddr"); + + Self { + naddr: naddr.clone(), + event_id: announcement.event_id.to_bech32().unwrap(), + coordinate, + gitworkshop: format!("https://gitworkshop.dev/{naddr}"), + ditto: format!("https://ditto.pub/{naddr}"), + } } - detail + fn menu(&self, menu: PopupMenu) -> PopupMenu { + menu.min_w(px(340.)) + .item(menu_copy_row( + "copy-gitworkshop", + "GitWorkshop", + truncate_naddr_link(&self.gitworkshop, 4), + self.gitworkshop.clone(), + )) + .item(menu_copy_row( + "copy-ditto", + "Ditto", + truncate_naddr_link(&self.ditto, 4), + self.ditto.clone(), + )) + .item(menu_copy_row( + "copy-event-id", + "Event ID", + middle_truncate(&self.event_id, 10, 10), + self.event_id.clone(), + )) + .item(menu_copy_row( + "copy-coordinate", + "Coordinate", + middle_truncate(&self.coordinate, 10, 10), + self.coordinate.clone(), + )) + } } -/// The nostr store of `announcement`'s repository, without opening a repository panel. -fn repo_store(announcement: &Announcement, cx: &mut App) -> Entity { - cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)) +fn truncate_naddr_link(url: &str, tail: usize) -> String { + let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { + return url.to_string(); + }; + if url.len() - end <= tail + 3 { + return url.to_string(); + } + format!("{}...{}", &url[..end], &url[url.len() - tail..]) } -/// An item of a repository to open from outside its detail panel. -/// A patch has no detail view in Signed, so it opens nothing. -pub(crate) enum RepoItem { - Issue(EventId), - PullRequest(EventId), - Patch, -} +fn load_repo_data(repo: &Repository) -> Result { + let entries = signed_git::worktree_entries(repo)?; + let tree = build_tree_items(&entries); + let readme_path = signed_git::find_readme(repo)?; -/// Open the detail panel of `item` in `announcement`'s repository, in the dock's center. -/// -/// The repository store is built here, not taken from a `RepoDetailView`, so the -/// item panel is the only panel docked. -/// -/// A patch opens nothing: patches are only consumed inside a pull request's -/// detail panel, and have no panel of their own. -pub(crate) fn open_repo_item( - dock_area: &WeakEntity, - announcement: &Announcement, - item: RepoItem, - window: &mut Window, - cx: &mut App, -) { - let panel: Arc = - match item { - RepoItem::Issue(issue_id) => { - let store = repo_store(announcement, cx); - panel_handle(cx.new(|cx| IssueDetailView::new(store, issue_id, window, cx))) - } - RepoItem::PullRequest(pr_id) => { - let store = repo_store(announcement, cx); - panel_handle(cx.new(|cx| { - PullRequestDetailView::new(dock_area.clone(), store, pr_id, window, cx) - })) - } - RepoItem::Patch => return, - }; - - let Some(dock_area) = dock_area.upgrade() else { - return; + let readme = match &readme_path { + Some(path) => signed_git::worktree_read(repo, path)?, + None => None, }; - dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel, window, cx); - }); + let worktree = repo.workdir().map(Path::to_path_buf); + let head_commit = signed_git::head_commit(repo).unwrap_or(None); + + let (branches, tags, current_branch) = match &worktree { + Some(_) => ( + signed_git::repo_branches(repo).unwrap_or_default(), + signed_git::repo_tags(repo).unwrap_or_default(), + signed_git::current_branch(repo).unwrap_or(None), + ), + None => (Vec::new(), Vec::new(), None), + }; + + Ok(RepoData { + tree, + entries, + readme_path, + readme, + worktree, + branches, + tags, + current_branch, + head_commit, + }) +} + +/// The announcement's name or ID for announced repositories, the directory name for local ones. +pub(super) fn repo_display_name(store: &RepoStore) -> SharedString { + if store.addr().is_none() { + return store + .path + .as_ref() + .map(|path| { + SharedString::from( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ) + }) + .unwrap_or_default(); + } + + store + .announcement + .as_ref() + .map(|announcement| { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + }) + .unwrap_or_default() +} + +impl BasePanel for RepoDetailView { + fn panel_name(&self) -> &'static str { + "repo" + } +} + +impl Panel for RepoDetailView { + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + repo_display_name(self.store.read(cx)) + } +} + +impl EventEmitter for RepoDetailView {} + +impl Focusable for RepoDetailView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RepoDetailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let banner = self + .render_ready_banner(cx) + .or_else(|| self.render_push_banner(cx)); + + let error = self.error.clone().or_else(|| { + self.store + .read(cx) + .last_error + .clone() + .map(SharedString::from) + }); + + v_flex() + .image_cache(gpui::retain_all("repo")) + .id("repo") + .size_full() + .when_some(banner, |this, banner| this.child(banner)) + .when_some(self.render_push_warning_banner(cx), |this, banner| { + this.child(banner) + }) + .child(self.render_header(cx)) + .when_some(error, |this, error| { + this.child( + Alert::error("repo-error", error) + .banner() + .on_close(cx.listener(|this, _event, _window, cx| { + this.error = None; + this.store.update(cx, |store, _| store.last_error = None); + cx.notify(); + })), + ) + }) + .map(|this| match self.active_tab { + 0 => this.child(self.render_files_tab(cx)), + _ => this.child(self.history.clone()), + }) + } } diff --git a/crates/workspace/src/views/repo/refs.rs b/crates/workspace/src/views/repo/refs.rs new file mode 100644 index 0000000..0c1bb03 --- /dev/null +++ b/crates/workspace/src/views/repo/refs.rs @@ -0,0 +1,115 @@ +use gpui::prelude::*; +use gpui::{App, Entity, SharedString, Window}; +use gpui_component::combobox::ComboboxState; +use gpui_component::searchable_list::SearchableVec; + +pub(super) struct RefSwitcher { + pub(super) branch_select: Entity>>, + pub(super) tag_select: Entity>>, + ref_branches: Vec, + ref_tags: Vec, + pub(super) switching_ref: bool, +} + +impl RefSwitcher { + pub(super) fn new(window: &mut Window, cx: &mut App) -> Self { + let branch_select = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + let tag_select = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(Vec::::new()), + Vec::new(), + window, + cx, + ) + .searchable(true) + }); + + Self { + branch_select, + tag_select, + ref_branches: Vec::new(), + ref_tags: Vec::new(), + switching_ref: false, + } + } + + pub(super) fn set_branches( + &mut self, + branches: Vec, + selected: Option, + window: &mut Window, + cx: &mut App, + ) -> bool { + sync_selector( + &self.branch_select, + &mut self.ref_branches, + branches, + selected, + window, + cx, + ) + } + + pub(super) fn set_tags( + &mut self, + tags: Vec, + window: &mut Window, + cx: &mut App, + ) -> bool { + sync_selector(&self.tag_select, &mut self.ref_tags, tags, None, window, cx) + } + + pub(super) fn restore_selection( + &self, + select: &Entity>>, + previous: &Option, + window: &mut Window, + cx: &mut App, + ) { + select.update(cx, |state, cx| match previous { + Some(value) => state.set_selected_values(std::slice::from_ref(value), window, cx), + None => state.clear_selection(cx), + }); + } +} + +fn sync_selector( + select: &Entity>>, + cached: &mut Vec, + items: Vec, + selected: Option, + window: &mut Window, + cx: &mut App, +) -> bool { + let items_changed = *cached != items; + + let selection_changed = selected + .as_ref() + .is_some_and(|value| select.read(cx).selected_value().as_ref() != Some(value)); + + if !items_changed && !selection_changed { + return false; + } + + select.update(cx, |state, cx| { + if items_changed { + state.set_items(SearchableVec::from(items.clone()), window, cx); + } + if let Some(value) = selected + && (items_changed || selection_changed) + { + state.set_selected_values(std::slice::from_ref(&value), window, cx); + } + }); + *cached = items; + + true +} diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs deleted file mode 100644 index 389aeae..0000000 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ /dev/null @@ -1,288 +0,0 @@ -use gpui::prelude::*; -use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px}; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::input::{Editor, EditorState}; -use gpui_component::list::ListItem; -use gpui_component::spinner::Spinner; -use gpui_component::text::{TextView, TextViewState}; -use gpui_component::tree::{TreeEntry, TreeState, tree}; -use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex}; -use signed_ui::{placeholder, tree_row}; - -use super::RepoDetailView; -use super::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. - pub(super) path: Option, - pub(super) state: Entity, -} - -/// A code file loaded into a persistent [`InputState`]. -pub(super) struct CodeView { - /// Source path, relative to the worktree root. - pub(super) path: SharedString, - pub(super) state: Entity, -} - -/// Spinner shown while a document is being loaded/parsed. -fn preview_spinner() -> AnyElement { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element() -} - -impl RepoDetailView { - /// One row of the file tree with icon and name, indented by depth. - fn render_tree_item( - ix: usize, - entry: &TreeEntry, - selected: bool, - view: &WeakEntity, - ) -> ListItem { - let view = view.clone(); - let id = entry.item().id.clone(); - - tree_row(ix, entry, selected, move |window, cx| { - if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| this.open_file(&id, window, cx)); - } - }) - } - - /// Left column showing the file tree. - pub(super) fn render_tree_column( - tree_state: Entity, - view: WeakEntity, - cx: &mut Context, - ) -> impl IntoElement { - v_flex() - .h_full() - .w(px(TREE_WIDTH)) - .p_2() - .flex_none() - .border_r_1() - .border_color(cx.theme().border) - .child(div().flex_1().min_h_0().child(tree( - &tree_state, - move |ix, entry, selected, _window, _cx| { - Self::render_tree_item(ix, entry, selected, &view) - }, - ))) - } - - /// Right column, README, selected file preview or status text. - pub(super) fn render_content_column( - &self, - pane_title: SharedString, - cx: &mut Context, - ) -> impl IntoElement { - let loading = self.loading; - let error = self.error.clone(); - let selected_file = self.selected_file.clone(); - - let body: AnyElement = if loading { - v_flex() - .size_full() - .items_center() - .justify_center() - .gap_2() - .child(Spinner::new().small()) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Cloning repository..."), - ) - .into_any_element() - } else if let Some(error) = error { - v_flex() - .size_full() - .items_center() - .justify_center() - .p_4() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(error), - ) - .into_any_element() - } else if let Some(path) = selected_file { - match self.files.get(path.as_ref()) { - Some(FileContent::Text(_)) => { - if is_markdown_path(path.as_ref()) { - self.markdown_element(Some(path.as_ref()), cx) - } else { - self.code_element(path.as_ref(), cx) - } - } - Some(FileContent::Binary) => placeholder("Binary file - preview not supported", cx), - Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx), - Some(FileContent::Failed(message)) => placeholder(message, cx), - None => preview_spinner(), - } - } else if self.readme_name.is_some() { - self.markdown_element(None, cx) - } else { - placeholder("No README found", cx) - }; - - // Latest commit for the current pane, the selected file or the README. - // Computed after the body above, which needs `&mut self`. - let commit = match &self.selected_file { - Some(path) => self.commits.get(path.as_ref()), - None => self - .readme_name - .as_ref() - .and_then(|name| self.commits.get(name.as_ref())), - }; - - v_flex() - .flex_1() - .min_w_0() - .h_full() - .child( - h_flex() - .px_3() - .h_9() - .gap_2() - .bg(cx.theme().muted) - .border_b(px(1.)) - .border_color(cx.theme().border) - .text_color(cx.theme().muted_foreground) - .child( - div() - .text_xs() - .font_semibold() - .text_ellipsis() - .whitespace_nowrap() - .child(pane_title), - ) - .when_some(commit, |this, commit| { - this.child( - h_flex() - .flex_1() - .gap_1() - .child( - Button::new("commit") - .xsmall() - .text() - .label(commit.id.clone()), - ) - .child( - div() - .max_w(px(250.)) - .text_xs() - .text_ellipsis() - .whitespace_nowrap() - .child(commit.summary.clone()), - ), - ) - }), - ) - .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, - text: &str, - cx: &mut Context, - ) { - let state = cx.new(|cx| TextViewState::markdown("", cx)); - state.update(cx, |state, cx| state.push_str(text, cx)); - self.md = Some(MarkdownView { path, state }); - } - - /// 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(); - }; - - let ready = match path { - Some(path) => md.path.as_deref() == Some(path), - None => md.path.is_none(), - }; - - if !ready { - return preview_spinner(); - } - - TextView::new(&md.state) - .selectable(true) - .scrollable(true) - .p_4() - .text_sm() - .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, - text: &str, - window: &mut Window, - cx: &mut Context, - ) { - let language = code_language(path.as_ref()).unwrap_or("text"); - let state = cx.new(|cx| { - EditorState::new(window, cx) - .language(language) - .default_value(text) - .line_number(true) - .folding(true) - }); - self.code = Some(CodeView { path, state }); - } - - /// 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(); - }; - if code.path.as_ref() != path { - return preview_spinner(); - } - - Editor::new(&code.state) - .readonly(true) - .bordered(false) - .rounded_none() - .h_full() - .text_sm() - .into_any_element() - } -} diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs deleted file mode 100644 index 49fb6ce..0000000 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ /dev/null @@ -1,160 +0,0 @@ -use gpui::prelude::*; -use gpui::{AnyElement, App, Context, Window, div, px}; -use gpui_component::scroll::Scrollbar; -use gpui_component::spinner::Spinner; -use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list}; -use signed_git::FileCommit; -use signed_ui::placeholder; -use utils::relative_time_secs; - -use super::RepoDetailView; - -/// Height of one commit row in the virtual list. -pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.; - -pub(super) fn commit_row( - ix: usize, - commit: &FileCommit, - on_click: impl Fn(&mut Window, &mut App) + 'static, - cx: &App, -) -> AnyElement { - h_flex() - .id(ix) - .px_4() - .h(px(COMMIT_ROW_HEIGHT)) - .w_full() - .gap_3() - .items_center() - .border_b(px(1.)) - .border_color(cx.theme().border) - .hover(|this| this.bg(cx.theme().list_hover)) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_0p5() - .justify_center() - .child( - h_flex() - .gap_2() - .items_center() - .overflow_hidden() - .child( - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.id.clone()), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_ellipsis() - .whitespace_nowrap() - .child(commit.summary.clone()), - ), - ) - .child( - h_flex() - .gap_2() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(commit.author.clone()) - .child(relative_time_secs(commit.time)), - ), - ) - .on_click(move |_event, window, cx| on_click(window, cx)) - .into_any_element() -} - -impl RepoDetailView { - pub(super) fn render_commits_tab(&self, cx: &mut Context) -> AnyElement { - let Some(list) = self.all_commits.as_ref() else { - return if self.loading_all_commits { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element() - } else { - placeholder("Failed to load commits", cx) - }; - }; - - if list.commits.is_empty() { - return placeholder("No commits found", cx); - } - - // Copy only the values the element tree needs. - // The list is borrowed by the renderer below instead of cloned per frame. - // A full history can be tens of thousands of commits. - let view = cx.entity().clone(); - let sizes = self.item_sizes.clone(); - let scroll_handle = self.scroll_handle.clone(); - let shown = list.commits.len(); - let total = list.total; - - v_flex() - .relative() - .flex_1() - .w_full() - .min_h_0() - .child( - v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| { - let view = cx.entity().downgrade(); - let commits = this - .all_commits - .as_ref() - .map(|list| list.commits.as_slice()) - .unwrap_or(&[]); - - range - .map(|ix| { - let id = commits[ix].id.clone(); - let view = view.clone(); - - commit_row( - ix, - &commits[ix], - move |window, cx| { - if let Some(view) = view.upgrade() { - view.update(cx, |this, cx| { - this.open_commit_diff(&id, window, cx) - }); - } - }, - cx, - ) - }) - .collect() - }) - .track_scroll(&scroll_handle) - .size_full(), - ) - .when(shown < total, |this| { - // The history is capped. - // Tell the user the list is truncated. - this.child( - div() - .py_2() - .w_full() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(format!("Showing {shown} of {total} commits")), - ) - }) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::vertical(&self.scroll_handle)), - ) - .into_any_element() - } -} diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs deleted file mode 100644 index fd7be3c..0000000 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ /dev/null @@ -1,714 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use assets::CustomIconName; -use gpui::prelude::*; -use gpui::{AnyElement, App, Entity, SharedString, div, px}; -use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::combobox::{Caret, ComboboxTriggerContext}; -use gpui_component::input::{Textarea, TextareaState}; -use gpui_component::menu::PopupMenu; -use gpui_component::searchable_list::SearchableVec; -use gpui_component::tag::Tag; -use gpui_component::tree::TreeItem; -use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; -use nostr::nips::nip19::{Nip19Coordinate, ToBech32}; -use nostr::prelude::{Event, EventId, PublicKey}; -use signed_core::Announcement; -use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff}; -use signed_state::{ProfileStore, RepoStore}; -use signed_ui::{UserAvatar, menu_copy_row, middle_truncate}; -use utils::relative_time; - -pub(super) struct TreeItemSeed { - /// Path of the node, relative to the worktree root. - id: String, - /// File or directory name. - label: String, - children: Vec, -} - -pub(super) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { - fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { - let mut item = TreeItem::new(seed.id, seed.label); - if expand_folders && !seed.children.is_empty() { - item = item.expanded(true); - } - item.children = seed - .children - .into_iter() - .map(|seed| convert(seed, expand_folders)) - .collect(); - item - } - - seeds - .into_iter() - .map(|seed| convert(seed, expand_folders)) - .collect() -} - -/// Build nested tree items from a flat entry list sorted dirs-first. -pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { - // Node indices by full path, so parents resolve in constant time while inserting. - let mut index: HashMap = HashMap::new(); - let mut nodes: Vec<(String, String, Vec)> = Vec::new(); - let mut roots: Vec = Vec::new(); - - for entry in entries { - let mut parent: Option = None; - let mut path = String::new(); - for part in entry.components() { - let label = part.as_os_str().to_string_lossy().into_owned(); - path = if path.is_empty() { - label.clone() - } else { - format!("{path}/{label}") - }; - let ix = *index.entry(path.clone()).or_insert_with(|| { - let ix = nodes.len(); - nodes.push((path.clone(), label.clone(), Vec::new())); - match parent { - Some(parent) => nodes[parent].2.push(ix), - None => roots.push(ix), - } - ix - }); - parent = Some(ix); - } - } - - fn assemble(ix: usize, nodes: &[(String, String, Vec)]) -> TreeItemSeed { - let (id, label, children) = &nodes[ix]; - TreeItemSeed { - id: id.clone(), - label: label.clone(), - children: children - .iter() - .map(|child| assemble(*child, nodes)) - .collect(), - } - } - - roots.iter().map(|root| assemble(*root, &nodes)).collect() -} - -/// The markdown fence language for a file path, or `None` for plain text. -pub(super) fn code_language(path: &str) -> Option<&'static str> { - let name = Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(); - - // Some common files are recognized by name rather than extension. - match name { - "Makefile" | "makefile" => return Some("make"), - "CMakeLists.txt" => return Some("cmake"), - _ => {} - } - - let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); - Some(match ext.as_str() { - "rs" => "rust", - "toml" => "toml", - "json" | "jsonc" => "json", - "py" => "python", - "js" | "mjs" | "cjs" => "javascript", - "ts" | "mts" | "cts" => "typescript", - "tsx" | "jsx" => "tsx", - "go" => "go", - "c" | "h" => "c", - "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp", - "cs" => "csharp", - "java" => "java", - "kt" | "kts" | "ktm" => "kotlin", - "swift" => "swift", - "php" | "phtml" => "php", - "rb" => "ruby", - "sh" | "bash" | "zsh" => "bash", - "yml" | "yaml" => "yaml", - "css" | "scss" | "sass" => "css", - "html" | "htm" => "html", - "lua" => "lua", - "sql" => "sql", - "proto" | "protobuf" => "proto", - "cmake" => "cmake", - "zig" => "zig", - "ex" | "exs" => "elixir", - "graphql" | "gql" => "graphql", - "diff" | "patch" => "diff", - "svelte" => "svelte", - "astro" => "astro", - "scala" => "scala", - _ => return None, - }) -} - -/// Whether a file path has a markdown extension. -pub(super) fn is_markdown_path(path: &str) -> bool { - Path::new(path) - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| { - matches!( - ext.to_ascii_lowercase().as_str(), - "md" | "markdown" | "mdown" | "mkdn" - ) - }) -} - -pub(super) struct ShareTargets { - /// NIP-19 `naddr1...` of the announcement, with its announced relays. - pub(super) naddr: String, - /// Hex ID of the announcement event itself. - pub(super) event_id: String, - /// NIP-34 coordinate `30617::`. - pub(super) coordinate: String, - /// `https://gitworkshop.dev/` - pub(super) gitworkshop: String, - /// `https://ditto.pub/` - pub(super) ditto: String, -} - -impl ShareTargets { - pub(super) fn from_announcement(announcement: &Announcement) -> Self { - let addr = announcement.addr(); - let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) - .to_bech32() - .expect("a complete coordinate always encodes to naddr"); - - Self { - naddr: naddr.clone(), - event_id: announcement.event_id.to_bech32().unwrap(), - coordinate, - gitworkshop: format!("https://gitworkshop.dev/{naddr}"), - ditto: format!("https://ditto.pub/{naddr}"), - } - } - - /// The share dropdown menu, one row per target. - /// - /// Each shows a compact label, the copy button and row click copy the full value. - pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu { - menu.min_w(px(340.)) - .item(menu_copy_row( - "copy-gitworkshop", - "GitWorkshop", - truncate_naddr_link(&self.gitworkshop, 4), - self.gitworkshop.clone(), - )) - .item(menu_copy_row( - "copy-ditto", - "Ditto", - truncate_naddr_link(&self.ditto, 4), - self.ditto.clone(), - )) - .item(menu_copy_row( - "copy-event-id", - "Event ID", - middle_truncate(&self.event_id, 10, 10), - self.event_id.clone(), - )) - .item(menu_copy_row( - "copy-coordinate", - "Coordinate", - middle_truncate(&self.coordinate, 10, 10), - self.coordinate.clone(), - )) - } -} - -/// 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(); - }; - if url.len() - end <= tail + 3 { - return url.to_string(); - } - format!("{}...{}", &url[..end], &url[url.len() - tail..]) -} - -/// Width of one line-number gutter in a diff row. -pub(super) const GUTTER_WIDTH: f32 = 44.; -/// Height of one row in a virtual diff list. -pub(super) const DIFF_ROW_HEIGHT: f32 = 20.; - -/// One row of a virtual diff list, a hunk header or a line of a hunk. -/// -/// Shared by the commit diff and pull request diff viewers. -#[derive(Clone, Copy)] -pub(super) enum DiffRow { - Hunk { - old_start: u32, - old_lines: u32, - new_start: u32, - new_lines: u32, - }, - /// Line `line` of hunk `hunk` of the selected file's diff. - Line { hunk: usize, line: usize }, -} - -/// The rows of `file`'s diff, one header row per hunk then its lines. -pub(super) fn diff_rows(file: &FileDiff) -> Vec { - let mut rows = Vec::new(); - for (hunk_ix, hunk) in file.hunks.iter().enumerate() { - rows.push(DiffRow::Hunk { - old_start: hunk.old_start, - old_lines: hunk.old_lines, - new_start: hunk.new_start, - new_lines: hunk.new_lines, - }); - rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line { - hunk: hunk_ix, - line, - })); - } - rows -} - -/// One row of the virtual diff list, a hunk header or a single line. -pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { - match row { - DiffRow::Hunk { - old_start, - old_lines, - new_start, - new_lines, - } => div() - .px_2() - .w_full() - .h(px(DIFF_ROW_HEIGHT)) - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .bg(cx.theme().muted) - .border_y(px(1.)) - .border_color(cx.theme().border) - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!( - "@@ -{},{} +{},{} @@", - old_start, old_lines, new_start, new_lines - ))) - .into_any_element(), - DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx), - } -} - -/// One diff line, old and new line numbers in the gutters. -/// -/// The content is tinted by kind, addition, deletion or context. -pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { - let bg = match line.kind { - DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)), - DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)), - DiffLineKind::Context => None, - }; - let gutter = cx.theme().muted_foreground; - - // Fixed height and nowrap, the virtual list assumes every row has the same height. - // Long lines are clipped instead of wrapped. - h_flex() - .w_full() - .h(px(DIFF_ROW_HEIGHT)) - .items_center() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .when_some(bg, |this, bg| this.bg(bg)) - .child( - div() - .w(px(GUTTER_WIDTH)) - .flex_none() - .pr_2() - .text_right() - .text_color(gutter) - .child(line.old.map(|n| n.to_string()).unwrap_or_default()), - ) - .child( - div() - .w(px(GUTTER_WIDTH)) - .flex_none() - .pr_2() - .text_right() - .text_color(gutter) - .child(line.new.map(|n| n.to_string()).unwrap_or_default()), - ) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_color(cx.theme().foreground) - .child(line.text.clone()), - ) - .into_any_element() -} - -/// Find a tree item by id, searching into nested children. -pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> { - let id = id?; - items.iter().find_map(|item| { - if item.id.as_ref() == id { - Some(item) - } else { - find_item(&item.children, Some(id)) - } - }) -} - -/// The root issue events of a repo store, for the shared detail sections. -pub(super) fn issue_roots(store: &RepoStore) -> &[Event] { - &store.issues -} - -/// The root pull request events of a repo store, for the shared detail sections. -pub(super) fn pr_roots(store: &RepoStore) -> &[Event] { - &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. -pub(super) fn ref_selector_trigger( - ctx: &ComboboxTriggerContext>, - icon: CustomIconName, - cx: &App, -) -> AnyElement { - let muted = cx.theme().muted_foreground; - - h_flex() - .w_full() - .min_w_0() - .gap_1() - .items_center() - .child(Icon::new(icon).small().flex_shrink_0()) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .when(ctx.selection().is_empty(), |this| this.text_color(muted)) - .child( - ctx.selection() - .first() - .map(|(_, item)| item.clone()) - .or_else(|| ctx.placeholder().cloned()) - .unwrap_or_default(), - ), - ) - .child(Caret::new(ctx.size()).text_color(muted)) - .into_any_element() -} - -/// Section heading of a detail sidebar, shared by the issue and PR panels. -pub(super) fn sidebar_title(text: &str, cx: &App) -> AnyElement { - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child(text.to_string()) - .into_any_element() -} - -/// Right sidebar with participants and labels of a root event, issue or PR. -pub(super) fn sidebar_section( - store: &Entity, - id: EventId, - roots: fn(&RepoStore) -> &[Event], - top_gap: bool, - cx: &App, -) -> AnyElement { - let store = store.read(cx); - let Some(root) = roots(store).iter().find(|event| event.id == id) else { - // The caller bails out when the root is missing. - return div().into_any_element(); - }; - let profile_store = ProfileStore::global(cx); - - // Participants, the root author plus everyone who commented. - let mut participants: Vec = vec![root.pubkey]; - participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey)); - participants.sort_by_key(PublicKey::to_hex); - participants.dedup(); - - // Labels are NIP-34 `t` hashtag tags on the event. - let labels: Vec = root.tags.hashtags().map(|tag| tag.to_string()).collect(); - - v_flex() - .w(px(240.)) - .h_full() - .flex_none() - .px_4() - .gap_4() - .border_l(px(1.)) - .border_color(cx.theme().sidebar_border) - .child( - v_flex() - .when(top_gap, |this| this.mt_4()) - .gap_2() - .child(sidebar_title("Participants", cx)) - .children(participants.iter().map(|pubkey| { - let profile = profile_store.read(cx).get(pubkey); - let name = profile.name(); - let picture = profile.picture(); - - h_flex() - .gap_1() - .items_center() - .child(UserAvatar::new(name.clone()).picture(picture)) - .child(div().text_sm().truncate().text_ellipsis().child(name)) - .into_any_element() - })), - ) - .child( - v_flex() - .gap_2() - .child(sidebar_title("Labels", cx)) - .map(|this| { - if labels.is_empty() { - this.child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("None yet."), - ) - } else { - this.child(h_flex().gap_1().children({ - let mut items = vec![]; - - for label in labels.iter() { - items.push( - Tag::secondary() - .outline() - .xsmall() - .child(SharedString::from(label)), - ); - } - - items - })) - } - }), - ) - .into_any_element() -} - -/// The comments on a root event, issue or PR, one card per comment. -pub(super) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { - let store = store.read(cx); - let comments: Vec<&Event> = store.comments_of(&root).collect(); - let title = SharedString::from(format!("Discussions {}", comments.len())); - - v_flex() - .gap_4() - .child(div().text_xs().font_semibold().child(title)) - .children(comments.iter().map(|comment| { - let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); - let author = profile.name(); - let picture = profile.picture(); - let age = relative_time(comment.created_at); - let content = SharedString::from(comment.content.as_str()); - - v_flex() - .gap_1() - .p_3() - .border_1() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .child( - h_flex() - .gap_2() - .text_sm() - .child( - h_flex() - .gap_1() - .child(UserAvatar::new(author.clone()).picture(picture)) - .child(author), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child("commented"), - ) - .child( - div() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(age)), - ), - ) - .child(div().text_sm().child(content)) - })) - .into_any_element() -} - -/// The comment form posting to an issue or PR root event. -/// -/// `roots` selects the root's list within the store, issues or pull requests. -pub(super) fn comment_form( - store: &Entity, - root: EventId, - roots: fn(&RepoStore) -> &[Event], - comment_input: &Entity, - button_id: &'static str, - cx: &App, -) -> AnyElement { - let comment_input = comment_input.clone(); - let store = store.clone(); - - v_flex() - .gap_2() - .child( - Textarea::new(&comment_input) - .h_24() - .text_color(cx.theme().muted_foreground) - .bg(cx.theme().muted), - ) - .child( - h_flex() - .justify_between() - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(CustomIconName::Markdown).small()) - .child("Markdown is supported"), - ) - .child( - Button::new(button_id) - .primary() - .label("Comment") - .tooltip("Post comment") - .on_click(move |_event, window, cx| { - let content = comment_input.read(cx).value().trim().to_string(); - if content.is_empty() { - return; - } - let Some(root) = roots(store.read(cx)) - .iter() - .find(|event| event.id == root) - .cloned() - else { - return; - }; - store.update(cx, |store, cx| { - store.comment(&root, content, cx); - }); - comment_input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - }), - ), - ) - .into_any_element() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builds_nested_tree_from_flat_entries() { - let entries = vec![ - PathBuf::from("src"), - PathBuf::from("src/lib.rs"), - PathBuf::from("README.md"), - PathBuf::from("docs/guide.md"), - ]; - - let items = build_tree_items(&entries); - - // Input order is preserved, dirs-first as produced by worktree_entries. - assert_eq!(items.len(), 3); - assert_eq!(items[0].label, "src"); - assert_eq!(items[0].id, "src"); - assert_eq!(items[0].children.len(), 1); - assert_eq!(items[0].children[0].label, "lib.rs"); - assert_eq!(items[0].children[0].id, "src/lib.rs"); - - assert_eq!(items[1].label, "README.md"); - assert_eq!(items[1].id, "README.md"); - - assert_eq!(items[2].label, "docs"); - assert_eq!(items[2].children[0].label, "guide.md"); - assert_eq!(items[2].children[0].id, "docs/guide.md"); - } - - #[test] - fn tree_builder_handles_deep_nesting() { - let entries = vec![ - PathBuf::from("a"), - PathBuf::from("a/b"), - PathBuf::from("a/b/c.txt"), - ]; - - let items = build_tree_items(&entries); - assert_eq!(items.len(), 1); - assert_eq!(items[0].children[0].id, "a/b"); - assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); - } - - #[test] - fn tree_builder_merges_shared_prefixes() { - // File children of a directory arrive after other directories' entries. - // The worktree list is dirs-first globally. - // The shared prefix must still resolve to one node. - let entries = vec![ - PathBuf::from("a/x.txt"), - PathBuf::from("b/y.txt"), - PathBuf::from("a/z.txt"), - ]; - - let items = build_tree_items(&entries); - assert_eq!(items.len(), 2); - assert_eq!(items[0].label, "a"); - assert_eq!(items[0].children.len(), 2); - assert_eq!(items[1].label, "b"); - } - - #[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_list.rs b/crates/workspace/src/views/repo_list.rs index 126669a..ed61ab1 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -1,3 +1,4 @@ +use std::fmt::Display; use std::rc::Rc; use assets::CustomIconName; @@ -5,7 +6,7 @@ use dock::{BasePanel, DockArea, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, - SharedString, Size, Subscription, WeakEntity, Window, div, px, size, + SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size, }; use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::scroll::Scrollbar; @@ -23,21 +24,32 @@ 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, } +impl AsRef for RepoFilter { + fn as_ref(&self) -> &str { + match self { + RepoFilter::All => "all", + RepoFilter::Popular => "popular", + RepoFilter::Recent => "recent", + } + } +} + +impl Display for RepoFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_ref()) + } +} + impl RepoFilter { /// Indices into the store's `announcements` this filter includes, in display order. /// @@ -49,6 +61,7 @@ impl RepoFilter { // Narrow by the search query first. // Recent then limits the matches and Popular ranks them. let query = query.trim().to_lowercase(); + if !query.is_empty() { indices.retain(|&ix| { let announcement = &announcements[ix]; @@ -82,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, } @@ -111,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) { @@ -125,7 +132,11 @@ impl RepoListView { this.rebuild_rows(cx); }); - let mut this = Self { + cx.defer_in(window, |this, _window, cx| { + this.rebuild_rows(cx); + }); + + Self { store, dock_area, focus_handle: cx.focus_handle(), @@ -137,27 +148,19 @@ impl RepoListView { search, _search_subscription: search_subscription, _subscription: subscription, - }; - - // Seed the rows right away. - // The store may already hold announcements from before the panel opened. - // The first render must not depend on a later store update. - this.rebuild_rows(cx); - - this + } } - /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the store. - /// - /// 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(); let store = self.store.read(cx); + self.visible = filter.visible(store, &query); // Each virtual list row holds `COLUMNS` repo cards. let rows = self.visible.len().div_ceil(COLUMNS); + if self.repo_len != rows { self.repo_len = rows; self.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]); @@ -172,7 +175,13 @@ impl RepoListView { window: &mut Window, cx: &mut Context, ) { - open_repo_panel(&self.dock_area, announcement, window, &mut *cx); + open_repo_panel( + &self.dock_area, + &announcement.addr(), + Some(announcement), + window, + cx, + ); } fn render_card( @@ -308,6 +317,22 @@ impl RepoListView { .into_any_element() } + fn render_filter(&self, filter: RepoFilter, label: T, cx: &mut Context) -> AnyElement + where + T: Into, + { + let active = self.filter == filter; + + SegmentButton::new(filter.to_string(), label) + .icon(Icon::new(filter.icon_name())) + .selected(active) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.filter = filter; + this.rebuild_rows(cx); + })) + .into_any_element() + } + fn render_header(&self, count: usize, cx: &mut Context) -> AnyElement { h_flex() .px_4() @@ -315,18 +340,24 @@ impl RepoListView { .w_full() .gap_3() .child( - h_flex() - .gap_1() - .text_xs() - .child(div().font_semibold().child("Repositories")) + v_flex() + .gap_0p5() .child( div() - .w_10() .min_w_0() .truncate() .text_ellipsis() + .font_semibold() + .text_xs() + .line_height(relative(1.2)) + .child("Repositories"), + ) + .child( + div() + .text_size(px(10.)) .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!("({count})"))), + .line_height(relative(1.2)) + .child(SharedString::from(format!("Total: {count}"))), ), ) .child( @@ -342,31 +373,12 @@ impl RepoListView { .child( h_flex() .gap_1() - .child(self.filter_button(RepoFilter::All, "All", cx)) - .child(self.filter_button(RepoFilter::Popular, "Popular", cx)) - .child(self.filter_button(RepoFilter::Recent, "Recent", cx)), + .child(self.render_filter(RepoFilter::All, "All", cx)) + .child(self.render_filter(RepoFilter::Popular, "Popular", cx)) + .child(self.render_filter(RepoFilter::Recent, "Recent", cx)), ) .into_any_element() } - - /// One segmented header filter button, like the issues list's status filter buttons. - fn filter_button( - &self, - filter: RepoFilter, - label: &'static str, - cx: &mut Context, - ) -> AnyElement { - let active = self.filter == filter; - - SegmentButton::new(label, label) - .icon(Icon::new(filter.icon_name())) - .selected(active) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.filter = filter; - this.rebuild_rows(cx); - })) - .into_any_element() - } } impl BasePanel for RepoListView { diff --git a/crates/workspace/src/views/repo_detail/send_patch.rs b/crates/workspace/src/views/send_patch.rs similarity index 95% rename from crates/workspace/src/views/repo_detail/send_patch.rs rename to crates/workspace/src/views/send_patch.rs index 719d02c..ff5958e 100644 --- a/crates/workspace/src/views/repo_detail/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 a4723f0..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,12 +246,17 @@ fn create_repository( .detach(); } -/// Open the newly created repository in the dock's center. fn open_repo( dock_area: WeakEntity, announcement: Announcement, window: &mut Window, cx: &mut App, ) { - open_repo_panel(&dock_area, &announcement, window, cx); + open_repo_panel( + &dock_area, + &announcement.addr(), + Some(&announcement), + window, + cx, + ); } diff --git a/crates/workspace/src/views/sidebar/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 303471f..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,14 +157,13 @@ 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).read(cx); + let checkouts = CheckoutsStore::global(cx); let mut unpushed = HashMap::with_capacity(self.announcements.len()); for announcement in self.announcements.iter() { let addr = announcement.addr(); - let count = checkouts.unpushed(&addr); + let count = checkouts.read(cx).unpushed(&addr); if count > 0 { unpushed.insert(addr, count); } @@ -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; @@ -202,12 +194,13 @@ impl SidebarPanel { let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx)); self.inbox = Some(panel.downgrade()); - let _ = self.dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); + self.dock_area + .update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }) + .ok(); } - /// Open the Explore repository list panel in the dock area's center. pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { if self .explore @@ -221,12 +214,13 @@ impl SidebarPanel { let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx)); self.explore = Some(panel.downgrade()); - let _ = self.dock_area.update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(panel), window, cx); - }); + self.dock_area + .update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(panel), window, cx); + }) + .ok(); } - /// Show the Onboarding dialog. 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| { @@ -244,23 +238,25 @@ 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, window: &mut Window, cx: &mut Context, ) { - open_repo_panel(&self.dock_area, announcement, window, &mut *cx); + open_repo_panel( + &self.dock_area, + &announcement.addr(), + Some(announcement), + window, + &mut *cx, + ); } - /// Open a local repository's detail view in the dock's center. - /// /// 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 = @@ -324,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 { @@ -364,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], @@ -393,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()) @@ -423,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() @@ -445,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, @@ -480,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() @@ -599,9 +591,9 @@ impl Render for SidebarPanel { } v_flex() + .image_cache(gpui::retain_all("sidebar")) .size_full() .justify_between() - .image_cache(gpui::retain_all("sidebar")) .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .child( 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/views/tree.rs b/crates/workspace/src/views/tree.rs new file mode 100644 index 0000000..8cc385d --- /dev/null +++ b/crates/workspace/src/views/tree.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use gpui_component::tree::TreeItem; + +pub(crate) struct TreeItemSeed { + /// Path of the node, relative to the worktree root. + pub(crate) id: String, + pub(crate) label: String, + pub(crate) children: Vec, +} + +pub(crate) fn tree_items(seeds: Vec, expand_folders: bool) -> Vec { + fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem { + let mut item = TreeItem::new(seed.id, seed.label); + if expand_folders && !seed.children.is_empty() { + item = item.expanded(true); + } + item.children = seed + .children + .into_iter() + .map(|seed| convert(seed, expand_folders)) + .collect(); + item + } + + seeds + .into_iter() + .map(|seed| convert(seed, expand_folders)) + .collect() +} + +/// Build nested tree items from a flat entry list sorted dirs-first. +pub(crate) fn build_tree_items(entries: &[PathBuf]) -> Vec { + // Node indices by full path, so parents resolve in constant time while inserting. + let mut index: HashMap = HashMap::new(); + let mut nodes: Vec<(String, String, Vec)> = Vec::new(); + let mut roots: Vec = Vec::new(); + + for entry in entries { + let mut parent: Option = None; + let mut path = String::new(); + for part in entry.components() { + let label = part.as_os_str().to_string_lossy().into_owned(); + path = if path.is_empty() { + label.clone() + } else { + format!("{path}/{label}") + }; + let ix = *index.entry(path.clone()).or_insert_with(|| { + let ix = nodes.len(); + nodes.push((path.clone(), label.clone(), Vec::new())); + match parent { + Some(parent) => nodes[parent].2.push(ix), + None => roots.push(ix), + } + ix + }); + parent = Some(ix); + } + } + + fn assemble(ix: usize, nodes: &[(String, String, Vec)]) -> TreeItemSeed { + let (id, label, children) = &nodes[ix]; + TreeItemSeed { + id: id.clone(), + label: label.clone(), + children: children + .iter() + .map(|child| assemble(*child, nodes)) + .collect(), + } + } + + roots.iter().map(|root| assemble(*root, &nodes)).collect() +} + +/// Sorted relative paths of a worktree snapshot. +/// +/// Compared against the `worktree_paths` of a repository panel to skip +/// rebuilding the explorer when a refresh left the worktree unchanged. +pub(crate) fn sorted_worktree_paths(entries: &[PathBuf]) -> Vec { + let mut paths: Vec = entries + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + paths.sort(); + paths +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_nested_tree_from_flat_entries() { + let entries = vec![ + PathBuf::from("src"), + PathBuf::from("src/lib.rs"), + PathBuf::from("README.md"), + PathBuf::from("docs/guide.md"), + ]; + + let items = build_tree_items(&entries); + + // Input order is preserved, dirs-first as produced by worktree_entries. + assert_eq!(items.len(), 3); + assert_eq!(items[0].label, "src"); + assert_eq!(items[0].id, "src"); + assert_eq!(items[0].children.len(), 1); + assert_eq!(items[0].children[0].label, "lib.rs"); + assert_eq!(items[0].children[0].id, "src/lib.rs"); + + assert_eq!(items[1].label, "README.md"); + assert_eq!(items[1].id, "README.md"); + + assert_eq!(items[2].label, "docs"); + assert_eq!(items[2].children[0].label, "guide.md"); + assert_eq!(items[2].children[0].id, "docs/guide.md"); + } + + #[test] + fn tree_builder_handles_deep_nesting() { + let entries = vec![ + PathBuf::from("a"), + PathBuf::from("a/b"), + PathBuf::from("a/b/c.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 1); + assert_eq!(items[0].children[0].id, "a/b"); + assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); + } + + #[test] + fn tree_builder_merges_shared_prefixes() { + // File children of a directory arrive after other directories' entries. + // The worktree list is dirs-first globally. + // The shared prefix must still resolve to one node. + let entries = vec![ + PathBuf::from("a/x.txt"), + PathBuf::from("b/y.txt"), + PathBuf::from("a/z.txt"), + ]; + + let items = build_tree_items(&entries); + assert_eq!(items.len(), 2); + assert_eq!(items[0].label, "a"); + assert_eq!(items[0].children.len(), 2); + assert_eq!(items[1].label, "b"); + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 05df0c0..825b873 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,28 +1,22 @@ use dock::{DockArea, DockEvent, DockLayout, DockPlacement, SignedDockSkin, panel_handle}; use gpui::prelude::*; -use gpui::{Context, Entity, KeyBinding, Render, Subscription, Window, actions, div, px}; +use gpui::{Context, Entity, Render, Subscription, Window, div, px}; use gpui_component::{Root, StyledExt, Theme}; -use gpui_fps::{FpsMonitor, FpsOverlay}; +use settings::{AppearanceMode, SettingsStore}; use signed_state::{Backend, BackendEvent}; use crate::views::SidebarPanel; use crate::views::sidebar::passphrase_dialog; -actions!(workspace, [ToggleMonitor]); - pub struct Workspace { dock: Entity, - fps: Entity, - /// Debug HUD, toggled with `cmd-shift-f`. - show_fps: bool, _subscriptions: Vec, - _passphrase_subscription: Subscription, } impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let fps = cx.new(|cx| FpsMonitor::new(window, cx).continuous(false)); - cx.bind_keys([KeyBinding::new("cmd-shift-f", ToggleMonitor, None)]); + let backend = Backend::global(cx); + let settings = SettingsStore::global(cx); let dock = cx.new(|cx| { let skin = SignedDockSkin::new(cx); @@ -33,20 +27,15 @@ impl Workspace { let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); let weak_sidebar = sidebar.downgrade(); - dock.update(cx, |dock_area, cx| { - dock_area.set_dock( - DockPlacement::Left, - DockLayout::tabs().panel_view(panel_handle(sidebar), cx), - window, - cx, - ); - dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx); - }); - let mut subscriptions = vec![]; + if settings.read(cx).settings().appearance == AppearanceMode::System { + subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| { + Theme::sync_system_appearance(Some(window), cx); + })); + } + // A bottom or right dock whose last panel was dragged away is removed entirely. - let dock_for_pruning = dock.clone(); subscriptions.push(cx.subscribe_in( &dock, window, @@ -54,9 +43,9 @@ impl Workspace { if !matches!(event, DockEvent::LayoutChanged) { return; } - let dock = dock_for_pruning.clone(); + let weak = weak_dock.clone(); cx.spawn_in(window, async move |_, window| { - dock.update_in(window, |area, window, cx| { + weak.update_in(window, |area, window, cx| { for placement in [DockPlacement::Bottom, DockPlacement::Right] { if area.is_empty(placement, cx) { area.remove_dock(placement, window, cx); @@ -69,28 +58,34 @@ impl Workspace { }, )); - subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| { - Theme::sync_system_appearance(Some(window), cx); - })); - - let backend = Backend::global(cx); - // Ask for the passphrase when the stored identity is NIP-49 encrypted. - let passphrase_subscription = - window.subscribe(&backend, cx, |_backend, event, window, cx| { + subscriptions.push(cx.subscribe_in( + &backend, + window, + |_this, _state, event, window, cx| { if matches!(event, BackendEvent::PassphraseRequired) { passphrase_dialog::open(window, cx); } + }, + )); + + cx.defer_in(window, move |this, window, cx| { + // The event may have fired before this window existed. + // Fall back to the backend state in that case. + if backend.read(cx).passphrase_required() { + passphrase_dialog::open(window, cx); + } + + this.dock.update(cx, |dock_area, cx| { + dock_area.set_dock( + DockPlacement::Left, + DockLayout::tabs().panel_view(panel_handle(sidebar), cx), + window, + cx, + ); + dock_area.set_dock_size(DockPlacement::Left, px(240.), window, cx); }); - // The event may have fired before this window existed. - // Fall back to the backend state in that case. - if backend.read(cx).passphrase_required() { - passphrase_dialog::open(window, cx); - } - - // Open the explore panel after the sidebar has been initialized. - cx.defer_in(window, move |_, window, cx| { weak_sidebar .update(cx, |this, cx| { this.open_explore(window, cx); @@ -100,10 +95,7 @@ impl Workspace { Self { dock, - show_fps: cfg!(debug_assertions), - fps, _subscriptions: subscriptions, - _passphrase_subscription: passphrase_subscription, } } } @@ -115,21 +107,11 @@ impl Render for Workspace { div() .id("workspace") - .on_action( - cx.listener(|this: &mut Self, _ev: &ToggleMonitor, _window, cx| { - this.show_fps = !this.show_fps; - cx.notify(); - }), - ) .v_flex() .size_full() .relative() .child(self.dock.clone()) - // Notifications .children(notification_layer) - // Modals .children(dialog_layer) - // On top of everything, so it stays readable while debugging. - .when(self.show_fps, |this| this.child(FpsOverlay::new(&self.fps))) } } 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.