From 7be393551592422f677adf7dd24d9a9358349e02 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 15 Oct 2026 17:38:52 +0700 Subject: [PATCH] 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.