From 7be393551592422f677adf7dd24d9a9358349e02 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 15 Oct 2026 17:38:52 +0700 Subject: [PATCH 1/4] add plan --- docs/SYNC_STATUS_DIRECTORIES.md | 253 ++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/SYNC_STATUS_DIRECTORIES.md diff --git a/docs/SYNC_STATUS_DIRECTORIES.md b/docs/SYNC_STATUS_DIRECTORIES.md new file mode 100644 index 0000000..7d54270 --- /dev/null +++ b/docs/SYNC_STATUS_DIRECTORIES.md @@ -0,0 +1,253 @@ +# 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 + in-flight, 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. + - 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 + +/// Fresh statuses for these repositories; 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>, + in_flight: RefCell, + pass_pending: RefCell, + 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>, + tasks: Vec>, + _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 service method 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. +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**: directories clear on the empty `Updated` event; + a late result for an untracked addr is dropped by `apply`. +- **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. -- 2.54.0 From 2cd1068351bff7f6b410cc6da42d764676babb5d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 15 Oct 2026 18:51:10 +0700 Subject: [PATCH 2/4] Reshape SyncStatusStore into a thin probe-and-emit store --- crates/signed_state/src/lib.rs | 2 +- crates/signed_state/src/repo.rs | 8 +- crates/signed_state/src/sync_status.rs | 246 ++++++++++++------------- docs/SYNC_STATUS_DIRECTORIES.md | 39 ++-- 4 files changed, 154 insertions(+), 141 deletions(-) diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 2f9e15e..2c52b8c 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -29,7 +29,7 @@ pub use repos::RepoListStore; pub use signed_git::{Nip34Binding, Nip34Kind}; use signed_nostr::NostrBackend; pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState}; -pub use sync_status::{CheckoutSyncStatus, SyncStatusStore}; +pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusStore}; pub fn init( db_path: impl AsRef, diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index a1b29d6..c98ac0b 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1226,6 +1226,7 @@ impl RepoStore { Ok(outcome) => { this.last_error = None; this.last_push_warning = outcome.partial_warning(); + if let Some((addr, path)) = &pushed_checkout { let checkouts = CheckoutsStore::global(cx); checkouts.update(cx, |store, cx| { @@ -1233,8 +1234,13 @@ impl RepoStore { }); } + // Re-probe so sync badges reflect the pushed state. let sync_status = SyncStatusStore::global(cx); - sync_status.update(cx, |store, cx| store.refresh(cx)); + let addr = pushed_checkout + .as_ref() + .map(|(addr, _)| addr.clone()) + .or_else(|| this.addr.clone()); + sync_status.read(cx).probe(addr); } Err(e) => { this.last_error = Some(format!("Push failed: {e}")); diff --git a/crates/signed_state/src/sync_status.rs b/crates/signed_state/src/sync_status.rs index 2c26fbb..a534f99 100644 --- a/crates/signed_state/src/sync_status.rs +++ b/crates/signed_state/src/sync_status.rs @@ -1,18 +1,17 @@ -use std::collections::HashMap; +use std::cell::RefCell; +use std::collections::HashSet; use std::path::PathBuf; use std::time::Duration; use anyhow::Error; -use gpui::{App, AppContext, Context, Entity, Global, Subscription}; -use nostr_sdk::prelude::*; +use flume::Sender; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; use signed_core::{Deletions, Filters, RepoAddr}; use signed_git::{Repo, RepoSyncStatus}; use crate::backend::{Backend, BackendEvent}; use crate::checkouts::CheckoutsStore; -use crate::refresh::{RefreshGate, RefreshRequest}; -use crate::repos::RepoListStore; -use crate::state::{GitRef, SyncState}; +use crate::state::SyncState; const SYNC_POLL: Duration = Duration::from_secs(180); const MAX_SYNC_CHECKOUTS: usize = 8; @@ -28,13 +27,25 @@ pub struct CheckoutSyncStatus { pub status: RepoSyncStatus, } +/// Statuses for these repositories may have changed; empty on signer change. +#[derive(Debug, Clone)] +pub enum SyncEvent { + Updated(Vec<(RepoAddr, Vec)>), +} + +/// Probes tracked repositories and broadcasts results. Holds no statuses. pub struct SyncStatusStore { - statuses: HashMap>, - refresh: RefreshGate, + active: RefCell>, + in_flight: bool, + pass_pending: bool, poll_pending: bool, + sender: Sender<()>, + tasks: Vec>>, _subscriptions: Vec, } +impl EventEmitter for SyncStatusStore {} + impl SyncStatusStore { pub fn global(cx: &App) -> Entity { cx.global::().0.clone() @@ -45,150 +56,131 @@ impl SyncStatusStore { } pub fn new(cx: &mut Context) -> Self { + let (sender, receiver) = flume::unbounded::<()>(); let mut subscriptions = Vec::new(); if !cfg!(target_arch = "wasm32") { - let repos = RepoListStore::global(cx); - let checkouts = CheckoutsStore::global(cx); let backend = Backend::global(cx); - - subscriptions.push(cx.observe(&repos, |this, _repos, cx| { - this.refresh(cx); - })); + let checkouts = CheckoutsStore::global(cx); // Checkout associations decide which paths get compared. subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { - this.refresh(cx); + this.run_pass(cx); })); subscriptions.push( cx.subscribe(&backend, |this, _backend, event, cx| match event { - BackendEvent::SignerChanged => { - this.statuses.clear(); - cx.notify(); - this.refresh(cx); - } - BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.refresh(cx), + BackendEvent::SignerChanged => this.clear(cx), + BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.run_pass(cx), _ => {} }), ); - - let weak = cx.entity().downgrade(); - cx.defer(move |cx| { - if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) { - log::warn!("sync status store dropped before the initial refresh: {error}"); - } - }); } + let task = cx.spawn(async move |this, cx| { + loop { + receiver.recv_async().await?; + + // One pass covers every signal queued since the wake. + while receiver.try_recv().is_ok() {} + + this.update(cx, |this, cx| { + this.run_pass(cx); + })?; + } + }); + Self { - statuses: HashMap::new(), - refresh: RefreshGate::default(), + active: RefCell::new(HashSet::new()), + sender, + in_flight: false, + pass_pending: false, poll_pending: false, + tasks: vec![task], _subscriptions: subscriptions, } } - /// Latest sync status per associated checkout. - pub fn statuses_of(&self, addr: &RepoAddr) -> Vec { - self.statuses.get(addr).cloned().unwrap_or_default() + /// Queue a probe pass; folded into any pass already in flight. + pub fn probe(&self, addrs: impl IntoIterator) { + self.active.borrow_mut().extend(addrs); + + if let Err(e) = self.sender.send(()) { + log::warn!("failed to queue sync status probe: {e}"); + } } - /// Commits available locally but missing on Nostr. - pub fn unsynced(&self, addr: &RepoAddr) -> usize { - self.statuses - .get(addr) - .map(|list| { - list.iter() - .map(|checkout| checkout.status.ahead_total) - .sum() - }) - .unwrap_or(0) + // Keeps the in-flight pass so its completion releases the slot exactly once. + fn clear(&mut self, cx: &mut Context) { + let had_addrs = !self.active.borrow().is_empty(); + self.active.borrow_mut().clear(); + self.pass_pending = false; + + if had_addrs { + cx.emit(SyncEvent::Updated(Vec::new())); + } } - pub fn refresh(&mut self, cx: &mut Context) { + fn run_pass(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { return; } - if self.refresh.request() != RefreshRequest::Schedule { + + if self.in_flight { + self.pass_pending = true; return; } - let backend = Backend::global(cx); - let Some(user) = backend.read(cx).current_user() else { - let changed = !self.statuses.is_empty(); - self.statuses.clear(); - if changed { - cx.notify(); - } - return; - }; - - self.schedule_poll(cx); - self.run_refresh(user, cx); - } - - fn schedule_poll(&mut self, cx: &mut Context) { - if self.poll_pending { - return; - } - self.poll_pending = true; - - cx.spawn(async move |this, cx| { - cx.background_executor().timer(SYNC_POLL).await; - this.update(cx, |this, cx| { - this.poll_pending = false; - this.refresh(cx); - }) - }) - .detach(); - } - - fn run_refresh(&mut self, user: PublicKey, cx: &mut Context) { - self.refresh.begin(); - let backend = Backend::global(cx); let client = backend.read(cx).client(); - let repos = RepoListStore::global(cx); - let announcements = repos.read(cx).announcements_of(&user); - let checkouts = CheckoutsStore::global(cx); + if backend.read(cx).current_user().is_none() { + self.clear(cx); + return; + } - let targets: Vec<(RepoAddr, Vec)> = announcements - .iter() - .map(|announcement| { - let addr = announcement.addr(); - let paths = checkouts.read(cx).associations_of(&addr); - (addr, paths) - }) - .collect(); + if self.active.borrow().is_empty() { + return; + } + + self.in_flight = true; + self.schedule_poll(cx); + + let targets: Vec<(RepoAddr, Vec)> = { + let active = self.active.borrow(); + let checkouts = CheckoutsStore::global(cx); + + active + .iter() + .map(|addr| { + let paths = checkouts.read(cx).associations_of(addr); + (addr.clone(), paths) + }) + .collect() + }; let work = cx.background_spawn(async move { let db = client.database(); let deletion_events = db.query(Filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); - let mut states: HashMap> = HashMap::new(); - for (addr, _) in &targets { + let mut updates: Vec<(RepoAddr, Vec)> = Vec::new(); + + for (addr, paths) in &targets { let events = db.query(addr.state_filter()).await?; let latest = utils::latest( events .into_iter() .filter(|event| !deletions.is_deleted(event)), ); + let Some(event) = latest else { - continue; - }; - states.insert(addr.clone(), SyncState::parse(&event).refs); - } - - let mut statuses: HashMap> = HashMap::new(); - for (addr, paths) in &targets { - let Some(nostr_refs) = states.get(addr) else { + updates.push((addr.clone(), Vec::new())); continue; }; - let remote_refs: Vec<(String, String)> = nostr_refs + let remote_refs: Vec<(String, String)> = SyncState::parse(&event) + .refs .iter() .map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string())) .collect(); @@ -206,41 +198,49 @@ impl SyncStatusStore { } } - if !list.is_empty() { - statuses.insert(addr.clone(), list); - } + updates.push((addr.clone(), list)); } - Ok::<_, Error>(statuses) + Ok::<_, Error>(updates) }); - cx.spawn(async move |this, cx| { - let statuses = match work.await { - Ok(statuses) => statuses, - Err(error) => { - log::warn!("failed to compute nostr sync statuses: {error}"); - return this.update(cx, |this, _cx| { - this.refresh.abort(); - }); - } - }; + self.tasks.push(cx.spawn(async move |this, cx| { + let updates = work.await?; + let mut again = false; - let again = this.update(cx, |this, cx| { - if this.statuses != statuses { - this.statuses = statuses; - cx.notify(); + this.update(cx, |this, cx| { + this.in_flight = false; + if !updates.is_empty() { + cx.emit(SyncEvent::Updated(updates)); } - this.refresh.finish() + again = std::mem::take(&mut this.pass_pending); })?; if again { this.update(cx, |this, cx| { - this.refresh(cx); + this.run_pass(cx); })?; } - Ok::<_, Error>(()) - }) - .detach(); + Ok::<(), anyhow::Error>(()) + })); + } + + fn schedule_poll(&mut self, cx: &mut Context) { + if self.poll_pending { + return; + } + self.poll_pending = true; + + self.tasks.push(cx.spawn(async move |this, cx| { + cx.background_executor().timer(SYNC_POLL).await; + + this.update(cx, |this, cx| { + this.poll_pending = false; + this.run_pass(cx); + })?; + + Ok::<(), anyhow::Error>(()) + })); } } diff --git a/docs/SYNC_STATUS_DIRECTORIES.md b/docs/SYNC_STATUS_DIRECTORIES.md index 7d54270..392ddef 100644 --- a/docs/SYNC_STATUS_DIRECTORIES.md +++ b/docs/SYNC_STATUS_DIRECTORIES.md @@ -79,11 +79,13 @@ Two pieces replace the fat global: 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 + in-flight, 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. + `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 @@ -121,7 +123,7 @@ mechanism the profile directories rely on, and it composes with ```rust // signed_state/src/sync_status.rs -/// Fresh statuses for these repositories; empty on signer change. +/// Statuses for these repositories may have changed; empty on signer change. #[derive(Debug, Clone)] pub enum SyncEvent { Updated(Vec<(RepoAddr, Vec)>), @@ -132,8 +134,9 @@ impl EventEmitter for SyncStatusStore {} /// Thin global: probes tracked repos and broadcasts results. Holds no data. pub struct SyncStatusStore { active: RefCell>, - in_flight: RefCell, - pass_pending: RefCell, + sender: Sender<()>, + in_flight: bool, + pass_pending: bool, poll_pending: bool, tasks: Vec>, _subscriptions: Vec, @@ -198,12 +201,13 @@ these two views. ## Migration steps 1. `signed_state`: reshape `sync_status.rs` — add `SyncEvent` + - `EventEmitter`, move the probe computation into a service method 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. + `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`, @@ -221,8 +225,11 @@ these two views. - **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**: directories clear on the empty `Updated` event; - a late result for an untracked addr is dropped by `apply`. +- **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. -- 2.54.0 From d4a7003a9e5ce21d1705326c51ee54d90f92c82b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 15 Oct 2026 19:38:59 +0700 Subject: [PATCH 3/4] Add SyncStatusDirectory for per-surface status caching --- crates/signed_state/src/lib.rs | 2 +- crates/signed_state/src/sync_status.rs | 125 ++++++++++++++++++++++++- docs/SYNC_STATUS_DIRECTORIES.md | 1 - 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 2c52b8c..c757f22 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -29,7 +29,7 @@ pub use repos::RepoListStore; pub use signed_git::{Nip34Binding, Nip34Kind}; use signed_nostr::NostrBackend; pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState}; -pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusStore}; +pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore}; pub fn init( db_path: impl AsRef, diff --git a/crates/signed_state/src/sync_status.rs b/crates/signed_state/src/sync_status.rs index a534f99..1ce345c 100644 --- a/crates/signed_state/src/sync_status.rs +++ b/crates/signed_state/src/sync_status.rs @@ -1,5 +1,5 @@ use std::cell::RefCell; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::time::Duration; @@ -205,7 +205,15 @@ impl SyncStatusStore { }); self.tasks.push(cx.spawn(async move |this, cx| { - let updates = work.await?; + // Reset the slot even on failure, or every later pass folds forever. + let updates = match work.await { + Ok(updates) => updates, + Err(error) => { + log::warn!("failed to compute nostr sync statuses: {error}"); + Vec::new() + } + }; + let mut again = false; this.update(cx, |this, cx| { @@ -244,3 +252,116 @@ impl SyncStatusStore { })); } } + +/// 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 { + let store = SyncStatusStore::global(cx); + + cx.new(|cx| { + let subscription = + cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event { + SyncEvent::Updated(updates) => this.apply(updates.clone(), cx), + }); + + Self { + statuses: HashMap::new(), + pending: RefCell::new(HashSet::new()), + _subscriptions: vec![subscription], + } + }) + } + + /// Queue a probe for addrs this surface renders. + pub fn track(&mut self, addrs: impl IntoIterator, cx: &mut Context) { + let missing: Vec = addrs + .into_iter() + .filter(|addr| { + !self.statuses.contains_key(addr) + && !self.with_pending(|pending| pending.contains(addr)) + }) + .collect(); + + if missing.is_empty() { + return; + } + + self.with_pending(|pending| pending.extend(missing.iter().cloned())); + + let store = SyncStatusStore::global(cx); + store.read(cx).probe(missing); + } + + /// Statuses for a repo, or empty; a miss queues a probe. + pub fn statuses_of(&self, addr: &RepoAddr, cx: &App) -> Vec { + self.queue(addr, cx); + self.statuses.get(addr).cloned().unwrap_or_default() + } + + /// Commits available locally but missing on Nostr. + pub fn unsynced(&self, addr: &RepoAddr, cx: &App) -> usize { + self.queue(addr, cx); + + self.statuses + .get(addr) + .map(|list| { + list.iter() + .map(|checkout| checkout.status.ahead_total) + .sum() + }) + .unwrap_or(0) + } + + fn queue(&self, addr: &RepoAddr, cx: &App) { + if self.with_pending(|pending| pending.insert(addr.clone())) { + let store = SyncStatusStore::global(cx); + store.read(cx).probe([addr.clone()]); + } + } + + fn apply(&mut self, updates: Vec<(RepoAddr, Vec)>, cx: &mut Context) { + if updates.is_empty() { + let changed = !self.statuses.is_empty(); + self.statuses.clear(); + self.with_pending(|pending| pending.clear()); + + if changed { + cx.notify(); + } + + return; + } + + let mut changed = false; + + for (addr, list) in updates { + let relevant = self.statuses.contains_key(&addr) + || self.with_pending(|pending| pending.remove(&addr)); + + if !relevant { + continue; + } + + if self.statuses.get(&addr) != Some(&list) { + self.statuses.insert(addr, list); + changed = true; + } + } + + if changed { + cx.notify(); + } + } + + fn with_pending(&self, then: impl FnOnce(&mut HashSet) -> R) -> R { + let mut pending = self.pending.borrow_mut(); + then(&mut pending) + } +} diff --git a/docs/SYNC_STATUS_DIRECTORIES.md b/docs/SYNC_STATUS_DIRECTORIES.md index 392ddef..24f436c 100644 --- a/docs/SYNC_STATUS_DIRECTORIES.md +++ b/docs/SYNC_STATUS_DIRECTORIES.md @@ -153,7 +153,6 @@ impl SyncStatusStore { pub struct SyncStatusDirectory { statuses: HashMap>, pending: RefCell>, - tasks: Vec>, _subscriptions: Vec, } -- 2.54.0 From 480ee38c4631303c3730bf2e9fd0825e031146ea Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 16 Oct 2026 09:33:39 +0700 Subject: [PATCH 4/4] 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. -- 2.54.0