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