Replace the sync status store with per-view directories
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Rust / build (macos-latest, stable) (pull_request) Canceled after 0s
Rust / build (ubuntu-latest, stable) (pull_request) Canceled after 0s
Rust / build (windows-latest, stable) (pull_request) Canceled after 0s

This commit is contained in:
2026-10-16 09:33:39 +07:00
parent d4a7003a9e
commit 480ee38c46
3 changed files with 33 additions and 301 deletions
+18 -9
View File
@@ -27,7 +27,7 @@ use signed_core::{Announcement, RepoAddr, RepoStatus};
use signed_git::{FileCommit, GitCache, RefSync, Repo};
use signed_state::{
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusStore,
Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusDirectory,
};
use signed_ui::{
Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger,
@@ -96,6 +96,8 @@ pub struct RepoDetailView {
tasks: Vec<Task<Result<(), Error>>>,
/// Profiles rendered by the header, maintainers and tab title.
directory: Entity<ProfileDirectory>,
/// Sync status feeding the sync-to-Nostr banner.
sync_directory: Entity<SyncStatusDirectory>,
_subscriptions: Vec<Subscription>,
}
@@ -156,7 +158,6 @@ impl RepoDetailView {
cx: &mut Context<Self>,
) -> Self {
let checkouts = CheckoutsStore::global(cx);
let sync_status = SyncStatusStore::global(cx);
let files = cx.new(RepoFilesView::new);
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
let refs = RefSwitcher::new(window, cx);
@@ -193,11 +194,6 @@ impl RepoDetailView {
}
}));
// The sync-to-Nostr banner reads the global sync store directly.
subscriptions.push(cx.observe(&sync_status, |_this, _sync, cx| {
cx.notify();
}));
// Defer loading the repository until the window is ready.
cx.defer_in(window, |this, window, cx| {
this.load_repo(window, cx);
@@ -219,6 +215,7 @@ impl RepoDetailView {
banners: Banners::default(),
focus_handle: cx.focus_handle(),
directory: ProfileDirectory::new(cx),
sync_directory: SyncStatusDirectory::new(cx),
_subscriptions: subscriptions,
};
@@ -255,6 +252,7 @@ impl RepoDetailView {
.push(cx.observe_in(store, window, |this, store, window, cx| {
this.refresh_ready_statuses(cx);
this.track_profiles(cx);
this.track_sync_status(cx);
if !this.repo_started && store.read(cx).announcement.is_some() {
this.load_repo(window, cx);
}
@@ -292,6 +290,17 @@ impl RepoDetailView {
});
}
/// Track the repository address so the sync banner stays current.
fn track_sync_status(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.store.read(cx).addr().cloned() else {
return;
};
self.sync_directory.update(cx, |directory, cx| {
directory.track([addr], cx);
});
}
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.store.read(cx).addr().cloned() else {
return;
@@ -349,6 +358,7 @@ impl RepoDetailView {
self.loading = true;
self.error = None;
self.track_profiles(cx);
self.track_sync_status(cx);
cx.notify();
let (announcement, local_path) = {
@@ -1602,8 +1612,7 @@ impl RepoDetailView {
return None;
}
let sync_status = SyncStatusStore::global(cx);
let statuses = sync_status.read(cx).statuses_of(addr);
let statuses = self.sync_directory.read(cx).statuses_of(addr, cx);
statuses.into_iter().find(|checkout| {
checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout)
+15 -33
View File
@@ -25,7 +25,7 @@ use nostr::prelude::{PublicKey, RelayUrl};
use signed_core::{Announcement, RepoAddr};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
ResolvedLocalRepo, SyncStatusStore,
ResolvedLocalRepo, SyncStatusDirectory,
};
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
@@ -68,10 +68,10 @@ pub struct SidebarPanel {
local_repos: Arc<Vec<ResolvedLocalRepo>>,
/// Unpushed commit counts per announced repository.
unpushed: HashMap<RepoAddr, usize>,
/// Commits not on Nostr per announced repository.
unsynced: HashMap<RepoAddr, usize>,
/// Profiles rendered by the sidebar.
directory: Entity<ProfileDirectory>,
/// Sync statuses for the announced repositories.
sync_directory: Entity<SyncStatusDirectory>,
_subscriptions: Vec<Subscription>,
}
@@ -81,7 +81,6 @@ impl SidebarPanel {
let repos = RepoListStore::global(cx);
let local = LocalReposStore::global(cx);
let checkouts = CheckoutsStore::global(cx);
let sync_status = SyncStatusStore::global(cx);
let mut subscriptions = Vec::new();
@@ -119,12 +118,6 @@ impl SidebarPanel {
}
}));
subscriptions.push(cx.observe(&sync_status, |this, _sync, cx| {
if this.refresh_unsynced(cx) {
cx.notify();
}
}));
Self {
focus_handle: cx.focus_handle(),
dock_area,
@@ -137,8 +130,8 @@ impl SidebarPanel {
local_repos: Arc::new(Vec::new()),
scanning: false,
unpushed: HashMap::new(),
unsynced: HashMap::new(),
directory: ProfileDirectory::new(cx),
sync_directory: SyncStatusDirectory::new(cx),
_subscriptions: subscriptions,
}
}
@@ -189,10 +182,19 @@ impl SidebarPanel {
directory.track(profiles, cx);
});
let addrs: Vec<RepoAddr> = self
.announcements
.iter()
.map(|announcement| announcement.addr())
.collect();
self.sync_directory.update(cx, |directory, cx| {
directory.track(addrs, cx);
});
if announcements_changed {
self.request_push_watches(cx);
self.unpushed.clear();
self.unsynced.clear();
}
announcements_changed || local_changed || scanning_changed
@@ -218,26 +220,6 @@ impl SidebarPanel {
true
}
fn refresh_unsynced(&mut self, cx: &mut Context<Self>) -> bool {
let sync_status = SyncStatusStore::global(cx);
let mut unsynced = HashMap::with_capacity(self.announcements.len());
for announcement in self.announcements.iter() {
let addr = announcement.addr();
let count = sync_status.read(cx).unsynced(&addr);
if count > 0 {
unsynced.insert(addr, count);
}
}
if unsynced == self.unsynced {
return false;
}
self.unsynced = unsynced;
true
}
fn request_push_watches(&self, cx: &mut Context<Self>) {
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| {
@@ -538,7 +520,7 @@ impl SidebarPanel {
let addr = announcement.addr();
let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
let unsynced = self.unsynced.get(&addr).copied().unwrap_or(0);
let unsynced = self.sync_directory.read(cx).unsynced(&addr, cx);
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
-259
View File
@@ -1,259 +0,0 @@
# 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, 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.
- Probe requests reach the loop over the same flume-channel wake the
profile fetch service uses, so `probe` needs only `&self`.
- 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
/// 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:
- `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 pass 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. `RepoStore::run_push`
replaces its post-push `refresh(cx)` with `probe(addr)` so badges update
without waiting for the relay pump.
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**: 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 `Updated`
event clears directories; a later probe re-runs over the new signer's
addrs.
- **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.