diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 206189b..006b7af 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -31,8 +31,14 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; /// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses. /// Always derived from the local database. pub struct RepoStore { - addr: RepoAddr, + /// NIP-34 address. `None` while the repository is local-only. + addr: Option, + /// Latest announcement. Seeded from the open-time hint, replaced by the + /// database's latest on the first pass. `None` while local-only. pub announcement: Option, + /// Local working copy. The scan path for a local repository, kept when it is + /// later announced so the panel keeps its worktree. + pub path: Option, /// The first local pass has been applied. /// /// Views distinguish "no data yet" from a genuinely empty repository with it. @@ -82,52 +88,21 @@ pub struct RepoStore { root_fetches: HashSet, /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, - _subscription: Subscription, + /// Backend subscription of an announced repository. `None` while local-only. + _subscription: Option, } impl RepoStore { - pub fn new(addr: RepoAddr, announced_relays: Vec, cx: &mut Context) -> Self { - let backend = Backend::global(cx); - - let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { - let relevant = match event { - BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| { - // Deletions may target any event of this repository. - let deletion = - update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; - let coordinate = update.coordinate.as_ref() == Some(&this.addr); - let author = update.author == this.addr.public_key; - let kind = update.kind == Kind::GitRepoAnnouncement; - // NIP-22 comments carry no `a` tag. - // Coordinate matching fails for them. - // Any comment may reference this repository's roots. - let comment = update.kind == Kind::Comment; - // Status events may omit their `a` tag, NIP-34. - // Any status event may reference a root of this repository. - let status = RepoStatus::from_kind(update.kind).is_some(); - - deletion || coordinate || (author && kind) || comment || status - }), - BackendEvent::Published(event) => { - let kind = event.kind == Kind::GitRepoAnnouncement; - let author = event.pubkey == this.addr.public_key; - let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); - // Locally published deletions may target any event of this repository. - // Refresh so they take effect immediately, like relay deletions. - let deletion = - event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; - - coordinate || (kind && author) || deletion - } - _ => false, - }; - - if relevant { - this.refresh(cx); - } - }); - + /// Announced repository. + pub fn new(addr: RepoAddr, hint: Option, cx: &mut Context) -> Self { let weak = cx.entity().downgrade(); + let subscription = Self::subscribe_backend(cx); + + let announced_relays = hint + .as_ref() + .map(|announcement| announcement.relays.clone()) + .unwrap_or_default(); + cx.defer(move |cx| { let result = weak.update(cx, |this, cx| { this.subscribe_remote(cx); @@ -141,8 +116,9 @@ impl RepoStore { }); Self { - addr, - announcement: None, + addr: Some(addr), + announcement: hint, + path: None, loaded: false, head: None, issues: Vec::new(), @@ -161,13 +137,103 @@ impl RepoStore { repo_relays: HashSet::new(), root_fetches: HashSet::new(), refresh: RefreshGate::default(), - _subscription: subscription, + _subscription: Some(subscription), } } - /// Returns the repository's address. - pub fn addr(&self) -> &RepoAddr { - &self.addr + /// Local repository discovered by the scan, not announced to NIP-34 yet. + pub fn new_local(path: PathBuf) -> Self { + Self { + addr: None, + announcement: None, + path: Some(path), + loaded: true, + head: None, + issues: Vec::new(), + patches: Vec::new(), + pull_requests: Vec::new(), + comments: Vec::new(), + status_by_root: HashMap::new(), + open_issue_count: 0, + open_pr_count: 0, + version: 0, + last_error: None, + last_warning: None, + last_push_warning: None, + pushing: false, + cloning: false, + repo_relays: HashSet::new(), + root_fetches: HashSet::new(), + refresh: RefreshGate::default(), + _subscription: None, + } + } + + /// Switch a local repository to its NIP-34 mode, keeping its path. + pub fn announce(&mut self, announcement: Announcement, cx: &mut Context) { + self.addr = Some(announcement.addr()); + self.announcement = Some(announcement.clone()); + self.loaded = false; + + if self._subscription.is_none() { + self._subscription = Some(Self::subscribe_backend(cx)); + } + + self.subscribe_remote(cx); + self.connect_announced_relays(&announcement.relays, cx); + self.refresh(cx); + } + + /// Subscriptions to the backend events concerning this repository. + fn subscribe_backend(cx: &mut Context) -> Subscription { + let backend = Backend::global(cx); + + cx.subscribe(&backend, |this, _backend, event, cx| { + let Some(addr) = this.addr.as_ref() else { + return; + }; + + let relevant = match event { + BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| { + // Deletions may target any event of this repository. + let deletion = + update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; + let coordinate = update.coordinate.as_ref() == Some(addr); + let author = update.author == addr.public_key; + let kind = update.kind == Kind::GitRepoAnnouncement; + // NIP-22 comments carry no `a` tag. + // Coordinate matching fails for them. + // Any comment may reference this repository's roots. + let comment = update.kind == Kind::Comment; + // Status events may omit their `a` tag, NIP-34. + // Any status event may reference a root of this repository. + let status = RepoStatus::from_kind(update.kind).is_some(); + + deletion || coordinate || (author && kind) || comment || status + }), + BackendEvent::Published(event) => { + let kind = event.kind == Kind::GitRepoAnnouncement; + let author = event.pubkey == addr.public_key; + let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr); + // Locally published deletions may target any event of this repository. + // Refresh so they take effect immediately, like relay deletions. + let deletion = + event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; + + coordinate || (kind && author) || deletion + } + _ => false, + }; + + if relevant { + this.refresh(cx); + } + }) + } + + /// Returns the repository's NIP-34 address. `None` while it is local-only. + pub fn addr(&self) -> Option<&RepoAddr> { + self.addr.as_ref() } /// Returns the repository's name, or `Unknown` when not known. @@ -199,6 +265,10 @@ impl RepoStore { /// Fetch this repository's events from the relays in its NIP-34 `relays` tag. fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + return; + }; + let new: Vec = relays .iter() .filter(|url| !self.repo_relays.contains(*url)) @@ -211,7 +281,6 @@ impl RepoStore { self.repo_relays.extend(new.iter().cloned()); let backend = Backend::global(cx); - let addr = self.addr.clone(); backend.update(cx, |backend, cx| { backend.connect_repo_relays(new, Self::repo_filters(&addr), cx); @@ -220,8 +289,11 @@ impl RepoStore { /// Fetch this repository's events from the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + return; + }; + let backend = Backend::global(cx); - let addr = self.addr.clone(); backend.update(cx, |backend, cx| { backend.subscribe_bootstrap(Self::repo_filters(&addr), cx); @@ -233,6 +305,10 @@ impl RepoStore { /// Runs immediately. The backend pump already batches the relay events that /// trigger a refresh, so no per-store debounce is needed. pub fn refresh(&mut self, cx: &mut Context) { + if self.addr.is_none() { + return; + } + if self.refresh.request() != RefreshRequest::Schedule { return; } @@ -241,11 +317,14 @@ impl RepoStore { } fn run_refresh(&mut self, cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + return; + }; + self.refresh.begin(); let backend = Backend::global(cx); let client = backend.read(cx).client(); - let addr = self.addr.clone(); let work = cx.background_spawn(async move { let (announcements, states, activity, deletion_events) = async { @@ -405,12 +484,18 @@ impl RepoStore { // The first pass is the exception: it must notify even when it // found nothing, so views can leave their loading state and show // the empty result. + // + // Keep the open-time hint until that first pass has confirmed what + // the database holds; afterwards the database is the truth, + // including a deletion. + let keep_hint = announcement.is_none() && !this.loaded; + let first_pass = !this.loaded; let head_changed = state .as_ref() .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); let changed = first_pass - || this.announcement != announcement + || (!keep_hint && this.announcement != announcement) || head_changed || this.issues != issues || this.patches != patches @@ -418,7 +503,9 @@ impl RepoStore { || this.comments != comments || this.status_by_root != status_by_root; - this.announcement = announcement; + if !keep_hint { + this.announcement = announcement; + } // The announcement may list relays for this repository's activity. // Connect to any we have not fetched from yet. @@ -526,13 +613,20 @@ impl RepoStore { /// The author is the public key of the repository address. /// Only the author may manage pull requests, close, reopen or merge. pub fn is_author(&self, user: &PublicKey) -> bool { - &self.addr.public_key == user + self.addr + .as_ref() + .is_some_and(|addr| &addr.public_key == user) } /// Open an issue on this repository. pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let builder = GitIssue { - repository: self.addr.clone(), + repository: addr, content, subject, labels: Vec::new(), @@ -564,6 +658,11 @@ impl RepoStore { content: String, cx: &mut Context, ) { + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let relay_hint = self .announcement .as_ref() @@ -571,7 +670,7 @@ impl RepoStore { .cloned(); self.publish( - comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content), + comment_builder(root, parent, relay_hint.as_ref(), &addr, content), cx, ); } @@ -592,6 +691,11 @@ impl RepoStore { self.last_error = None; self.last_warning = None; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let series: Vec = signed_git::split_patch_series(&patch) .into_iter() .map(str::to_owned) @@ -635,8 +739,7 @@ impl RepoStore { // The author's npub names their GRASP-06 namespace, `/prs/...`. let author_npub = user.to_bech32().unwrap(); - let addr = self.addr.clone(); - let owner = self.addr.public_key; + let owner = addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let repo_id = addr.identifier.clone(); let base_npub = owner.to_bech32().unwrap(); @@ -752,7 +855,7 @@ impl RepoStore { let clone = pr_clone_urls(prs_urls, base_clone); let builder = GitPullRequest { - repository: this.addr.clone(), + repository: addr.clone(), content: description, subject, labels: Vec::new(), @@ -971,8 +1074,11 @@ impl RepoStore { .map(|p| p.id) }); - let addr = self.addr.clone(); - let owner = self.addr.public_key; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let owner = addr.public_key; let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let root = root.clone(); let clone: Vec = self @@ -1000,9 +1106,9 @@ impl RepoStore { }); } - let builder = this.update(cx, |this, _cx| { + let builder = this.update(cx, |_this, _cx| { let builder = GitPullRequestUpdate { - repository: this.addr.clone(), + repository: addr.clone(), pull_request_event: root.id, pull_request_author: root.pubkey, current_commit, @@ -1059,6 +1165,11 @@ impl RepoStore { pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let maintainers = self .announcement .as_ref() @@ -1084,9 +1195,9 @@ impl RepoStore { let builder = EventBuilder::new(status.kind(), "").tags([ root_ref, - Tag::public_key(self.addr.public_key), + Tag::public_key(addr.public_key), Tag::public_key(root.pubkey), - Tag::coordinate(self.addr.clone(), None), + Tag::coordinate(addr, None), ]); self.publish(builder, cx); @@ -1097,6 +1208,11 @@ impl RepoStore { self.last_error = None; self.last_warning = None; + let Some(addr) = self.addr.clone() else { + self.not_announced(cx); + return; + }; + let is_author = Backend::global(cx) .read(cx) .current_user() @@ -1107,7 +1223,6 @@ impl RepoStore { } let cache = GitStore::global(cx).cache().clone(); - let addr = self.addr.clone(); let clone_urls: Vec = self .announcement @@ -1176,12 +1291,13 @@ impl RepoStore { /// The latest announcement of this repository, /// for operations that need its clone URLs and relays. fn action_announcement(&self, cx: &App) -> Option { + let addr = self.addr.as_ref()?; self.announcement.clone().or_else(|| { RepoListStore::global(cx) .read(cx) .announcements .iter() - .find(|announcement| announcement.addr() == self.addr) + .find(|announcement| announcement.addr() == *addr) .cloned() }) } @@ -1243,6 +1359,10 @@ impl RepoStore { ))); } + let Some(addr) = self.addr.clone() else { + return self.action_error("This repository is not published to Nostr yet", cx); + }; + let Some(announcement) = self.action_announcement(cx) else { return self.action_error("Repository announcement is not loaded yet", cx); }; @@ -1250,7 +1370,6 @@ impl RepoStore { // The state event's `HEAD` stays the announced default branch. // The checkout may be on a side branch. let head = self.head.clone(); - let addr = self.addr.clone(); self.pushing = true; self.last_error = None; @@ -1298,7 +1417,9 @@ impl RepoStore { /// Only the repository owner may delete it. The lists update when the /// deletion events arrive. pub fn delete_repository(&mut self, cx: &mut Context) -> Task> { - let addr = self.addr.clone(); + let Some(addr) = self.addr.clone() else { + return self.action_error("This repository is not published to Nostr yet", cx); + }; self.last_error = None; let backend = Backend::global(cx); @@ -1328,12 +1449,16 @@ impl RepoStore { "A clone of this repository is already in progress" ))); } + + let Some(addr) = self.addr.clone() else { + return self.action_error("This repository is not published to Nostr yet", cx); + }; + let Some(announcement) = self.action_announcement(cx) else { return self.action_error("Repository announcement is not loaded yet", cx); }; let clone_urls = announcement.clone.clone(); - let addr = self.addr.clone(); self.cloning = true; self.last_error = None; @@ -1370,6 +1495,12 @@ impl RepoStore { }) } + /// Record that an action needs a NIP-34 address this repository does not have. + fn not_announced(&mut self, cx: &mut Context) { + self.last_error = Some("This repository is not published to Nostr yet".into()); + cx.notify(); + } + /// Fail an operation whose announcement is not loaded yet. fn action_error( &mut self, @@ -1393,11 +1524,15 @@ impl RepoStore { euc: Option<&str>, cx: &mut Context, ) { + let Some(addr) = self.addr.clone() else { + return; + }; + let mut tags = vec![ Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"), - Tag::public_key(self.addr.public_key), + Tag::public_key(addr.public_key), Tag::public_key(root.pubkey), - Tag::coordinate(self.addr.clone(), None), + Tag::coordinate(addr, None), ]; if let Some(euc) = euc diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index f4d7c4d..5e7e526 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -151,35 +151,37 @@ impl PullRequestDetailView { let binding = { let store = self.store.read(cx); - store - .pull_requests - .iter() - .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) - .map(|root| { - let update = latest_update(store.pull_requests.iter(), root); + store.addr().and_then(|addr| { + store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + .map(|root| { + let update = latest_update(store.pull_requests.iter(), root); - let tip = update - .and_then(current_commit_of) - .or_else(|| current_commit_of(root)); + let tip = update + .and_then(current_commit_of) + .or_else(|| current_commit_of(root)); - let base = update - .and_then(merge_base_of) - .or_else(|| merge_base_of(root)); + let base = update + .and_then(merge_base_of) + .or_else(|| merge_base_of(root)); - let clone_urls = clone_urls_of(root) - .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) - .unwrap_or_default(); + let clone_urls = clone_urls_of(root) + .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) + .unwrap_or_default(); - PrBinding { - description: root.content.clone(), - patch: pull_request_patch(root, store.patches.iter()), - tip, - base, - clone_urls, - addr: store.addr().clone(), - has_patch_link: root.tags.event_ids().next().is_some(), - } - }) + PrBinding { + description: root.content.clone(), + patch: pull_request_patch(root, store.patches.iter()), + tip, + base, + clone_urls, + addr: addr.clone(), + has_patch_link: root.tags.event_ids().next().is_some(), + } + }) + }) }; let Some(binding) = binding else { diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index f087c4a..06f19ea 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -305,7 +305,24 @@ impl NewPullRequestView { ), ]; - let mut view = Self { + cx.defer_in(window, |this, window, cx| { + let Some(addr) = this.store.read(cx).addr().cloned() else { + return; + }; + + let Some(path) = CheckoutsStore::global(cx) + .read(cx) + .associations_of(&addr) + .into_iter() + .next() + else { + return; + }; + + this.apply_folder_path(path, window, cx); + }); + + Self { focus_handle: cx.focus_handle(), dock_area, store, @@ -330,20 +347,7 @@ impl NewPullRequestView { scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), _subscriptions: subscriptions, - }; - - // Prefill with the store's freshest associated checkout, no folder dialog. - let addr = view.store.read(cx).addr().clone(); - if let Some(path) = CheckoutsStore::global(cx) - .read(cx) - .associations_of(&addr) - .into_iter() - .next() - { - view.apply_folder_path(path, window, cx); } - - view } /// Whether a compare source, a checkout or a fork, is applied. @@ -496,11 +500,12 @@ impl NewPullRequestView { // Remember this folder as a checkout of the target repository. // The next panel pre-fills it. - let addr = self.store.read(cx).addr().clone(); - let checkout_store = CheckoutsStore::global(cx); - checkout_store.update(cx, |store, cx| { - store.record(PathBuf::from(&path), addr, cx); - }); + if let Some(addr) = self.store.read(cx).addr().cloned() { + let checkout_store = CheckoutsStore::global(cx); + checkout_store.update(cx, |store, cx| { + store.record(PathBuf::from(&path), addr, cx); + }); + } let branches = self.branches.clone(); let base = SharedString::from(base.clone()); @@ -524,18 +529,21 @@ impl NewPullRequestView { /// The base repository of the panel, its address and announced EUC. /// - /// Used to find fork candidates. - fn base_repo(&self, cx: &App) -> (RepoAddr, Option) { + /// Used to find fork candidates. `None` while the repository is not announced. + fn base_repo(&self, cx: &App) -> Option<(RepoAddr, Option)> { let store = self.store.read(cx); + let addr = store.addr()?.clone(); let euc = store.announcement.as_ref().and_then(|a| a.euc.clone()); - (store.addr().clone(), euc) + Some((addr, euc)) } /// Announced forks of the target repository a compare can use, own first. /// /// Re-read whenever the picker opens. fn fork_candidates(&self, cx: &App) -> Vec { - let (base, euc) = self.base_repo(cx); + let Some((base, euc)) = self.base_repo(cx) else { + return Vec::new(); + }; let user = Backend::global(cx).read(cx).current_user(); let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); fork_candidates(&announcements, &base, euc.as_deref(), user) @@ -556,7 +564,9 @@ impl NewPullRequestView { .as_ref() .is_some_and(|fork| fork.announcement.addr() == announcement.addr()); - let (base, _euc) = self.base_repo(cx); + let Some((base, _euc)) = self.base_repo(cx) else { + return; + }; let cache = GitStore::global(cx).cache().clone(); let mirror_path = cache.repo_path(&base); let namespace = fork_namespace(&announcement); @@ -1124,8 +1134,12 @@ impl NewPullRequestView { cx: &Context, ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static { let view = cx.entity().downgrade(); - let addr = self.store.read(cx).addr().clone(); - let associated = CheckoutsStore::global(cx).read(cx).associations_of(&addr); + let associated = self + .store + .read(cx) + .addr() + .map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr)) + .unwrap_or_default(); let active_path = (self.fork.is_none()) .then(|| self.repo_path.clone()) diff --git a/crates/workspace/src/views/repo/actions.rs b/crates/workspace/src/views/repo/actions.rs index 8d35f94..ee04c28 100644 --- a/crates/workspace/src/views/repo/actions.rs +++ b/crates/workspace/src/views/repo/actions.rs @@ -20,14 +20,10 @@ use crate::views::repo::init_dialog; impl RepoDetailView { /// Re-push the repository's refs to its announced grasp servers. pub(super) fn push_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - self.error = None; cx.notify(); - store + self.store .update(cx, |store, cx| store.push_repository(cx)) .detach(); } @@ -39,9 +35,7 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) { - let Some(store) = self.store.clone() else { - return; - }; + let store = self.store.clone(); if store.read(cx).pushing { return; @@ -72,23 +66,22 @@ impl RepoDetailView { /// Delete the repository from nostr, announcement, state and activity. pub(super) fn delete_repository(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; - store + self.store .update(cx, |store, cx| store.delete_repository(cx)) .detach(); } /// Open the issues list panel in the dock area. pub(super) fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { + if self.store.read(cx).addr().is_none() { return; - }; + } + let Some(dock_area) = self.dock_area.upgrade() else { return; }; + let store = self.store.clone(); let panel = cx.new(|cx| IssuesView::new(self.dock_area.clone(), store, window, cx)); dock_area.update(cx, |dock_area, cx| { @@ -98,13 +91,15 @@ impl RepoDetailView { /// Open the pull requests list panel in the dock area. pub(super) fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { + if self.store.read(cx).addr().is_none() { return; - }; + } + let Some(dock_area) = self.dock_area.upgrade() else { return; }; + let store = self.store.clone(); let panel = cx.new(|cx| PullRequestsView::new(self.dock_area.clone(), store, window, cx)); dock_area.update(cx, |dock_area, cx| { @@ -131,7 +126,7 @@ impl RepoDetailView { /// Open the dialog guiding the user through publishing the local repository to NIP-34. pub(super) fn open_init_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let Some(local_path) = self.local_path.clone() else { + let Some(local_path) = self.store.read(cx).path.clone() else { return; }; let view = cx.entity().downgrade(); @@ -170,10 +165,7 @@ pub(crate) fn open_repo_panel( /// relays to connect to right away; the store loads the announcement from the /// local database on its first pass, so the hint is optional. fn repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx: &mut App) -> Entity { - let relays = hint - .map(|announcement| announcement.relays.clone()) - .unwrap_or_default(); - cx.new(|cx| RepoStore::new(addr.clone(), relays, cx)) + cx.new(|cx| RepoStore::new(addr.clone(), hint.cloned(), cx)) } /// An item of a repository to open from outside its detail panel. diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index c50a7c2..f686233 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -17,14 +17,15 @@ impl RepoDetailView { /// The repository's own checkouts are not suggested here. /// Their work is pushed, see [`Self::push_suggestion`]. fn ready_suggestion(&self, cx: &App) -> Option { - let store = self.store.as_ref()?; - let addr = store.read(cx).addr().clone(); + let store = self.store.read(cx); + let addr = store.addr()?; let user = Backend::global(cx).read(cx).current_user()?; - if store.read(cx).is_author(&user) { + + if store.is_author(&user) { return None; } - let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr); + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(addr); 'status: for status in statuses { if self @@ -33,7 +34,6 @@ impl RepoDetailView { { continue; } - let store = store.read(cx); for pr in &store.pull_requests { if pr_proposes_checkout(pr, store.status_of(pr) == RepoStatus::Open, user, &status) { @@ -50,15 +50,15 @@ impl RepoDetailView { /// /// Not dismissed in this panel. fn push_suggestion(&self, cx: &App) -> Option { - let entity = self.store.as_ref()?; + let store = self.store.read(cx); + let addr = store.addr()?; let user = Backend::global(cx).read(cx).current_user()?; - if !entity.read(cx).is_author(&user) { + if !store.is_author(&user) { return None; } - let addr = entity.read(cx).addr().clone(); - let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr); + let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(addr); statuses.into_iter().find(|status| { !self @@ -75,10 +75,7 @@ impl RepoDetailView { let key = (status.path.clone(), status.branch.clone()); let path = status.path.clone(); // The push busy flag lives on the store; it disables the banner's triggers. - let pushing = self - .store - .as_ref() - .is_some_and(|store| store.read(cx).pushing); + let pushing = self.store.read(cx).pushing; let commits = if status.ahead == 1 { SharedString::from("1 commit") @@ -160,8 +157,7 @@ impl RepoDetailView { /// Warning after a push that only some grasp servers accepted. pub(super) fn render_push_warning_banner(&self, cx: &Context) -> Option { - let store = self.store.as_ref()?; - let store = store.read(cx); + let store = self.store.read(cx); let warning = store.last_push_warning.clone()?; let pushing = store.pushing; @@ -213,11 +209,9 @@ impl RepoDetailView { .ghost() .disabled(pushing) .on_click(cx.listener(|this, _ev, _window, cx| { - if let Some(store) = this.store.clone() { - store.update(cx, |store, _| { - store.last_push_warning = None; - }); - } + this.store.update(cx, |store, _| { + store.last_push_warning = None; + }); cx.notify(); })), ), @@ -299,14 +293,12 @@ impl RepoDetailView { .small() .info() .on_click(cx.listener(|this, _event, window, cx| { - if let Some(store) = this.store.clone() { - open_new_pull_panel( - this.dock_area.clone(), - store, - window, - cx, - ); - } + open_new_pull_panel( + this.dock_area.clone(), + this.store.clone(), + window, + cx, + ); })), ) .child( diff --git a/crates/workspace/src/views/repo/header.rs b/crates/workspace/src/views/repo/header.rs index b652dd5..87912cf 100644 --- a/crates/workspace/src/views/repo/header.rs +++ b/crates/workspace/src/views/repo/header.rs @@ -27,15 +27,11 @@ impl RepoDetailView { /// The NIP-34 header, actions and issues/PR counts. /// Or the local header with an Init button for an unpublished repository. pub(super) fn render_header(&mut self, cx: &mut Context) -> AnyElement { - if self.local_path.is_some() { + if self.store.read(cx).addr().is_none() { return self.render_local_header(cx); } - let Some(store_entity) = self.store.as_ref() else { - return div().into_any_element(); - }; - - let store = store_entity.read(cx); + let store = self.store.read(cx); let issue_count = SharedString::from(store.issue_count().to_string()); let pr_count = SharedString::from(store.pull_request_count().to_string()); @@ -43,7 +39,7 @@ impl RepoDetailView { let pushing = store.pushing; let cloning = store.cloning; - let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else { + let Some(source) = store.announcement.as_ref() else { return div().into_any_element(); }; @@ -73,19 +69,18 @@ impl RepoDetailView { .on_action( cx.listener(|this, action: &RepoAction, window, cx| match action { RepoAction::NewIssue => { - if let Some(store) = this.store.clone() { - open_new_issue_dialog(store, window, cx); - } + open_new_issue_dialog(this.store.clone(), window, cx); } RepoAction::NewPR => { - if let Some(store) = this.store.clone() { - open_new_pull_panel(this.dock_area.clone(), store, window, cx); - } + open_new_pull_panel(this.dock_area.clone(), this.store.clone(), window, cx); } RepoAction::SendPatch => { - if let Some(store) = this.store.clone() { - open_send_patch_panel(this.dock_area.clone(), store, window, cx); - } + open_send_patch_panel( + this.dock_area.clone(), + this.store.clone(), + window, + cx, + ); } RepoAction::About => { if let Some(announcement) = this.announcement(cx) { @@ -421,7 +416,9 @@ impl RepoDetailView { fn render_local_header(&self, cx: &mut Context) -> AnyElement { let name = self.display_name(cx); let path = self - .local_path + .store + .read(cx) + .path .as_ref() .map(|path| path.display().to_string()) .unwrap_or_default(); diff --git a/crates/workspace/src/views/repo/init_dialog.rs b/crates/workspace/src/views/repo/init_dialog.rs index 8187111..02083bf 100644 --- a/crates/workspace/src/views/repo/init_dialog.rs +++ b/crates/workspace/src/views/repo/init_dialog.rs @@ -190,7 +190,7 @@ fn init_repository( window.close_dialog(cx); if let Some(view) = view.upgrade() { view.update(cx, |this, cx| { - this.apply_announcement(announcement, window, cx); + this.apply_announcement(announcement, cx); }); } }) diff --git a/crates/workspace/src/views/repo/loading.rs b/crates/workspace/src/views/repo/loading.rs index bf6a761..d3a8266 100644 --- a/crates/workspace/src/views/repo/loading.rs +++ b/crates/workspace/src/views/repo/loading.rs @@ -39,9 +39,24 @@ impl RepoDetailView { self.error = None; cx.notify(); + let (addr, announcement, local_path) = { + let store = self.store.read(cx); + ( + store.addr().cloned(), + store.announcement.clone(), + store.path.clone(), + ) + }; + // Local repositories live on disk at their scan path. // No clone step or network refresh applies here. - if let Some(local_path) = self.local_path.clone() { + if addr.is_none() { + self.repo_started = true; + + let Some(local_path) = local_path else { + return; + }; + let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { let data = cx .background_spawn(async move { @@ -67,13 +82,14 @@ impl RepoDetailView { return; } - let Some(initial) = self.initial.as_ref() else { + let Some(announcement) = announcement else { return; }; + self.repo_started = true; let cache = GitStore::global(cx).cache().clone(); - let addr = initial.addr(); - let clone_urls: Vec = initial.clone.clone(); + let addr = announcement.addr(); + let clone_urls: Vec = announcement.clone.clone(); // Captured before the loads start. // A branch/tag switch bumps the generation, discarding the refresh below. @@ -324,9 +340,7 @@ impl RepoDetailView { /// Clone the repository into a user-chosen folder outside the cache. pub(super) fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context) { - let Some(store) = self.store.clone() else { - return; - }; + let store = self.store.clone(); let name = { let Some(announcement) = self.announcement(cx) else { diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 18edd49..8a59f45 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -71,19 +71,14 @@ pub struct RepoDetailView { /// /// New panels, commit diffs, are added there. dock_area: WeakEntity, - /// Snapshot taken at open time. + /// Per-repository store, holding the local path and the announcement, + /// issues, PRs and statuses. The single identity of both modes. + store: Entity, + /// The initial explorer load has been started. /// - /// `None` for local repositories that haven't been published yet, and for a - /// repository opened by address until its store loads the announcement. - initial: Option, - /// Per-repository nostr store, holding announcement, issues, PRs and statuses. - /// - /// `None` until a local repository is initialized to NIP-34. - store: Option>, - /// Path of the local repository when opened from the scan. - /// - /// `None` once it is initialized to NIP-34, or for announced repositories. - local_path: Option, + /// A repository opened by address alone starts without an announcement; the + /// store observer starts the load once the first one lands. + repo_started: bool, /// File explorer state, the worktree of the local clone. tree_state: Entity, /// Root of the local clone, for reading files on demand. @@ -178,18 +173,8 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) -> Self { - // The announcement we opened from already carries the NIP-34 `relays` tag. - // - // The store connects to those relays immediately, no bootstrap fetch wait. - let relays = hint - .as_ref() - .map(|announcement| announcement.relays.clone()) - .unwrap_or_default(); - let store = cx.new(|cx| RepoStore::new(addr, relays, cx)); - - let mut view = Self::new_common(dock_area, hint, Some(store.clone()), None, window, cx); - view.attach_store(&store, window, cx); - view + let store = cx.new(|cx| RepoStore::new(addr, hint, cx)); + Self::new_common(dock_area, store, window, cx) } /// Open a local repository discovered by the scan. @@ -199,7 +184,8 @@ impl RepoDetailView { window: &mut Window, cx: &mut Context, ) -> Self { - Self::new_common(dock_area, None, None, Some(local_path), window, cx) + let store = cx.new(move |_cx| RepoStore::new_local(local_path)); + Self::new_common(dock_area, store, window, cx) } /// Shared construction. @@ -207,9 +193,7 @@ impl RepoDetailView { /// File explorer state, ref selectors and the deferred repository load. fn new_common( dock_area: WeakEntity, - initial: Option, - store: Option>, - local_path: Option, + store: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -271,11 +255,10 @@ impl RepoDetailView { this.load_repo(window, cx); }); - Self { - initial, + let mut view = Self { dock_area, - store, - local_path, + store: store.clone(), + repo_started: false, tree_state, worktree: None, worktree_paths: Vec::new(), @@ -311,31 +294,40 @@ impl RepoDetailView { push_statuses: Vec::new(), focus_handle: cx.focus_handle(), _subscriptions: subscriptions, - } + }; + + view.attach_store(&store, window, cx); + view } - /// The latest announcement from the store or the open-time snapshot. - /// `None` for local repositories that haven't been published yet. + /// The latest announcement of the repository, `None` while local-only or + /// until the store's first pass loads it. fn announcement<'a>(&'a self, cx: &'a App) -> Option<&'a Announcement> { - let store = self.store.as_ref()?; - store - .read(cx) - .announcement - .as_ref() - .or(self.initial.as_ref()) + self.store.read(cx).announcement.as_ref() } /// Display name, the announcement's name or ID for announced repositories. /// The directory name for local ones. fn display_name(&self, cx: &App) -> SharedString { - if let Some(path) = &self.local_path { - return SharedString::from( - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()), - ); + let store = self.store.read(cx); + + if store.addr().is_none() { + return store + .path + .as_ref() + .map(|path| { + SharedString::from( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ) + }) + .unwrap_or_default(); } - self.announcement(cx) + + store + .announcement + .as_ref() .map(|announcement| { announcement .name @@ -386,8 +378,10 @@ impl Render for RepoDetailView { // operations, republish, checkout push, delete and clone-to-folder. let error = self.error.clone().or_else(|| { self.store - .as_ref() - .and_then(|store| store.read(cx).last_error.clone().map(SharedString::from)) + .read(cx) + .last_error + .clone() + .map(SharedString::from) }); v_flex() @@ -405,9 +399,7 @@ impl Render for RepoDetailView { .banner() .on_close(cx.listener(|this, _event, _window, cx| { this.error = None; - if let Some(store) = this.store.clone() { - store.update(cx, |store, _| store.last_error = None); - } + this.store.update(cx, |store, _| store.last_error = None); cx.notify(); })), ) diff --git a/crates/workspace/src/views/repo/store.rs b/crates/workspace/src/views/repo/store.rs index 9279200..4653b22 100644 --- a/crates/workspace/src/views/repo/store.rs +++ b/crates/workspace/src/views/repo/store.rs @@ -1,4 +1,3 @@ -use gpui::prelude::*; use gpui::{Context, Entity, Window}; use signed_core::Announcement; use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; @@ -6,33 +5,29 @@ use signed_state::{Backend, CheckoutsStore, LocalReposStore, RepoStore}; use super::RepoDetailView; impl RepoDetailView { - /// Switch the repository into its NIP-34 mode after a successful init. - /// Creates the nostr store for the announced repository. - /// Drops the local scan identity. - /// The worktree is unchanged, so the explorer keeps its loaded content. + /// Switch a local repository into its NIP-34 mode after a successful init. + /// The store is kept, so the panel keeps its path and loaded worktree. + /// Drops the local scan identity so it leaves the sidebar's local section. pub(crate) fn apply_announcement( &mut self, announcement: Announcement, - window: &mut Window, cx: &mut Context, ) { - // The repository is no longer a bare local repo. - // Drop it from the scan results so it leaves the sidebar's local section. - if let Some(path) = self.local_path.take() { + let path = self.store.read(cx).path.clone(); + if let Some(path) = path { LocalReposStore::global(cx).update(cx, |store, cx| store.remove(&path, cx)); } - let store = - cx.new(|cx| RepoStore::new(announcement.addr(), announcement.relays.clone(), cx)); - // Re-render on store refreshes, issues, PRs and statuses. - // Keep the ready-to-contribute statuses of this repository requested. - self.attach_store(&store, window, cx); - self.store = Some(store); - self.initial = Some(announcement); + + self.store + .update(cx, |store, cx| store.announce(announcement, cx)); + + // The new address needs its ready-to-contribute statuses requested. + self.refresh_ready_statuses(cx); cx.notify(); } - /// Observe the repository's store, re-render on refreshes. - /// Request the ready-to-contribute statuses for it. + /// Observe the repository's store and re-render on its refreshes. + /// Start the explorer once the store has an announcement. pub(super) fn attach_store( &mut self, store: &Entity, @@ -42,18 +37,9 @@ impl RepoDetailView { self._subscriptions .push(cx.observe_in(store, window, |this, store, window, cx| { this.refresh_ready_statuses(cx); - - // A repository opened from its address alone starts without an - // announcement. Adopt the store's first one so the explorer can - // load; later passes leave the snapshot and the selection alone. - let announcement = store.read(cx).announcement.clone(); - if this.initial.is_none() - && let Some(announcement) = announcement - { - this.initial = Some(announcement); + if !this.repo_started && store.read(cx).announcement.is_some() { this.load_repo(window, cx); } - cx.notify(); })); self.refresh_ready_statuses(cx); @@ -64,11 +50,11 @@ impl RepoDetailView { /// Owned repositories are watched for unpushed commits. /// Other repositories for ready-to-contribute checkouts. fn refresh_ready_statuses(&mut self, cx: &mut Context) { - let Some(entity) = self.store.clone() else { + let Some(addr) = self.store.read(cx).addr().cloned() else { return; }; - let head = entity.read(cx).head.clone(); + let head = self.store.read(cx).head.clone(); if self.ready_requested && self.ready_head == head { return; @@ -77,14 +63,13 @@ impl RepoDetailView { self.ready_requested = true; self.ready_head = head.clone(); - let addr = entity.read(cx).addr().clone(); let backend = Backend::global(cx); let checkout = CheckoutsStore::global(cx); let owned = backend .read(cx) .current_user() - .is_some_and(|user| entity.read(cx).is_author(&user)); + .is_some_and(|user| self.store.read(cx).is_author(&user)); checkout.update(cx, |store, cx| { // The ready statuses keep the fast poll running while the panel is open. @@ -97,16 +82,12 @@ impl RepoDetailView { }); } - /// The ready-to-push statuses of this repository in the global checkouts - /// store changed since they last drove a render. - /// - /// Updates the cached slices. `None` store (a local, not yet published, - /// repository) has no statuses. + /// The ready-to-contribute and ready-to-push statuses of this repository. pub(super) fn refresh_statuses(&mut self, cx: &mut Context) -> bool { - let Some(entity) = self.store.clone() else { + let Some(addr) = self.store.read(cx).addr().cloned() else { return false; }; - let addr = entity.read(cx).addr().clone(); + let checkouts = CheckoutsStore::global(cx).read(cx); let ready_statuses = checkouts.ready_statuses_of(&addr); let push_statuses = checkouts.push_statuses_of(&addr); diff --git a/docs/repo-state-plan.md b/docs/repo-state-plan.md index 5ab7777..e35db14 100644 --- a/docs/repo-state-plan.md +++ b/docs/repo-state-plan.md @@ -1,6 +1,6 @@ # Repository state and panel flow plan -Status: phases 1-2 implemented, phase 3 next (2026-09-13) +Status: phases 1-3 implemented (2026-09-13) Builds on `docs/backend-rearchitecture.md`, especially §7 (notify audit), §11 (split independently-observed state), §12 (one debounce at the source) @@ -281,27 +281,56 @@ Status: implemented. 3. `open_repo_panel` and `RepoDetailView::new` take an address plus an optional hint, so a repository panel opens from a `RepoAddr` alone. This pulls the address-based constructor forward from Phase 3 step 2. -4. `RepoDetailView::attach_store` adopts the store's first announcement when - `initial` is still empty and calls `load_repo`, so a panel opened by address - fills in instead of waiting for the caller to have the announcement. +4. `RepoDetailView::attach_store` starts the explorer from the store's first + announcement when the panel was opened by address alone, so a panel opened + by address fills in instead of waiting for the caller to have the + announcement. Phase 3 moves this onto the single store observer, gated by + `repo_started`. 5. `open_upstream`: the 60 x 250 ms poll and the `pending_upstream` field are gone. It opens the panel by address; the store's `subscribe_remote` fetches the announcement from the bootstrap relays and step 4 loads the explorer. ### Phase 3 - one entity for local and NIP-34 -1. `signed_state/src/repo.rs`: `addr`/`path` options, `new_local`, - `announce`, `Option`, action guards. -2. `views/repo/mod.rs`: single `store` field; `new_local`; header, - display name, `load_repo`, `open_init_dialog` derive from the store. The - address-based `new` is already in place from Phase 2. -3. `views/repo/store.rs`: always observe; `refresh_statuses` returns false - when not announced. -4. `views/repo/actions.rs`, `header.rs`, `banners.rs`: drop - `Option>` guards, guard on `addr()` instead. +Status: implemented. + +1. `signed_state/src/repo.rs`: `addr: Option`, + `path: Option`, `announcement: Option`, + `_subscription: Option`. `new(addr, hint, cx)` seeds the + announcement and relays from the hint; `new_local(path)`; `announce` + switches a local store to NIP-34 in place, keeping `path`. `addr()` returns + `Option<&RepoAddr>`; `refresh`/`connect_announced_relays`/`subscribe_remote` + no-op without an address. Nostr-side actions guard with `not_announced` + (unit actions) or `action_error` (task actions). +2. `views/repo/mod.rs`: one `store: Entity` field. `initial` and + `local_path` are deleted; `new_local` builds a local store. The store + observer starts the explorer once an announcement lands, tracked by + `repo_started`. `display_name`, `load_repo` and `open_init_dialog` derive + their mode from `addr()`/`path` instead of the removed fields. +3. `views/repo/store.rs`: the store is observed from construction for both + modes; `apply_announcement` calls `store.announce` on the existing entity. + `refresh_ready_statuses` and `refresh_statuses` return early when `addr()` + is `None`. +4. `views/repo/{actions,header,banners,loading}.rs`: the + `Option>` guards are gone. Announced-only entry points + (issue/PR lists, new PR, send patch) guard on `addr()`; `NewPullRequestView` + and `PullRequestDetailView` thread the address option through their + prefill/binding paths. 5. `LocalReposStore` stays as the scan index; `CheckoutsStore` stays the association authority. +Deviations from the sketch above: + +- `path` is set only by `new_local` and kept by `announce`. `new` does not + resolve an associated checkout: the explorer still mirrors the cache for + announced repositories, so a stored checkout path would be dead weight. + The field is the seam for the open question below. +- `new_local` takes no `Context`: a local store has nothing to subscribe to and + no first pass to defer. +- `new` keeps the open-time hint until the first pass has confirmed what the + database holds, so a panel opened from a hint renders before the query lands + and still adopts a later deletion. + ### Phase 4 - deferred, only if duplicate stores become a problem One store per address via `HashMap>` inside