diff --git a/crates/signed_git/src/nip34.rs b/crates/signed_git/src/nip34.rs index cd7ebc7..bf58ac2 100644 --- a/crates/signed_git/src/nip34.rs +++ b/crates/signed_git/src/nip34.rs @@ -36,7 +36,7 @@ impl GraspSignals { } /// What a local repository's on-disk state says about its NIP-34 binding. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Nip34Binding { pub kind: Nip34Kind, pub signals: GraspSignals, diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 297578f..323577f 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -16,7 +16,7 @@ use git_store::set_git_cache; pub use git_store::{ensure_repo_mirror, open_repo_mirror, repo_mirror_path}; use gpui::{App, AppContext}; pub use inbox::{Inbox, query_inbox}; -pub use local_repos::{LocalReposStore, local_repo_addr}; +pub use local_repos::{LocalReposStore, ResolvedLocalRepo, local_repo_addr, resolve_local_repos}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; pub use refresh::{RefreshGate, RefreshRequest}; diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 85891a3..ac2607d 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -1,10 +1,11 @@ +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, Task}; -use signed_core::{RepoAddr, repo_addr}; -use signed_git::{LocalRepo, find_git_repos}; +use signed_core::{Announcement, RepoAddr, repo_addr}; +use signed_git::{LocalRepo, Nip34Binding, find_git_repos}; struct GlobalLocalReposStore(Entity); @@ -114,3 +115,142 @@ pub fn local_repo_addr(repo: &LocalRepo) -> Option { Some(repo_addr(owner, identifier)) } + +/// A scanned repository resolved against the known announcements. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedLocalRepo { + pub path: PathBuf, + /// `None` for a plain repository. + pub nip34: Option, + /// The known announcement this repository is bound to, when one matched. + pub announcement: Option, +} + +/// Resolve the scanned repositories against the known announcements. +pub fn resolve_local_repos( + repos: &[LocalRepo], + known: &[Announcement], + own: &[Announcement], +) -> Vec { + let shown: HashSet = own.iter().map(Announcement::addr).collect(); + + repos + .iter() + .filter_map(|repo| { + let addr = local_repo_addr(repo); + + if let Some(addr) = &addr + && shown.contains(addr) + { + return None; + } + + let announcement = addr + .as_ref() + .and_then(|addr| { + known + .iter() + .find(|announcement| announcement.addr() == *addr) + }) + .cloned(); + + Some(ResolvedLocalRepo { + path: repo.path.clone(), + nip34: repo.nip34.clone(), + announcement, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use nostr::prelude::*; + use signed_git::{GraspSignals, Nip34Kind}; + + use super::*; + + const KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const OTHER_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000002"; + + fn announcement(secret: &str, id: &str) -> Announcement { + let keys = Keys::new(SecretKey::from_hex(secret).expect("secret")); + let tag = Tag::parse(vec!["d", id]).expect("tag"); + let event = EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(vec![tag]) + .finalize(&keys) + .expect("signed"); + + Announcement::from_event(&event).expect("parsed") + } + + fn owner(secret: &str) -> PublicKey { + Keys::new(SecretKey::from_hex(secret).expect("secret")).public_key() + } + + fn bound(secret: &str, id: &str) -> LocalRepo { + let binding = Nip34Binding { + kind: Nip34Kind::Initialized, + signals: GraspSignals { + nip34_json: true, + ..Default::default() + }, + owner: Some(owner(secret)), + identifier: Some(id.to_owned()), + grasp_urls: Vec::new(), + }; + + LocalRepo { + path: PathBuf::from(id), + nip34: Some(binding), + } + } + + #[test] + fn the_users_own_announcement_is_dropped() { + let own = announcement(KEY, "mine"); + let repo = bound(KEY, "mine"); + + let own = std::slice::from_ref(&own); + assert!(resolve_local_repos(&[repo], own, own).is_empty()); + } + + #[test] + fn another_owners_announcement_is_linked_and_kept() { + let known = announcement(OTHER_KEY, "theirs"); + let repo = bound(OTHER_KEY, "theirs"); + + let resolved = resolve_local_repos(&[repo], std::slice::from_ref(&known), &[]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].announcement.as_ref(), Some(&known)); + } + + #[test] + fn an_unmatched_repository_keeps_its_binding() { + let repo = bound(KEY, "unlisted"); + + let resolved = resolve_local_repos(&[repo], &[], &[]); + + assert_eq!(resolved.len(), 1); + assert!(resolved[0].announcement.is_none()); + assert_eq!( + resolved[0].nip34.as_ref().map(|binding| binding.kind), + Some(Nip34Kind::Initialized) + ); + } + + #[test] + fn a_plain_repository_is_kept_without_a_binding() { + let repo = LocalRepo { + path: PathBuf::from("plain"), + nip34: None, + }; + + let resolved = resolve_local_repos(&[repo], &[], &[]); + + assert_eq!(resolved.len(), 1); + assert!(resolved[0].nip34.is_none()); + assert!(resolved[0].announcement.is_none()); + } +} diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 6d5c063..8dc7b99 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,6 +1,6 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::ops::Range; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -10,7 +10,7 @@ use dock::{ }; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, + AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, Hsla, ObjectFit, Render, SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white, }; use gpui_base::Button as BaseButton; @@ -18,9 +18,10 @@ use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::InputState; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use nostr::prelude::RelayUrl; -use signed_core::{Announcement, RepoAddr, identifier_from_name}; +use signed_core::{Announcement, RepoAddr}; use signed_state::{ - Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, + Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, + ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos, }; use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; @@ -35,18 +36,31 @@ mod settings_dialog; use self::onboarding_dialog::OnboardingState; +/// The tool that bound a repository, `"nak"` or `"ngit"`. +fn local_tool(binding: &Nip34Binding) -> Option<&'static str> { + let signals = &binding.signals; + + if signals.nip34_json || signals.nip34_grasp_remote || signals.nip34_state_refs { + Some("nak") + } else if signals.nostr_repo_config { + Some("ngit") + } else { + None + } +} + pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, inbox: Option>, explore: Option>, banner: SharedString, - /// The signed-in user's announced repositories, newest first. + /// User's announced repositories. announcements: Arc>, /// Local repositories found by the scan that are not announced yet. - local_repos: Arc>, + local_repos: Arc>, scanning: bool, - /// Unpushed commit counts per announced repository, shown as row badges. + /// Unpushed commit counts per announced repository. unpushed: HashMap, _subscriptions: Vec, } @@ -116,30 +130,21 @@ impl SidebarPanel { let backend = Backend::global(cx); let user = backend.read(cx).current_user(); - let repo_list = RepoListStore::global(cx); - let announcements = user - .as_ref() - .map(|user| repo_list.read(cx).announcements_of(user)) - .unwrap_or_default(); + let (announcements, local_repos, scanning) = { + let repo_list = RepoListStore::global(cx); + let repo_list = repo_list.read(cx); - // 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; + let announcements = user + .as_ref() + .map(|user| repo_list.announcements_of(user)) + .unwrap_or_default(); - let local_repos = { - let ids: HashSet = announcements.iter().map(|a| a.id.clone()).collect(); - local - .read(cx) - .repos - .iter() - .filter(|repo| { - let Some(name) = repo.path.file_name() else { - return true; - }; - !ids.contains(&identifier_from_name(&name.to_string_lossy())) - }) - .map(|repo| repo.path.clone()) - .collect() + let local = LocalReposStore::global(cx); + let local = local.read(cx); + let local_repos = + resolve_local_repos(&local.repos, &repo_list.announcements, &announcements); + + (announcements, local_repos, local.scanning) }; let announcements_changed = *self.announcements != announcements; @@ -270,6 +275,22 @@ impl SidebarPanel { .ok(); } + /// A local repository opens as the announced repository when its binding matches one, + /// and as a local-only repository otherwise. + fn open_local_entry( + &mut self, + entry: ResolvedLocalRepo, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(announcement) = entry.announcement { + self.open_repo(&announcement, window, cx); + return; + } + + self.open_local_repo(entry.path, window, cx); + } + fn render_repos(&self, cx: &mut Context) -> impl IntoElement { let announcements = self.announcements.clone(); let local_repos = self.local_repos.clone(); @@ -361,11 +382,10 @@ impl SidebarPanel { }) } - /// Renders row `ix` of the merged list: an announced repository or a local one. fn render_repo_at( &self, announcements: &[Announcement], - local_repos: &[PathBuf], + local_repos: &[ResolvedLocalRepo], ix: usize, cx: &mut Context, ) -> AnyElement { @@ -376,9 +396,9 @@ impl SidebarPanel { } let local_ix = ix - announcements.len(); - let path = &local_repos[local_ix]; + let entry = &local_repos[local_ix]; - self.render_local_row(path, cx).into_any_element() + self.render_local_row(entry, cx).into_any_element() } fn render_repo_row( @@ -419,23 +439,43 @@ impl SidebarPanel { ) } - /// 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 + fn render_local_row( + &self, + entry: &ResolvedLocalRepo, + cx: &mut Context, + ) -> impl IntoElement { + let name = entry + .path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or("Untitled".into()); - let path = path.to_path_buf(); - let avatar = PixelAvatar::new(path.to_string_lossy()); + let avatar = PixelAvatar::new(entry.path.to_string_lossy()); - NavItem::new(format!("local-repo:{}", path.display()), name, avatar) - .suffix( - Icon::new(IconName::TriangleAlert) - .small() - .text_color(cx.theme().warning), - ) + let suffix: AnyElement = match entry.nip34.as_ref().map(|binding| binding.kind) { + Some(Nip34Kind::Initialized) => { + let label = match entry.nip34.as_ref().and_then(local_tool) { + Some(tool) => format!("NIP-34 · {tool}"), + None => "NIP-34".to_owned(), + }; + local_badge(&label, cx.theme().muted_foreground) + } + Some(Nip34Kind::Cloned) => local_badge("NIP-34 clone", cx.theme().muted_foreground), + Some(Nip34Kind::ToolingOnly) => { + local_badge("Nostr tooling", cx.theme().muted_foreground) + } + None => Icon::new(IconName::TriangleAlert) + .small() + .text_color(cx.theme().warning) + .into_any_element(), + }; + + let id = format!("local-repo:{}", entry.path.display()); + let entry = entry.clone(); + + NavItem::new(id, name, avatar) + .suffix(suffix) .on_click(cx.listener(move |this, _ev, window, cx| { - this.open_local_repo(path.clone(), window, cx); + this.open_local_entry(entry.clone(), window, cx); })) } @@ -563,6 +603,15 @@ pub(super) fn server_host(relay: &RelayUrl) -> SharedString { .unwrap_or_else(|| SharedString::from(relay.to_string())) } +fn local_badge(label: &str, color: Hsla) -> AnyElement { + div() + .flex_shrink_0() + .text_xs() + .text_color(color) + .child(SharedString::from(label.to_owned())) + .into_any_element() +} + fn pick_banner() -> SharedString { let num = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/docs/local-repo-nip34-detection.md b/docs/local-repo-nip34-detection.md index 7c7f71f..388aa55 100644 --- a/docs/local-repo-nip34-detection.md +++ b/docs/local-repo-nip34-detection.md @@ -1,6 +1,6 @@ # Local repository NIP-34 detection -Status: Phases 1–2 implemented. Phases 3–5 pending. +Status: Phases 1–3 implemented. Phases 4–5 pending. ## Motivation @@ -212,28 +212,31 @@ Implemented. - [x] `crates/workspace/src/views/sidebar/mod.rs`: minimal adaptation of the existing folder-name dedupe to the new entry type so the tree compiles; the badge/dedupe rewrite is Phase 3. -### Phase 3 — sidebar: derive the local list and link to announcements +### Phase 3 — derive the local list and link it to announcements -File: `crates/workspace/src/views/sidebar/mod.rs`. +Implemented. The matching lives in `signed_state` (per the data-model note above), so the sidebar +stays a thin renderer. -- [ ] Replace the folder-name dedupe in `refresh` with a resolution pass over - `LocalReposStore::global(cx).read(cx).repos`: - - For each entry, compute the detected `RepoAddr` when the binding is `Initialized` with - owner + identifier. - - Look the address up in `RepoListStore` announcements. - - If it matches an announcement **already shown** in the sidebar's list (the signed-in - user's own), drop it from the local list to avoid showing it twice. - - If it matches an announcement that is not in the shown list, keep it as a local entry - rendered with the "initialized" badge; clicking opens the announced repository (decision 1). - - Otherwise keep it with a badge derived from the binding kind. -- [ ] Extend the sidebar's local entry model from `Vec` to a small struct carrying the - path, the binding kind, the tool label and the resolved address, so `render_repo_at` can - render the badge and route clicks. -- [ ] Add the badge rendering to the local row in `render_repo_at` (three visible states): +- [x] `crates/signed_state/src/local_repos.rs`: `ResolvedLocalRepo` and + `resolve_local_repos(repos, known, own)` resolve each scanned repository's `RepoAddr` against + the known announcements: + - a binding matching one of the user's **own** announcements is dropped, since it is already + listed as an announcement row; + - a binding matching any other known announcement is kept and linked to it; + - every other repository is kept unchanged. +- [x] `crates/signed_git/src/nip34.rs`: `Nip34Binding` derives `PartialEq` so the sidebar can + detect changes to the resolved list. +- [x] `crates/workspace/src/views/sidebar/mod.rs`: the local list is `Arc>`; + `refresh` calls `resolve_local_repos` instead of the folder-name dedupe. A matched entry's + click opens the announced repository, the rest open the local detail view. +- [x] Badge rendering in `render_local_row`, derived from `Nip34Binding::signals`: - `Initialized` → "NIP-34 · nak" / "NIP-34 · ngit" - `Cloned` → "NIP-34 clone" (decision 2, its own visible state) - `ToolingOnly` → "Nostr tooling" (muted) - - plain → unchanged. + - plain → unchanged warning icon. + +Note: the announced-open path currently opens the announced repository without the local worktree; +attaching it is Phase 4. ### Phase 4 — detail view: open as announced, gate the publish CTA @@ -295,14 +298,15 @@ Unit tests in `crates/signed_git/src/nip34.rs` (use `tempfile` and a small local `https://host/repo.git` (no owner), a first segment that is not a valid `npub`, and an unrelated scheme such as `ssh://`. -Integration test in `crates/signed_state`: +Integration tests in `crates/signed_state/src/local_repos.rs` (plain `#[test]`, no GPUI harness): -- A scanned repository whose binding resolves to a `RepoAddr` that matches a seeded announcement is - deduped out of the local list when it is the user's own; one with no matching announcement - remains and is labelled. +- a repository bound to the user's own announcement is dropped from the local list; +- one bound to another owner's announcement is kept and linked to it; +- one with an unmatched binding is kept with its binding and no announcement; +- a plain repository is kept with no binding and no announcement. -Final verification: `cargo check -p signed_git -p signed_state -p workspace` and -`cargo test -p signed_git`. +Final verification: `cargo clippy -p signed_git -p signed_state -p workspace --all-targets` and +`cargo test -p signed_git -p signed_state`. ## Out of scope