add plan
This commit is contained in:
@@ -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<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
|
||||
|
||||
```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<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.
|
||||
Reference in New Issue
Block a user