From 7ff09502b9d3db0cd239fe96153633e2197406e2 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 5 Sep 2026 15:27:35 +0700 Subject: [PATCH] update --- crates/dock/src/dock_area.rs | 1 + crates/signed_core/src/filters.rs | 7 - crates/signed_core/src/model.rs | 6 +- crates/signed_state/src/checkouts.rs | 42 ++- crates/signed_state/src/lib.rs | 4 +- crates/signed_state/src/repo_list.rs | 41 ++- crates/workspace/src/views/repo_detail/mod.rs | 138 +++++++--- crates/workspace/src/views/sidebar/mod.rs | 257 +++++++++++------- 8 files changed, 314 insertions(+), 182 deletions(-) diff --git a/crates/dock/src/dock_area.rs b/crates/dock/src/dock_area.rs index 6928d32..0195955 100644 --- a/crates/dock/src/dock_area.rs +++ b/crates/dock/src/dock_area.rs @@ -105,6 +105,7 @@ impl SignedDockSkin { } /// Payload a dock's resize handle drags. +/// /// It draws nothing, the handle element is the visible affordance. #[derive(Clone)] struct ResizePanel; diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index be79314..e6485cf 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -94,13 +94,6 @@ pub fn comments_for(roots: impl IntoIterator) -> Vec { ] } -/// All repositories announced by an author. -pub fn announcements_by(public_key: PublicKey) -> Filter { - Filter::new() - .kind(Kind::GitRepoAnnouncement) - .author(public_key) -} - /// All repository announcements, for global discovery. pub fn all_announcements() -> Filter { Filter::new().kind(Kind::GitRepoAnnouncement) diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 116dce5..8fc87c2 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use nostr::prelude::*; -use crate::RepoAddr; +use crate::{RepoAddr, repo_addr}; /// Parsed NIP-34 repository announcement, plain data ready for the UI. #[derive(Debug, Clone, PartialEq, Eq)] @@ -267,8 +267,8 @@ impl Announcement { } /// The repository address of this announcement. - pub fn addr(&self) -> crate::RepoAddr { - crate::repo_addr(self.owner, self.id.clone()) + pub fn addr(&self) -> RepoAddr { + repo_addr(self.owner, self.id.clone()) } /// The name of the repository, or a default if none is provided. diff --git a/crates/signed_state/src/checkouts.rs b/crates/signed_state/src/checkouts.rs index c078da8..00b3a21 100644 --- a/crates/signed_state/src/checkouts.rs +++ b/crates/signed_state/src/checkouts.rs @@ -64,10 +64,17 @@ struct Remembered { } /// Global store of local-checkout associations and per-checkout statuses. +/// +/// Readers (the sidebar rows, the repository panels) observe this store and +/// derive what they display from their own snapshots, so publishing needs no +/// fine-grained entities: the store notifies when a slice changed and each +/// reader re-derives only what it shows. pub struct CheckoutsStore { /// Checkout paths per announced repository. by_repo: HashMap>, /// Ready-to-contribute statuses of the requested repositories. + /// + /// Those are the repository detail panels currently open. statuses: HashMap>, /// Repositories whose statuses are recomputed on every input change. /// @@ -77,7 +84,7 @@ pub struct CheckoutsStore { /// /// The sidebar rows of the user's own repositories and their detail panels. push_requested: HashSet, - /// Ready-to-push statuses of the requested own repositories. + /// The ready-to-push statuses of the requested own repositories. push_statuses: HashMap>, /// Last announced head branch per requested repository. /// @@ -85,8 +92,8 @@ pub struct CheckoutsStore { requested_head: HashMap>, /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, - _subscriptions: Vec, tasks: Vec>>, + _subscriptions: Vec, } impl CheckoutsStore { @@ -127,8 +134,9 @@ impl CheckoutsStore { this.status_requested.clear(); this.push_requested.clear(); this.requested_head.clear(); - this.statuses = HashMap::new(); - this.push_statuses = HashMap::new(); + this.statuses.clear(); + this.push_statuses.clear(); + cx.notify(); this.refresh(cx); } })); @@ -142,8 +150,8 @@ impl CheckoutsStore { push_statuses: HashMap::new(), requested_head: HashMap::new(), refresh: RefreshGate::default(), - _subscriptions: subscriptions, tasks: Vec::new(), + _subscriptions: subscriptions, }; if !cfg!(target_arch = "wasm32") { @@ -217,7 +225,7 @@ impl CheckoutsStore { /// The ready-to-contribute statuses of `addr`. /// /// Empty while none are known or nothing is ahead. - pub fn statuses_of(&self, addr: &RepoAddr) -> Vec { + pub fn ready_statuses_of(&self, addr: &RepoAddr) -> Vec { self.statuses.get(addr).cloned().unwrap_or_default() } @@ -228,13 +236,20 @@ impl CheckoutsStore { } /// The ready-to-push statuses of `addr`. - /// Only meaningful for repositories announced by the signed-in user. /// /// Empty while none are known or nothing is unpushed. pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec { 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) + .map(|list| list.iter().map(|status| status.ahead as usize).sum()) + .unwrap_or(0) + } + /// Re-resolve the associations and the requested statuses. /// /// Requests arriving while a pass runs fold into a follow-up. @@ -362,10 +377,20 @@ impl CheckoutsStore { }; let again = this.update(cx, |this, cx| { + let associations_changed = this.by_repo != associations; + let statuses_changed = this.statuses != statuses; + let push_statuses_changed = this.push_statuses != push_statuses; + this.by_repo = associations; this.statuses = statuses; this.push_statuses = push_statuses; - cx.notify(); + + // Poll cycles and identity re-requests recompute the same maps + // over and over. Notify only when something actually changed, + // so observers skip the no-op heartbeats. + if associations_changed || statuses_changed || push_statuses_changed { + cx.notify(); + } this.refresh.finish() })?; @@ -378,6 +403,7 @@ impl CheckoutsStore { this.update(cx, |this, cx| { if poll && this.refresh.idle() { this.refresh.debounce(); + // Open panels get the fast cadence. // Each cycle fetches every watched checkout's remote. let delay = if this.status_requested.is_empty() { diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 5bd40ac..9d90403 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -42,7 +42,7 @@ pub fn init( let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(entity.clone(), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx); - RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); + RepoListStore::set_global(cx.new(RepoListStore::new), cx); GitStore::set_global(repos_root, cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); @@ -57,7 +57,7 @@ pub fn init(cx: &mut App) -> Entity { let entity = cx.new(|cx| Backend::new(client, signer, cx)); Backend::set_global(entity.clone(), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx); - RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); + RepoListStore::set_global(cx.new(RepoListStore::new), cx); GitStore::set_global(PathBuf::new(), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx); CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx); diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index 783f5b2..58a4efd 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -42,7 +42,7 @@ impl RepoActivityCounts { } } -/// Store listing repository announcements, global discovery or per-author. +/// Store listing the discovered repository announcements, newest first. pub struct RepoListStore { /// Shared so views can clone the list per frame without a deep copy. pub announcements: Arc>, @@ -53,7 +53,6 @@ pub struct RepoListStore { /// /// Used for the Popular ranking of the explore list. pub counts: Arc>, - author: Option, /// Refresh coalescing, see [`RefreshGate`]. refresh: RefreshGate, tasks: Vec>>, @@ -61,7 +60,7 @@ pub struct RepoListStore { } impl RepoListStore { - /// Retrieve the global explore store. + /// Retrieve the global repository list store. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -70,8 +69,8 @@ impl RepoListStore { cx.set_global(GlobalRepoListStore(entity)); } - /// Create a store. If `author` is `None`, all announcements are listed. - pub fn new(author: Option, cx: &mut Context) -> Self { + /// Create the store listing all announcements. + pub fn new(cx: &mut Context) -> Self { let backend = Backend::global(cx); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { @@ -87,14 +86,11 @@ impl RepoListStore { } else { let is_announcement = update.kind == Kind::GitRepoAnnouncement; let is_repo_state = update.kind == Kind::RepoState; - let tracked = is_announcement || is_repo_state; - tracked && this.author.is_none_or(|a| a == update.author) + is_announcement || is_repo_state } } BackendEvent::Published(event) => { - let kind_match = event.kind == Kind::GitRepoAnnouncement; - let author_match = this.author.is_none_or(|a| a == event.pubkey); - let announcement = kind_match && author_match; + let announcement = event.kind == Kind::GitRepoAnnouncement; // Locally published deletions are already in the local database. // Refresh so they take effect immediately, like relay deletions. @@ -116,7 +112,6 @@ impl RepoListStore { announcements: Arc::new(Vec::new()), last_activity: Arc::new(HashMap::new()), counts: Arc::new(HashMap::new()), - author, refresh: RefreshGate::default(), _subscription: subscription, tasks: Vec::new(), @@ -129,6 +124,15 @@ impl RepoListStore { store } + /// The announcements of `user`, newest first. + pub fn announcements_of(&self, user: &PublicKey) -> Vec { + self.announcements + .iter() + .filter(|a| a.owner == *user) + .cloned() + .collect() + } + /// Track a spawned task, pruning finished tasks first. /// /// Keeps the store's task list bounded by the number of in-flight tasks. @@ -140,14 +144,9 @@ impl RepoListStore { /// Negentropy-sync announcements with the bootstrap relays. fn subscribe_remote(&mut self, cx: &mut Context) { let backend = Backend::global(cx); - let author = self.author; backend.update(cx, |backend, cx| { - let filter = match author { - Some(a) => filters::announcements_by(a), - None => filters::all_announcements(), - }; - backend.sync_bootstrap(filter, cx); + backend.sync_bootstrap(filters::all_announcements(), cx); // Deletion requests, NIP-09/62, must be known before any announcement is shown. backend.sync_bootstrap(filters::deletions(), cx); }); @@ -187,15 +186,11 @@ impl RepoListStore { let backend = Backend::global(cx); let client = backend.read(cx).client(); - let author = self.author; let work = cx.background_spawn(async move { - let filter = match author { - Some(a) => filters::announcements_by(a), - None => filters::all_announcements(), - }; - + let filter = filters::all_announcements(); let events = client.database().query(filter).await?; + let deletion_events = client.database().query(filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 06c82ed..719b747 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -184,24 +184,31 @@ pub struct RepoDetailView { tasks: Vec>>, /// Subscriptions keeping the selectors' confirm events alive. _subscriptions: Vec, - /// Observes the checkouts store. - /// Its statuses feed the ready-to-contribute banner of the repository panel. - _checkouts_subscription: Subscription, /// `(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. + /// 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 on NIP-34. + /// Open a repository announced. + /// /// The store connects to the announcement's relays and loads issues, PRs and statuses. pub fn new( dock_area: WeakEntity, @@ -228,8 +235,6 @@ impl RepoDetailView { } /// Open a local repository discovered by the scan. - /// There is no announcement and no nostr store until the user publishes it to NIP-34. - /// The header shows an Init button instead of the NIP-34 actions. pub fn new_local( dock_area: WeakEntity, local_path: PathBuf, @@ -240,6 +245,7 @@ impl RepoDetailView { } /// Shared construction. + /// /// File explorer state, ref selectors and the deferred repository load. fn new_common( dock_area: WeakEntity, @@ -271,7 +277,7 @@ impl RepoDetailView { .searchable(true) }); - let subscriptions = vec![ + 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. @@ -291,6 +297,17 @@ impl RepoDetailView { }), ]; + // 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); + subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { + if this.refresh_statuses(cx) { + cx.notify(); + } + })); + // Defer loading the repository until the window is ready. cx.defer_in(window, |this, window, cx| { this.load_repo(window, cx); @@ -328,15 +345,15 @@ impl RepoDetailView { tag_select, switching_ref: false, ref_generation: 0, - focus_handle: cx.focus_handle(), - tasks: Vec::new(), - _subscriptions: subscriptions, - _checkouts_subscription: cx - .observe(&CheckoutsStore::global(cx), |_this, _store, cx| cx.notify()), banner_dismissed: HashSet::new(), ready_requested: false, ready_head: None, + ready_statuses: Vec::new(), + push_statuses: Vec::new(), pending_upstream: None, + focus_handle: cx.focus_handle(), + tasks: Vec::new(), + _subscriptions: subscriptions, } } @@ -371,16 +388,20 @@ impl RepoDetailView { })?; Ok(()) }); + self.tasks.push(task); + return; } let Some(initial) = self.initial.as_ref() else { return; }; + let cache = GitStore::global(cx).cache().clone(); let addr = initial.addr(); let clone_urls: Vec = initial.clone.iter().map(ToString::to_string).collect(); + // Captured before the loads start. // A branch/tag switch bumps the generation, discarding the refresh below. let refresh_generation = self.ref_generation; @@ -1414,8 +1435,7 @@ impl RepoDetailView { RepoAction::Delete => this.delete_repository(window, cx), }), ) - .px_4() - .pb_4() + .p_4() .w_full() .gap_8() .border_b_1() @@ -1880,6 +1900,26 @@ 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. + 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. @@ -1893,7 +1933,7 @@ impl RepoDetailView { return None; } - let statuses = CheckoutsStore::global(cx).read(cx).statuses_of(&addr); + let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr); 'status: for status in statuses { if self @@ -1916,15 +1956,19 @@ impl RepoDetailView { } /// 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 @@ -1933,52 +1977,60 @@ impl RepoDetailView { } /// The ready-to-push banner of an owned repository. + /// /// A local checkout has unpushed commits, with a Push action and a dismiss control. 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(); + let commits = if status.ahead == 1 { "1 commit".to_owned() } else { format!("{} commits", status.ahead) }; + let message = SharedString::from(format!( "{} has {} ready to push in {}", status.branch, commits, status.path.display() )); - let key = (status.path.clone(), status.branch.clone()); - let view = cx.entity().clone(); - let path = status.path.clone(); - let pushing = self.pushing; Some( h_flex() - .gap_2() .px_4() - .pt_1() + .gap_2() .w_full() .items_center() + .justify_between() + .child(div().text_sm().child(message)) .child( - Alert::info("repo-unpushed", message) - .banner() - .flex_1() - .on_close(move |_event, _window, cx| { - view.update(cx, |this, _| { - this.banner_dismissed.insert(key.clone()); - }); - }), - ) - .child( - Button::new("push-checkout-banner") - .small() - .icon(CustomIconName::Init) - .label("Push") - .loading(pushing) - .disabled(pushing) - .on_click(cx.listener(move |this, _event, window, cx| { - this.push_unpushed_checkout(path.clone(), window, cx); - })), + h_flex() + .gap_1() + .child( + Button::new("push-checkout-banner") + .icon(IconName::ArrowUp) + .label("Push") + .small() + .primary() + .loading(self.pushing) + .disabled(self.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) + .small() + .ghost() + .disabled(self.pushing) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.banner_dismissed.insert(key.clone()); + cx.notify(); + })), + ), ) .into_any_element(), ) @@ -2257,8 +2309,10 @@ impl Render for RepoDetailView { .image_cache(gpui::retain_all("repo")) .id("repo") .size_full() + .when_some(banner, |this, banner| { + this.child(v_flex().gap_1().py_4().bg(cx.theme().muted).child(banner)) + }) .child(self.render_header(cx)) - .when_some(banner, |this, banner| this.child(banner)) .when_some(self.error.clone(), |this, error| { this.child( Alert::error("repo-error", error) diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index fea1f11..96db48a 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -1,6 +1,7 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use assets::CustomIconName; @@ -9,7 +10,7 @@ use dock::{ }; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, + AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list, }; use gpui_base::Button as BaseButton; @@ -17,8 +18,7 @@ use gpui_component::badge::Badge; 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::nips::nip01::Coordinate; -use signed_core::{Announcement, identifier_from_name}; +use signed_core::{Announcement, RepoAddr, identifier_from_name}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, }; @@ -39,90 +39,172 @@ pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, explore: Option>, - logged_in: bool, - /// Repositories the current user announced, listed under the All Repositories heading. - repos: Option>, - /// Observes the current user's repo store so the list re-renders. - repos_subscription: Option, - /// Banner artwork behind the sign-in screen. + /// Artwork for the sign-in screen. banner: SharedString, - _subscription: Subscription, + /// 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: HashMap, + _subscriptions: Vec, } impl SidebarPanel { pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { let backend = Backend::global(cx); - let logged_in = backend.read(cx).current_user().is_some(); + let repos = RepoListStore::global(cx); + let local = LocalReposStore::global(cx); + let checkouts = CheckoutsStore::global(cx); - let subscription = cx.subscribe(&backend, |this, backend, event, cx| { - match event { - BackendEvent::SignerChanged => { - this.logged_in = backend.read(cx).current_user().is_some(); - this.refresh_repos(cx); - } - BackendEvent::SignerRequired => { - this.logged_in = false; - this.banner = pick_banner(); - this.repos = None; - this.repos_subscription = None; - } - _ => return, + let mut subscriptions = Vec::new(); + + // Identity changes swap the whole sidebar between the sign-in screen and the signed-in content. + subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { + let signer_changed = matches!(event, BackendEvent::SignerChanged); + let signer_required = matches!(event, BackendEvent::SignerRequired); + + if !signer_changed && !signer_required { + return; } - cx.notify(); - }); - let mut panel = Self { + if signer_required { + this.banner = pick_banner(); + } + + if this.refresh(cx) || signer_required { + cx.notify(); + } + })); + + // 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. + subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { + if this.refresh_unpushed(cx) { + cx.notify(); + } + })); + + let mut this = Self { focus_handle: cx.focus_handle(), dock_area, - logged_in, explore: None, - repos: None, - repos_subscription: None, banner: pick_banner(), - _subscription: subscription, + announcements: Arc::new(Vec::new()), + local_repos: Arc::new(Vec::new()), + scanning: false, + unpushed: HashMap::new(), + _subscriptions: subscriptions, }; - if logged_in { - panel.refresh_repos(cx); - cx.notify(); - } + // Seed the snapshot right away. + // The stores may already hold data from before the panel opened. + // The first render must not depend on a later store update. + this.refresh(cx); - panel + this } - /// Recreate the store listing the current user's repositories. + /// The sidebar renders only its own derived fields, never the stores + /// directly. Because the panel is a cached view, a store update alone does + /// not re-render it: the observers notify this panel, which re-runs + /// `render` over the fresh snapshot. /// - /// Watch each repository for unpushed local work. - fn refresh_repos(&mut self, cx: &mut Context) { - self.repos_subscription = None; - - let checkouts = CheckoutsStore::global(cx); + /// Returns `true` when a rendered field changed. + fn refresh(&mut self, cx: &mut Context) -> bool { let backend = Backend::global(cx); - let author = backend.read(cx).current_user(); + let user = backend.read(cx).current_user(); - // Create a new repo list store for the signed-in user, if they are logged in. - self.repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx))); + let repo_list = RepoListStore::global(cx); + let announcements = user + .as_ref() + .map(|user| repo_list.read(cx).announcements_of(user)) + .unwrap_or_default(); - if let Some(store) = self.repos.as_ref() { - self.repos_subscription = Some(cx.observe(store, move |_, store, cx| { - let addrs: Vec = store - .read(cx) - .announcements - .iter() - .map(|a| a.addr()) - .collect(); + // A scanned repository is dropped from the local list + // once the user announces it, so it is not listed twice. + let local = LocalReposStore::global(cx); + let scanning = local.read(cx).scanning; - checkouts.update(cx, |checkouts, cx| { - for addr in addrs { - checkouts.request_push_statuses(&addr, cx); - } - }); - })); + let local_repos = { + let ids: HashSet = announcements.iter().map(|a| a.id.clone()).collect(); + local + .read(cx) + .repos + .iter() + .filter(|path| { + let Some(name) = path.file_name() else { + return true; + }; + !ids.contains(&identifier_from_name(&name.to_string_lossy())) + }) + .cloned() + .collect() + }; + + let announcements_changed = *self.announcements != announcements; + let local_changed = *self.local_repos != local_repos; + let scanning_changed = self.scanning != scanning; + + self.announcements = Arc::new(announcements); + self.local_repos = Arc::new(local_repos); + self.scanning = scanning; + + if announcements_changed { + self.request_push_watches(cx); + self.unpushed.clear(); } + + 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 mut unpushed = HashMap::with_capacity(self.announcements.len()); + + for announcement in self.announcements.iter() { + let addr = announcement.addr(); + let count = checkouts.unpushed(&addr); + if count > 0 { + unpushed.insert(addr, count); + } + } + + if unpushed == self.unpushed { + return false; + } + + self.unpushed = unpushed; + 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| { + for announcement in self.announcements.iter() { + checkouts.request_push_statuses(&announcement.addr(), cx); + } + }); } /// Open the Explore repository list panel in the dock area's center. - /// No-op if it is already open. pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { if self .explore @@ -189,10 +271,9 @@ impl SidebarPanel { } fn render_repos(&self, cx: &mut Context) -> impl IntoElement { - let store = self.repos.as_ref(); - let local = LocalReposStore::global(cx); - let local_repos = local.read(cx).repos.clone(); - let scanning = local.read(cx).scanning; + let announcements = self.announcements.clone(); + let local_repos = self.local_repos.clone(); + let scanning = self.scanning; v_flex() .px_2() @@ -239,27 +320,12 @@ impl SidebarPanel { ), ), ) - .when_some(store, |builder, store| { - let announcements = store.read(cx).announcements.clone(); - let ids: HashSet = announcements.iter().map(|a| a.id.clone()).collect(); - - let local_repos: Vec = local_repos - .iter() - .filter(|path| { - let Some(name) = path.file_name() else { - return true; - }; - !ids.contains(&identifier_from_name(&name.to_string_lossy())) - }) - .cloned() - .collect(); - - // One merged list, the user's NIP-34 repositories first. - // Local repositories discovered by the scan follow. + .map(|this| { + // Merged list, the user's NIP-34 repositories and local repositories discovered. let total = announcements.len() + local_repos.len(); if total == 0 { - builder.child( + this.child( div() .flex_1() .px_2() @@ -275,7 +341,7 @@ impl SidebarPanel { }), ) } else { - builder.child( + this.child( uniform_list( "repos", total, @@ -325,14 +391,11 @@ impl SidebarPanel { let announcement = announcement.clone(); // Badge with the unpushed commit count of the repository's local checkouts. - // The commits are ready to push to the grasp servers. - let checkout = CheckoutsStore::global(cx); - let unpushed = checkout - .read(cx) - .push_statuses_of(&announcement.addr()) - .iter() - .map(|status| status.ahead as usize) - .sum(); + let unpushed = self + .unpushed + .get(&announcement.addr()) + .copied() + .unwrap_or(0); let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar); @@ -352,7 +415,7 @@ impl SidebarPanel { let name = path .file_name() .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()); + .unwrap_or("Untitled".into()); let path = path.to_path_buf(); let avatar = PixelAvatar::new(path.to_string_lossy()); @@ -508,10 +571,6 @@ impl Focusable for SidebarPanel { impl Render for SidebarPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - if !self.logged_in { - return self.render_sign_in(window, cx); - } - let backend = Backend::global(cx); let profile_store = ProfileStore::global(cx); @@ -520,6 +579,10 @@ impl Render for SidebarPanel { .current_user() .map(|public_key| profile_store.read(cx).get(&public_key)); + if profile.is_none() { + return self.render_sign_in(window, cx); + } + v_flex() .size_full() .justify_between()