diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 8ad9539..05c9a86 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -11,6 +11,7 @@ use signed_core::{ Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch, pull_request_patches, }; +use signed_git::Nip34Binding; use crate::backend::{ Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted, @@ -39,6 +40,8 @@ pub struct RepoStore { /// 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, + /// NIP-34 state detected on disk for a local repository, if any. + pub nip34: Option, /// The first local pass has been applied. /// /// Views distinguish "no data yet" from a genuinely empty repository with it. @@ -112,6 +115,7 @@ impl RepoStore { addr: Some(addr), announcement: hint, path: None, + nip34: None, loaded: false, head: None, issues: Vec::new(), @@ -134,11 +138,12 @@ impl RepoStore { } /// Local repository discovered by the scan, not announced to NIP-34 yet. - pub fn new_local(path: PathBuf) -> Self { + pub fn new_local(path: PathBuf, nip34: Option) -> Self { Self { addr: None, announcement: None, path: Some(path), + nip34, loaded: true, head: None, issues: Vec::new(), @@ -160,6 +165,18 @@ impl RepoStore { } } + /// An announced repository whose working copy is already on disk. + pub fn from_worktree( + addr: RepoAddr, + announcement: Announcement, + path: PathBuf, + cx: &mut Context, + ) -> Self { + let mut store = Self::new(addr, Some(announcement), cx); + store.path = Some(path); + store + } + /// 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()); diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 747ca4d..fe61c68 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -26,8 +26,9 @@ use nostr::prelude::{RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_git::FileCommit; use signed_state::{ - Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, ProfileStore, RepoListStore, - RepoStore, ensure_repo_mirror, open_repo_mirror, pr_proposes_checkout, + Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, + ProfileStore, RepoListStore, RepoStore, ensure_repo_mirror, open_repo_mirror, + pr_proposes_checkout, }; use signed_ui::{ CountBadge, DropdownButton, PixelAvatar, UserAvatar, copy_row, menu_copy_row, middle_truncate, @@ -123,10 +124,26 @@ impl RepoDetailView { pub fn new_local( dock_area: WeakEntity, local_path: PathBuf, + nip34: Option, window: &mut Window, cx: &mut Context, ) -> Self { - let store = cx.new(move |_cx| RepoStore::new_local(local_path)); + let store = cx.new(move |_cx| RepoStore::new_local(local_path, nip34)); + Self::new_common(dock_area, store, window, cx) + } + + /// A local repository whose detected binding matches an announcement. + /// + /// Opens as the announced repository with the local worktree attached. + pub fn new_local_announced( + dock_area: WeakEntity, + announcement: Announcement, + local_path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let addr = announcement.addr(); + let store = cx.new(move |cx| RepoStore::from_worktree(addr, announcement, local_path, cx)); Self::new_common(dock_area, store, window, cx) } @@ -294,24 +311,17 @@ impl RepoDetailView { self.error = None; cx.notify(); - let (addr, announcement, local_path) = { + let (announcement, local_path) = { let store = self.store.read(cx); - ( - store.addr().cloned(), - store.announcement.clone(), - store.path.clone(), - ) + (store.announcement.clone(), store.path.clone()) }; - // Local repositories live on disk at their scan path. - // No clone step or network refresh applies here. - if addr.is_none() { + // A repository with a local worktree shows it directly. An announced one + // still loads its announcement and activity from the store, which is + // subscribed to the relays independently. + if let Some(local_path) = local_path { 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 { @@ -1254,6 +1264,24 @@ impl RepoDetailView { .unwrap_or_default(); let avatar = PixelAvatar::new(path.clone()); + // A repository already bound to a coordinate is not offered for publishing again. + let bound = self.store.read(cx).nip34.clone(); + let action = match bound + .as_ref() + .filter(|binding| binding.kind == Nip34Kind::Initialized) + { + Some(binding) => bound_repo_label(binding, cx), + None => Button::new("init") + .icon(CustomIconName::Init) + .label("Initialize on Nostr") + .primary() + .tooltip("Publish this repository to Nostr") + .on_click(cx.listener(|this, _event, window, cx| { + this.open_init_dialog(window, cx); + })) + .into_any_element(), + }; + v_flex() .px_4() .pb_4() @@ -1291,16 +1319,7 @@ impl RepoDetailView { .child(path), ), ) - .child( - Button::new("init") - .icon(CustomIconName::Init) - .label("Initialize on Nostr") - .primary() - .tooltip("Publish this repository to Nostr") - .on_click(cx.listener(|this, _event, window, cx| { - this.open_init_dialog(window, cx); - })), - ), + .child(action), ) .child(self.render_header_tabs(cx)) .into_any_element() @@ -1996,6 +2015,34 @@ pub(super) fn repo_display_name(store: &RepoStore) -> SharedString { .unwrap_or_default() } +/// The owner and identifier a local repository is already bound to. +fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement { + let owner = binding + .owner + .and_then(|owner| owner.to_bech32().ok()) + .map(|npub| middle_truncate(&npub, 12, 8)) + .unwrap_or_else(|| "a NIP-34 coordinate".to_owned()); + + let mut label = v_flex().flex_shrink_0().items_end().gap_1().child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!("Bound to {owner}"))), + ); + + if let Some(identifier) = binding.identifier.as_deref() { + label = label.child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(identifier.to_owned())), + ); + } + + label.into_any_element() +} + impl BasePanel for RepoDetailView { fn panel_name(&self) -> &'static str { "repo" diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 8dc7b99..5f2df34 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -10,8 +10,9 @@ use dock::{ }; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, Hsla, ObjectFit, Render, - SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white, + AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ObjectFit, + Render, SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, + white, }; use gpui_base::Button as BaseButton; use gpui_component::button::{Button, ButtonVariants}; @@ -264,31 +265,71 @@ impl SidebarPanel { } /// The detail view offers to publish it to NIP-34. - fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { + fn open_local_repo( + &mut self, + path: PathBuf, + nip34: Option, + window: &mut Window, + cx: &mut Context, + ) { let detail = - cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx)); - - self.dock_area - .update(cx, |dock_area, cx| { - add_center_panel(dock_area, panel_handle(detail), window, cx); - }) - .ok(); + cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, nip34, window, cx)); + self.add_detail_panel(detail, window, cx); } - /// A local repository opens as the announced repository when its binding matches one, - /// and as a local-only repository otherwise. + /// A local repository whose binding matches an announcement opens as the announced repository. + fn open_local_announced( + &mut self, + announcement: Announcement, + path: PathBuf, + window: &mut Window, + cx: &mut Context, + ) { + let detail = cx.new(|cx| { + RepoDetailView::new_local_announced( + self.dock_area.clone(), + announcement, + path, + window, + cx, + ) + }); + self.add_detail_panel(detail, window, cx); + } + + /// 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); + let ResolvedLocalRepo { + path, + nip34, + announcement, + } = entry; + + if let Some(announcement) = announcement { + self.open_local_announced(announcement, path, window, cx); return; } - self.open_local_repo(entry.path, window, cx); + self.open_local_repo(path, nip34, window, cx); + } + + fn add_detail_panel( + &mut self, + detail: Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.dock_area + .update(cx, |dock_area, cx| { + add_center_panel(dock_area, panel_handle(detail), window, cx); + }) + .ok(); } fn render_repos(&self, cx: &mut Context) -> impl IntoElement { diff --git a/docs/local-repo-nip34-detection.md b/docs/local-repo-nip34-detection.md index 388aa55..1511fdb 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–3 implemented. Phases 4–5 pending. +Status: Phases 1–4 implemented. Phase 5 optional and pending. ## Motivation @@ -235,25 +235,27 @@ stays a thin renderer. - `ToolingOnly` → "Nostr tooling" (muted) - plain → unchanged warning icon. -Note: the announced-open path currently opens the announced repository without the local worktree; -attaching it is Phase 4. +A matched entry opens the announced repository; Phase 4 attaches its local worktree. ### Phase 4 — detail view: open as announced, gate the publish CTA -- [ ] `crates/signed_state/src/repo.rs`: add a constructor that keeps the worktree path while in - NIP-34 mode, e.g. `RepoStore::from_worktree(addr, announcement, path, cx)`, or a small - `set_path`. `RepoStore::announce` already keeps an existing path, so this can be built from - `new` plus assigning the path. Opening a repository must **not** remove it from - `LocalReposStore` (that removal belongs to `apply_announcement`, which only runs on a real - publish). -- [ ] `crates/workspace/src/views/repo/mod.rs`: add `RepoDetailView::new_local_announced(...)` - (or an `Option` parameter on `new_local`) that builds the announced store with - the local worktree attached, then `new_common`. -- [ ] `crates/workspace/src/views/sidebar/mod.rs`: route local-entry clicks through the new - constructor when an announcement matched, and through `open_local_repo` otherwise. -- [ ] `crates/workspace/src/views/repo/mod.rs`: for a `new_local` view whose store carries an - `Initialized` binding, suppress the "publish to NIP-34" call to action and instead show the - owner and identifier. `Cloned` keeps the ability to be adopted/published. +Implemented. + +- [x] `crates/signed_state/src/repo.rs`: `RepoStore::from_worktree(addr, announcement, path, cx)` + builds the announced store and attaches the local path. `RepoStore` gained a `nip34` field + carrying the detected binding, and `new_local` now takes it. +- [x] `crates/workspace/src/views/repo/mod.rs`: `RepoDetailView::new_local_announced(...)` builds the + announced store with the local worktree, then `new_common`. `new_local` gained an + `Option` parameter. +- [x] `crates/workspace/src/views/repo/mod.rs` (`load_repo`): a store with a local path now loads the + worktree from that path even when it is announced, instead of always using the cached mirror. + This is what actually attaches the local worktree; previously the announced branch ignored + `RepoStore::path` and cloned into the mirror. +- [x] `crates/workspace/src/views/sidebar/mod.rs`: local-entry clicks route through + `open_local_announced` when an announcement matched, and `open_local_repo` otherwise. +- [x] `crates/workspace/src/views/repo/mod.rs` (`render_local_header`): an `Initialized` binding + suppresses the "Initialize on Nostr" call to action and shows the owner and identifier + instead. `Cloned` and `ToolingOnly` keep the publish path. ### Phase 5 — optional: mark Signed's own publications