From 480ee38c4631303c3730bf2e9fd0825e031146ea Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 16 Oct 2026 09:33:39 +0700 Subject: [PATCH] Replace the sync status store with per-view directories --- crates/workspace/src/views/repo/mod.rs | 27 ++- crates/workspace/src/views/sidebar/mod.rs | 48 ++-- docs/SYNC_STATUS_DIRECTORIES.md | 259 ---------------------- 3 files changed, 33 insertions(+), 301 deletions(-) delete mode 100644 docs/SYNC_STATUS_DIRECTORIES.md diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 7546211..fd0e7b4 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -27,7 +27,7 @@ use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_git::{FileCommit, GitCache, RefSync, Repo}; use signed_state::{ Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors, - Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusStore, + Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusDirectory, }; use signed_ui::{ Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger, @@ -96,6 +96,8 @@ pub struct RepoDetailView { tasks: Vec>>, /// Profiles rendered by the header, maintainers and tab title. directory: Entity, + /// Sync status feeding the sync-to-Nostr banner. + sync_directory: Entity, _subscriptions: Vec, } @@ -156,7 +158,6 @@ impl RepoDetailView { cx: &mut Context, ) -> Self { let checkouts = CheckoutsStore::global(cx); - let sync_status = SyncStatusStore::global(cx); let files = cx.new(RepoFilesView::new); let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone())); let refs = RefSwitcher::new(window, cx); @@ -193,11 +194,6 @@ impl RepoDetailView { } })); - // The sync-to-Nostr banner reads the global sync store directly. - subscriptions.push(cx.observe(&sync_status, |_this, _sync, cx| { - cx.notify(); - })); - // Defer loading the repository until the window is ready. cx.defer_in(window, |this, window, cx| { this.load_repo(window, cx); @@ -219,6 +215,7 @@ impl RepoDetailView { banners: Banners::default(), focus_handle: cx.focus_handle(), directory: ProfileDirectory::new(cx), + sync_directory: SyncStatusDirectory::new(cx), _subscriptions: subscriptions, }; @@ -255,6 +252,7 @@ impl RepoDetailView { .push(cx.observe_in(store, window, |this, store, window, cx| { this.refresh_ready_statuses(cx); this.track_profiles(cx); + this.track_sync_status(cx); if !this.repo_started && store.read(cx).announcement.is_some() { this.load_repo(window, cx); } @@ -292,6 +290,17 @@ impl RepoDetailView { }); } + /// Track the repository address so the sync banner stays current. + fn track_sync_status(&mut self, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + self.sync_directory.update(cx, |directory, cx| { + directory.track([addr], cx); + }); + } + fn refresh_ready_statuses(&mut self, cx: &mut Context) { let Some(addr) = self.store.read(cx).addr().cloned() else { return; @@ -349,6 +358,7 @@ impl RepoDetailView { self.loading = true; self.error = None; self.track_profiles(cx); + self.track_sync_status(cx); cx.notify(); let (announcement, local_path) = { @@ -1602,8 +1612,7 @@ impl RepoDetailView { return None; } - let sync_status = SyncStatusStore::global(cx); - let statuses = sync_status.read(cx).statuses_of(addr); + let statuses = self.sync_directory.read(cx).statuses_of(addr, cx); statuses.into_iter().find(|checkout| { checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout) diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 0c12d01..1888d12 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -25,7 +25,7 @@ use nostr::prelude::{PublicKey, RelayUrl}; use signed_core::{Announcement, RepoAddr}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore, - ResolvedLocalRepo, SyncStatusStore, + ResolvedLocalRepo, SyncStatusDirectory, }; use signed_ui::{Avatar, NavItem, title_bar_drag_handlers}; @@ -68,10 +68,10 @@ pub struct SidebarPanel { local_repos: Arc>, /// Unpushed commit counts per announced repository. unpushed: HashMap, - /// Commits not on Nostr per announced repository. - unsynced: HashMap, /// Profiles rendered by the sidebar. directory: Entity, + /// Sync statuses for the announced repositories. + sync_directory: Entity, _subscriptions: Vec, } @@ -81,7 +81,6 @@ impl SidebarPanel { let repos = RepoListStore::global(cx); let local = LocalReposStore::global(cx); let checkouts = CheckoutsStore::global(cx); - let sync_status = SyncStatusStore::global(cx); let mut subscriptions = Vec::new(); @@ -119,12 +118,6 @@ impl SidebarPanel { } })); - subscriptions.push(cx.observe(&sync_status, |this, _sync, cx| { - if this.refresh_unsynced(cx) { - cx.notify(); - } - })); - Self { focus_handle: cx.focus_handle(), dock_area, @@ -137,8 +130,8 @@ impl SidebarPanel { local_repos: Arc::new(Vec::new()), scanning: false, unpushed: HashMap::new(), - unsynced: HashMap::new(), directory: ProfileDirectory::new(cx), + sync_directory: SyncStatusDirectory::new(cx), _subscriptions: subscriptions, } } @@ -189,10 +182,19 @@ impl SidebarPanel { directory.track(profiles, cx); }); + let addrs: Vec = self + .announcements + .iter() + .map(|announcement| announcement.addr()) + .collect(); + + self.sync_directory.update(cx, |directory, cx| { + directory.track(addrs, cx); + }); + if announcements_changed { self.request_push_watches(cx); self.unpushed.clear(); - self.unsynced.clear(); } announcements_changed || local_changed || scanning_changed @@ -218,26 +220,6 @@ impl SidebarPanel { true } - fn refresh_unsynced(&mut self, cx: &mut Context) -> bool { - let sync_status = SyncStatusStore::global(cx); - let mut unsynced = HashMap::with_capacity(self.announcements.len()); - - for announcement in self.announcements.iter() { - let addr = announcement.addr(); - let count = sync_status.read(cx).unsynced(&addr); - if count > 0 { - unsynced.insert(addr, count); - } - } - - if unsynced == self.unsynced { - return false; - } - - self.unsynced = unsynced; - true - } - fn request_push_watches(&self, cx: &mut Context) { let checkouts = CheckoutsStore::global(cx); checkouts.update(cx, |checkouts, cx| { @@ -538,7 +520,7 @@ impl SidebarPanel { let addr = announcement.addr(); let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0); - let unsynced = self.unsynced.get(&addr).copied().unwrap_or(0); + let unsynced = self.sync_directory.read(cx).unsynced(&addr, cx); let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar); diff --git a/docs/SYNC_STATUS_DIRECTORIES.md b/docs/SYNC_STATUS_DIRECTORIES.md deleted file mode 100644 index 24f436c..0000000 --- a/docs/SYNC_STATUS_DIRECTORIES.md +++ /dev/null @@ -1,259 +0,0 @@ -# Per-view sync status directories - -Status: proposal -Follows the pattern established in the profile metadata refactor (see git history: -`bind user metadata to per-view directories`). - -## Background - -`SyncStatusStore` (`crates/signed_state/src/sync_status.rs`) is a single global -entity: - -- Computes, per announced repo, how far each associated checkout is from the - repo state published on Nostr (`CheckoutSyncStatus { path, status }`). -- Targets are derived from three stores: `RepoListStore::announcements_of(user)` - × `CheckoutsStore::associations_of(addr)`, capped at `MAX_SYNC_CHECKOUTS`. -- Each refresh queries deletions + one state filter per addr from the local - database, then opens every checkout on disk (`Repo::try_open` + - `sync_status`) on the background thread. -- Re-runs on a 180s poll (`SYNC_POLL`) forever once signed in, and on - `RepoUpdates`, `Synced`, `CheckoutsStore` changes, and `SignerChanged`. - -Consumers: - -- `SidebarPanel` (`crates/workspace/src/views/sidebar/mod.rs`) observes the - store, re-derives a local `unsynced: HashMap` on every - store notify, and compares it to the previous map to decide whether to - repaint. -- `RepoView` (`crates/workspace/src/views/repo/mod.rs`) observes the store - purely to re-render, and reads `statuses_of(addr)` in `sync_suggestion` for - the sync-to-Nostr banner. - -Problems: - -- Invalidation is global: any status change anywhere notifies both the sidebar - and every open repo view, including `panel.cached` ones. -- The poll keeps probing every announced repo's checkouts even when nothing - renders them, and never stops while signed in. -- `SyncStatusStore` observes `RepoListStore` and `CheckoutsStore`, so the - stores form a dependency web where each change re-derives the full target - set. - -Honest limits: while the sidebar is alive (always, once signed in) it tracks -every announced repo, so total probing work is similar to today. The wins are -scoped invalidation, deleting the sidebar's derived-map + observe boilerplate, -removing the store-to-store observation edges, and establishing the service + -directory seam that a later `CheckoutsStore` split can reuse. - -## Goals - -- Sync status data is owned per surface. A directory dies with its panel. -- A status change re-renders only the surfaces tracking that repo: reads - during render are tracked by the window, so no `cx.observe` and no derived - maps are needed. -- One shared probe pipeline: in-flight dedupe and a single poll owned by the - service, so N directories still produce one probe pass per tick. -- Directories track explicit addrs (what the surface renders) instead of the - service deriving targets from the whole repo list. - -Non-goals: changing `CheckoutSyncStatus`, the probe computation itself, or -`CheckoutsStore` (the `unpushed` badge stays where it is); persistence. - -## Design - -Two pieces replace the fat global: - -1. `SyncStatusStore` (global, thin): probe service + event broadcaster. - Holds no statuses. - - Keeps an `active` set (addrs ever tracked, cleared on `SignerChanged`) - and an in-flight set for dedupe. - - `probe(addrs)`: inserts into `active`, schedules one background pass if - none is running (requests fold while one is in flight, like today's - `RefreshGate` behavior). - - A pass resolves each active addr's checkout paths from - `CheckoutsStore`, then runs today's computation unchanged (deletions + - state filters from the local database, `utils::latest`, - `SyncState::parse`, git probing capped at `MAX_SYNC_CHECKOUTS`). - - Emits `SyncEvent::Updated(Vec<(RepoAddr, Vec)>)` - after each pass. Unlike the profile design, the event carries results: - directories cannot cheaply recompute git statuses, so re-querying per - directory would multiply the expensive part. - - Subscribes to `Backend` (`RepoUpdates`/`Synced` → probe active; - `SignerChanged` → clear active, emit empty so directories clear) and - observes `CheckoutsStore` (associations changed → probe active). The - `RepoListStore` observation edge disappears: targets now come from - directories, and announcement refreshes re-track through the sidebar's - own data path. - - Probe requests reach the loop over the same flume-channel wake the - profile fetch service uses, so `probe` needs only `&self`. - - Polls `active` every 180s while it is non-empty; no user signed in → - clear and idle, same as today. -2. `SyncStatusDirectory` (per surface entity): the statuses one surface - renders. - - `track(addrs, cx)`: queues a probe for addrs it does not cache yet - (pending set guards until the first event). - - `statuses_of(addr, cx)` / `unsynced(addr, cx)`: cached value or empty; a - miss marks the addr pending and queues a probe (same call shape as - `ProfileDirectory::profile`). - - Subscribes to `SyncEvent::Updated`, keeps only tracked addrs, replaces - its entries when they differ, notifies itself once per event if anything - changed. - - Stores its own tasks and subscription; both drop with the panel. - -``` -sidebar ──track(announced addrs)──┐ repo view ──track(addr)──┐ - ▲ ▼ ▲ ▼ - │ cx.notify() SyncStatusDirectory ──┴── SyncEvent::Updated ── SyncStatusStore (global) - │ ▲ │ - │ │ keep tracked addrs probe pass: DB query + - └──────────────────────────────┘ git probing (MAX_SYNC_CHECKOUTS) - BackendEvent::RepoUpdates / Synced │ - checkout associations changed ▼ - relay pump / local git ────► local database + worktrees -``` - -Surfaces own `Entity` and read it directly in render; no -`cx.observe` is needed. gpui tracks which entities a view reads while drawing -and invalidates exactly those views when the directory notifies, the same -mechanism the profile directories rely on, and it composes with -`panel.cached(...)`. - -## API sketch - -```rust -// signed_state/src/sync_status.rs - -/// Statuses for these repositories may have changed; empty on signer change. -#[derive(Debug, Clone)] -pub enum SyncEvent { - Updated(Vec<(RepoAddr, Vec)>), -} - -impl EventEmitter for SyncStatusStore {} - -/// Thin global: probes tracked repos and broadcasts results. Holds no data. -pub struct SyncStatusStore { - active: RefCell>, - sender: Sender<()>, - in_flight: bool, - pass_pending: bool, - poll_pending: bool, - tasks: Vec>, - _subscriptions: Vec, -} - -impl SyncStatusStore { - pub fn global(cx: &App) -> Entity; - pub(crate) fn set_global(entity: Entity, cx: &mut App); - /// Queue a probe pass; folded into any pass already in flight. - pub fn probe(&self, addrs: impl IntoIterator); -} - -/// Sync status cache bound to one panel or screen. -pub struct SyncStatusDirectory { - statuses: HashMap>, - pending: RefCell>, - _subscriptions: Vec, -} - -impl SyncStatusDirectory { - /// Create a directory, following the sync store's events. - pub fn new(cx: &mut App) -> Entity; - /// Queue a probe for addrs this surface renders. - pub fn track(&mut self, addrs: impl IntoIterator, cx: &mut Context); - /// Statuses for a repo, or empty; a miss queues a probe. - pub fn statuses_of(&self, addr: &RepoAddr, cx: &App) -> Vec; - /// Commits available locally but missing on Nostr. - pub fn unsynced(&self, addr: &RepoAddr, cx: &App) -> usize; - /// Keeps only tracked addrs, replaces changed entries, notifies once. - fn apply(&mut self, updates: Vec<(RepoAddr, Vec)>, cx: &mut Context); -} -``` - -Behavior details: - -- `probe` folds concurrent requests: an in-flight pass sets `pass_pending`, - and its completion re-runs once with everything queued meanwhile. Today's - `RefreshGate` already provides this shape; the service keeps the semantics - without observing three stores to rebuild targets. -- The `active` set grows with addrs ever tracked and is cleared on - `SignerChanged`. It is bounded by the repos the user opened or announced in - this session, so no ref-counting between directories. -- `apply` replaces an addr's entry only when it differs (the types already - derive `PartialEq`) so unchanged polls do not notify. -- `statuses_of`/`unsynced` keep results empty for addrs with no published - state or no checkouts, matching today's `statuses_of` returning - `unwrap_or_default()`. -- Wasm32: the service no-ops (`Repo::try_open` is native-only today; the - current store gates its subscriptions the same way), directories stay empty. - -## Where directories live - -| Surface | File | Addrs tracked | When | -| --- | --- | --- | --- | -| `SidebarPanel` | `views/sidebar/mod.rs` | announced repo addrs | in `refresh`, alongside the `announcements` rebuild; the `unsynced` map, `refresh_unsynced`, and the store observe go away | -| `RepoView` | `views/repo/mod.rs` | its own store addr | once the addr is known (store-ready path); `statuses_of` lazily queues as a fallback, and the store observe goes away | - -No free helpers read the sync store today, so no signatures change beyond -these two views. - -## Migration steps - -1. `signed_state`: reshape `sync_status.rs` — add `SyncEvent` + - `EventEmitter`, move the probe computation into a pass that emits results, - and strip the store of `statuses`, `statuses_of`, `unsynced`, and - `RefreshGate` (still used by `RepoListStore` and `CheckoutsStore`, so - `refresh.rs` stays). The `Backend` subscription and the 180s poll move to - the service; the `RepoListStore` observation is dropped. `RepoStore::run_push` - replaces its post-push `refresh(cx)` with `probe(addr)` so badges update - without waiting for the relay pump. -2. `signed_state`: add `SyncStatusDirectory` in the same file and export it - from `lib.rs`. `init` keeps creating the global store. -3. `workspace`: rewire `SidebarPanel` (directory + `track` in `refresh`, - delete `unsynced`/`refresh_unsynced`/observe) and `RepoView` (directory + - `track` on store-ready, `sync_suggestion` reads the directory, delete the - observe). -4. Delete the old read API last; the compiler walks the remaining call sites. - The workspace crate will not compile between steps 1 and 3, so this lands - as one change. - -## Edge cases - -- **Two surfaces tracking the same repo**: both directories cache the entry; - the service dedupes probing via the in-flight pass, and the event fans out. -- **Checkout association added mid-pass**: `CheckoutsStore` observation - schedules a follow-up pass, so the new path is picked up promptly, not on - the next 180s tick. -- **Signer change mid-pass**: the in-flight pass is left alone so its - completion releases the slot exactly once; its stale results are dropped by - directories because the addrs are no longer tracked. The empty `Updated` - event clears directories; a later probe re-runs over the new signer's - addrs. -- **No published state for a repo**: the pass skips it (`states.get` miss), - statuses stay empty, and the pending set is released like today's - missing-entry behavior. -- **Panel closed**: directory drops → subscription and cache drop; the - service keeps the addr in `active` for the session, which only costs one - probe slot per 180s tick. - -## Testing - -- Unit: `SyncStatusDirectory` pending-set dedupe and changed-only merge via - `TestAppContext`, seeding the directory through a `#[cfg(test)]` helper - rather than the probe pipeline. -- Manual: open the sidebar with a local checkout ahead of Nostr → badge - appears; open that repo → sync banner appears; confirm unrelated repos' - changes do not repaint a closed/`cached` repo tab (profile directories are - the reference behavior). - -## Alternatives considered - -- **Directories probe themselves, service only broadcasts keys** (the exact - profile shape): rejected because each directory would redo the database - queries and git probing; the event must carry results here. -- **Keep the global store, add `EventEmitter` only**: scoped events would - stop cross-surface repaints but keep the derived maps, the observe - boilerplate, and the forever-poll over all announced repos. -- **Ref-count `active` per directory** so polling stops when the last - directory for an addr drops: more bookkeeping for no observable gain while - the sidebar exists; revisit if a surface ever tracks repos transiently.