Files
signed/docs/SYNC_STATUS_DIRECTORIES.md
T
2026-10-15 17:38:52 +07:00

12 KiB
Raw Blame History

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<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 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<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 + 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<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

/// Fresh statuses for these repositories; 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>>,
    in_flight: RefCell<bool>,
    pass_pending: RefCell<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>>,
    tasks: Vec<Task<()>>,
    _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:

  • 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.