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
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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user