13 KiB
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 atMAX_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 onRepoUpdates,Synced,CheckoutsStorechanges, andSignerChanged.
Consumers:
SidebarPanel(crates/workspace/src/views/sidebar/mod.rs) observes the store, re-derives a localunsynced: HashMap<RepoAddr, usize>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 readsstatuses_of(addr)insync_suggestionfor the sync-to-Nostr banner.
Problems:
- Invalidation is global: any status change anywhere notifies both the sidebar
and every open repo view, including
panel.cachedones. - The poll keeps probing every announced repo's checkouts even when nothing renders them, and never stops while signed in.
SyncStatusStoreobservesRepoListStoreandCheckoutsStore, 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.observeand 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:
SyncStatusStore(global, thin): probe service + event broadcaster. Holds no statuses.- Keeps an
activeset (addrs ever tracked, cleared onSignerChanged) and an in-flight set for dedupe. probe(addrs): inserts intoactive, schedules one background pass if none is running (requests fold while one is in flight, like today'sRefreshGatebehavior).- 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 atMAX_SYNC_CHECKOUTS). - Emits
SyncEvent::Updated(Vec<(RepoAddr, Vec<CheckoutSyncStatus>)>)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 observesCheckoutsStore(associations changed → probe active). TheRepoListStoreobservation 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
probeneeds only&self. - Polls
activeevery 180s while it is non-empty; no user signed in → clear and idle, same as today.
- Keeps an
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 asProfileDirectory::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<SyncStatusDirectory> 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
// 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<CheckoutSyncStatus>)>),
}
impl EventEmitter<SyncEvent> for SyncStatusStore {}
/// Thin global: probes tracked repos and broadcasts results. Holds no data.
pub struct SyncStatusStore {
active: RefCell<HashSet<RepoAddr>>,
sender: Sender<()>,
in_flight: bool,
pass_pending: bool,
poll_pending: bool,
tasks: Vec<Task<()>>,
_subscriptions: Vec<Subscription>,
}
impl SyncStatusStore {
pub fn global(cx: &App) -> Entity<Self>;
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App);
/// Queue a probe pass; folded into any pass already in flight.
pub fn probe(&self, addrs: impl IntoIterator<Item = RepoAddr>);
}
/// Sync status cache bound to one panel or screen.
pub struct SyncStatusDirectory {
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
pending: RefCell<HashSet<RepoAddr>>,
_subscriptions: Vec<Subscription>,
}
impl SyncStatusDirectory {
/// Create a directory, following the sync store's events.
pub fn new(cx: &mut App) -> Entity<Self>;
/// Queue a probe for addrs this surface renders.
pub fn track(&mut self, addrs: impl IntoIterator<Item = RepoAddr>, cx: &mut Context<Self>);
/// Statuses for a repo, or empty; a miss queues a probe.
pub fn statuses_of(&self, addr: &RepoAddr, cx: &App) -> Vec<CheckoutSyncStatus>;
/// 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<CheckoutSyncStatus>)>, cx: &mut Context<Self>);
}
Behavior details:
probefolds concurrent requests: an in-flight pass setspass_pending, and its completion re-runs once with everything queued meanwhile. Today'sRefreshGatealready provides this shape; the service keeps the semantics without observing three stores to rebuild targets.- The
activeset grows with addrs ever tracked and is cleared onSignerChanged. It is bounded by the repos the user opened or announced in this session, so no ref-counting between directories. applyreplaces an addr's entry only when it differs (the types already derivePartialEq) so unchanged polls do not notify.statuses_of/unsyncedkeep results empty for addrs with no published state or no checkouts, matching today'sstatuses_ofreturningunwrap_or_default().- Wasm32: the service no-ops (
Repo::try_openis 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
signed_state: reshapesync_status.rs— addSyncEvent+EventEmitter, move the probe computation into a pass that emits results, and strip the store ofstatuses,statuses_of,unsynced, andRefreshGate(still used byRepoListStoreandCheckoutsStore, sorefresh.rsstays). TheBackendsubscription and the 180s poll move to the service; theRepoListStoreobservation is dropped.RepoStore::run_pushreplaces its post-pushrefresh(cx)withprobe(addr)so badges update without waiting for the relay pump.signed_state: addSyncStatusDirectoryin the same file and export it fromlib.rs.initkeeps creating the global store.workspace: rewireSidebarPanel(directory +trackinrefresh, deleteunsynced/refresh_unsynced/observe) andRepoView(directory +trackon store-ready,sync_suggestionreads the directory, delete the observe).- 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:
CheckoutsStoreobservation 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
Updatedevent clears directories; a later probe re-runs over the new signer's addrs. - No published state for a repo: the pass skips it (
states.getmiss), 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
activefor the session, which only costs one probe slot per 180s tick.
Testing
- Unit:
SyncStatusDirectorypending-set dedupe and changed-only merge viaTestAppContext, 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/
cachedrepo 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
EventEmitteronly: 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
activeper 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.