# 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>, 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 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.