From 946128433b26af452d1cf7e4fb82b8e05c4c15af Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 16 Oct 2026 02:40:20 +0000 Subject: [PATCH] chore: bind sync status to per-view directories (#32) Reviewed-on: https://git.reya.info/reya/signed/pulls/32 --- crates/signed_state/src/lib.rs | 2 +- crates/signed_state/src/repo.rs | 8 +- crates/signed_state/src/sync_status.rs | 357 +++++++++++++++------- crates/workspace/src/views/repo/mod.rs | 27 +- crates/workspace/src/views/sidebar/mod.rs | 48 +-- 5 files changed, 280 insertions(+), 162 deletions(-) diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 2f9e15e..c757f22 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -29,7 +29,7 @@ pub use repos::RepoListStore; pub use signed_git::{Nip34Binding, Nip34Kind}; use signed_nostr::NostrBackend; pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState}; -pub use sync_status::{CheckoutSyncStatus, SyncStatusStore}; +pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore}; pub fn init( db_path: impl AsRef, diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index a1b29d6..c98ac0b 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1226,6 +1226,7 @@ impl RepoStore { Ok(outcome) => { this.last_error = None; this.last_push_warning = outcome.partial_warning(); + if let Some((addr, path)) = &pushed_checkout { let checkouts = CheckoutsStore::global(cx); checkouts.update(cx, |store, cx| { @@ -1233,8 +1234,13 @@ impl RepoStore { }); } + // Re-probe so sync badges reflect the pushed state. let sync_status = SyncStatusStore::global(cx); - sync_status.update(cx, |store, cx| store.refresh(cx)); + let addr = pushed_checkout + .as_ref() + .map(|(addr, _)| addr.clone()) + .or_else(|| this.addr.clone()); + sync_status.read(cx).probe(addr); } Err(e) => { this.last_error = Some(format!("Push failed: {e}")); diff --git a/crates/signed_state/src/sync_status.rs b/crates/signed_state/src/sync_status.rs index 2c26fbb..1ce345c 100644 --- a/crates/signed_state/src/sync_status.rs +++ b/crates/signed_state/src/sync_status.rs @@ -1,18 +1,17 @@ -use std::collections::HashMap; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::time::Duration; use anyhow::Error; -use gpui::{App, AppContext, Context, Entity, Global, Subscription}; -use nostr_sdk::prelude::*; +use flume::Sender; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; use signed_core::{Deletions, Filters, RepoAddr}; use signed_git::{Repo, RepoSyncStatus}; use crate::backend::{Backend, BackendEvent}; use crate::checkouts::CheckoutsStore; -use crate::refresh::{RefreshGate, RefreshRequest}; -use crate::repos::RepoListStore; -use crate::state::{GitRef, SyncState}; +use crate::state::SyncState; const SYNC_POLL: Duration = Duration::from_secs(180); const MAX_SYNC_CHECKOUTS: usize = 8; @@ -28,13 +27,25 @@ pub struct CheckoutSyncStatus { pub status: RepoSyncStatus, } +/// Statuses for these repositories may have changed; empty on signer change. +#[derive(Debug, Clone)] +pub enum SyncEvent { + Updated(Vec<(RepoAddr, Vec)>), +} + +/// Probes tracked repositories and broadcasts results. Holds no statuses. pub struct SyncStatusStore { - statuses: HashMap>, - refresh: RefreshGate, + active: RefCell>, + in_flight: bool, + pass_pending: bool, poll_pending: bool, + sender: Sender<()>, + tasks: Vec>>, _subscriptions: Vec, } +impl EventEmitter for SyncStatusStore {} + impl SyncStatusStore { pub fn global(cx: &App) -> Entity { cx.global::().0.clone() @@ -45,150 +56,131 @@ impl SyncStatusStore { } pub fn new(cx: &mut Context) -> Self { + let (sender, receiver) = flume::unbounded::<()>(); let mut subscriptions = Vec::new(); if !cfg!(target_arch = "wasm32") { - let repos = RepoListStore::global(cx); - let checkouts = CheckoutsStore::global(cx); let backend = Backend::global(cx); - - subscriptions.push(cx.observe(&repos, |this, _repos, cx| { - this.refresh(cx); - })); + let checkouts = CheckoutsStore::global(cx); // Checkout associations decide which paths get compared. subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { - this.refresh(cx); + this.run_pass(cx); })); subscriptions.push( cx.subscribe(&backend, |this, _backend, event, cx| match event { - BackendEvent::SignerChanged => { - this.statuses.clear(); - cx.notify(); - this.refresh(cx); - } - BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.refresh(cx), + BackendEvent::SignerChanged => this.clear(cx), + BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.run_pass(cx), _ => {} }), ); - - let weak = cx.entity().downgrade(); - cx.defer(move |cx| { - if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) { - log::warn!("sync status store dropped before the initial refresh: {error}"); - } - }); } + let task = cx.spawn(async move |this, cx| { + loop { + receiver.recv_async().await?; + + // One pass covers every signal queued since the wake. + while receiver.try_recv().is_ok() {} + + this.update(cx, |this, cx| { + this.run_pass(cx); + })?; + } + }); + Self { - statuses: HashMap::new(), - refresh: RefreshGate::default(), + active: RefCell::new(HashSet::new()), + sender, + in_flight: false, + pass_pending: false, poll_pending: false, + tasks: vec![task], _subscriptions: subscriptions, } } - /// Latest sync status per associated checkout. - pub fn statuses_of(&self, addr: &RepoAddr) -> Vec { - self.statuses.get(addr).cloned().unwrap_or_default() + /// Queue a probe pass; folded into any pass already in flight. + pub fn probe(&self, addrs: impl IntoIterator) { + self.active.borrow_mut().extend(addrs); + + if let Err(e) = self.sender.send(()) { + log::warn!("failed to queue sync status probe: {e}"); + } } - /// Commits available locally but missing on Nostr. - pub fn unsynced(&self, addr: &RepoAddr) -> usize { - self.statuses - .get(addr) - .map(|list| { - list.iter() - .map(|checkout| checkout.status.ahead_total) - .sum() - }) - .unwrap_or(0) + // Keeps the in-flight pass so its completion releases the slot exactly once. + fn clear(&mut self, cx: &mut Context) { + let had_addrs = !self.active.borrow().is_empty(); + self.active.borrow_mut().clear(); + self.pass_pending = false; + + if had_addrs { + cx.emit(SyncEvent::Updated(Vec::new())); + } } - pub fn refresh(&mut self, cx: &mut Context) { + fn run_pass(&mut self, cx: &mut Context) { if cfg!(target_arch = "wasm32") { return; } - if self.refresh.request() != RefreshRequest::Schedule { + + if self.in_flight { + self.pass_pending = true; return; } - let backend = Backend::global(cx); - let Some(user) = backend.read(cx).current_user() else { - let changed = !self.statuses.is_empty(); - self.statuses.clear(); - if changed { - cx.notify(); - } - return; - }; - - self.schedule_poll(cx); - self.run_refresh(user, cx); - } - - fn schedule_poll(&mut self, cx: &mut Context) { - if self.poll_pending { - return; - } - self.poll_pending = true; - - cx.spawn(async move |this, cx| { - cx.background_executor().timer(SYNC_POLL).await; - this.update(cx, |this, cx| { - this.poll_pending = false; - this.refresh(cx); - }) - }) - .detach(); - } - - fn run_refresh(&mut self, user: PublicKey, cx: &mut Context) { - self.refresh.begin(); - let backend = Backend::global(cx); let client = backend.read(cx).client(); - let repos = RepoListStore::global(cx); - let announcements = repos.read(cx).announcements_of(&user); - let checkouts = CheckoutsStore::global(cx); + if backend.read(cx).current_user().is_none() { + self.clear(cx); + return; + } - let targets: Vec<(RepoAddr, Vec)> = announcements - .iter() - .map(|announcement| { - let addr = announcement.addr(); - let paths = checkouts.read(cx).associations_of(&addr); - (addr, paths) - }) - .collect(); + if self.active.borrow().is_empty() { + return; + } + + self.in_flight = true; + self.schedule_poll(cx); + + let targets: Vec<(RepoAddr, Vec)> = { + let active = self.active.borrow(); + let checkouts = CheckoutsStore::global(cx); + + active + .iter() + .map(|addr| { + let paths = checkouts.read(cx).associations_of(addr); + (addr.clone(), paths) + }) + .collect() + }; let work = cx.background_spawn(async move { let db = client.database(); let deletion_events = db.query(Filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); - let mut states: HashMap> = HashMap::new(); - for (addr, _) in &targets { + let mut updates: Vec<(RepoAddr, Vec)> = Vec::new(); + + for (addr, paths) in &targets { let events = db.query(addr.state_filter()).await?; let latest = utils::latest( events .into_iter() .filter(|event| !deletions.is_deleted(event)), ); + let Some(event) = latest else { - continue; - }; - states.insert(addr.clone(), SyncState::parse(&event).refs); - } - - let mut statuses: HashMap> = HashMap::new(); - for (addr, paths) in &targets { - let Some(nostr_refs) = states.get(addr) else { + updates.push((addr.clone(), Vec::new())); continue; }; - let remote_refs: Vec<(String, String)> = nostr_refs + let remote_refs: Vec<(String, String)> = SyncState::parse(&event) + .refs .iter() .map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string())) .collect(); @@ -206,41 +198,170 @@ impl SyncStatusStore { } } - if !list.is_empty() { - statuses.insert(addr.clone(), list); - } + updates.push((addr.clone(), list)); } - Ok::<_, Error>(statuses) + Ok::<_, Error>(updates) }); - cx.spawn(async move |this, cx| { - let statuses = match work.await { - Ok(statuses) => statuses, + self.tasks.push(cx.spawn(async move |this, cx| { + // Reset the slot even on failure, or every later pass folds forever. + let updates = match work.await { + Ok(updates) => updates, Err(error) => { log::warn!("failed to compute nostr sync statuses: {error}"); - return this.update(cx, |this, _cx| { - this.refresh.abort(); - }); + Vec::new() } }; - let again = this.update(cx, |this, cx| { - if this.statuses != statuses { - this.statuses = statuses; - cx.notify(); + let mut again = false; + + this.update(cx, |this, cx| { + this.in_flight = false; + if !updates.is_empty() { + cx.emit(SyncEvent::Updated(updates)); } - this.refresh.finish() + again = std::mem::take(&mut this.pass_pending); })?; if again { this.update(cx, |this, cx| { - this.refresh(cx); + this.run_pass(cx); })?; } - Ok::<_, Error>(()) + Ok::<(), anyhow::Error>(()) + })); + } + + fn schedule_poll(&mut self, cx: &mut Context) { + if self.poll_pending { + return; + } + self.poll_pending = true; + + self.tasks.push(cx.spawn(async move |this, cx| { + cx.background_executor().timer(SYNC_POLL).await; + + this.update(cx, |this, cx| { + this.poll_pending = false; + this.run_pass(cx); + })?; + + Ok::<(), anyhow::Error>(()) + })); + } +} + +/// Sync status cache bound to one panel or screen. +pub struct SyncStatusDirectory { + statuses: HashMap>, + pending: RefCell>, + _subscriptions: Vec, +} + +impl SyncStatusDirectory { + /// Create a directory, following the sync store's events. + pub fn new(cx: &mut App) -> Entity { + let store = SyncStatusStore::global(cx); + + cx.new(|cx| { + let subscription = + cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event { + SyncEvent::Updated(updates) => this.apply(updates.clone(), cx), + }); + + Self { + statuses: HashMap::new(), + pending: RefCell::new(HashSet::new()), + _subscriptions: vec![subscription], + } }) - .detach(); + } + + /// Queue a probe for addrs this surface renders. + pub fn track(&mut self, addrs: impl IntoIterator, cx: &mut Context) { + let missing: Vec = addrs + .into_iter() + .filter(|addr| { + !self.statuses.contains_key(addr) + && !self.with_pending(|pending| pending.contains(addr)) + }) + .collect(); + + if missing.is_empty() { + return; + } + + self.with_pending(|pending| pending.extend(missing.iter().cloned())); + + let store = SyncStatusStore::global(cx); + store.read(cx).probe(missing); + } + + /// Statuses for a repo, or empty; a miss queues a probe. + pub fn statuses_of(&self, addr: &RepoAddr, cx: &App) -> Vec { + self.queue(addr, cx); + self.statuses.get(addr).cloned().unwrap_or_default() + } + + /// Commits available locally but missing on Nostr. + pub fn unsynced(&self, addr: &RepoAddr, cx: &App) -> usize { + self.queue(addr, cx); + + self.statuses + .get(addr) + .map(|list| { + list.iter() + .map(|checkout| checkout.status.ahead_total) + .sum() + }) + .unwrap_or(0) + } + + fn queue(&self, addr: &RepoAddr, cx: &App) { + if self.with_pending(|pending| pending.insert(addr.clone())) { + let store = SyncStatusStore::global(cx); + store.read(cx).probe([addr.clone()]); + } + } + + fn apply(&mut self, updates: Vec<(RepoAddr, Vec)>, cx: &mut Context) { + if updates.is_empty() { + let changed = !self.statuses.is_empty(); + self.statuses.clear(); + self.with_pending(|pending| pending.clear()); + + if changed { + cx.notify(); + } + + return; + } + + let mut changed = false; + + for (addr, list) in updates { + let relevant = self.statuses.contains_key(&addr) + || self.with_pending(|pending| pending.remove(&addr)); + + if !relevant { + continue; + } + + if self.statuses.get(&addr) != Some(&list) { + self.statuses.insert(addr, list); + changed = true; + } + } + + if changed { + cx.notify(); + } + } + + fn with_pending(&self, then: impl FnOnce(&mut HashSet) -> R) -> R { + let mut pending = self.pending.borrow_mut(); + then(&mut pending) } } diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 7546211..fd0e7b4 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -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>>, /// Profiles rendered by the header, maintainers and tab title. directory: Entity, + /// Sync status feeding the sync-to-Nostr banner. + sync_directory: Entity, _subscriptions: Vec, } @@ -156,7 +158,6 @@ impl RepoDetailView { cx: &mut Context, ) -> 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) { + 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) { 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) diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 0c12d01..1888d12 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -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>, /// Unpushed commit counts per announced repository. unpushed: HashMap, - /// Commits not on Nostr per announced repository. - unsynced: HashMap, /// Profiles rendered by the sidebar. directory: Entity, + /// Sync statuses for the announced repositories. + sync_directory: Entity, _subscriptions: Vec, } @@ -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 = 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) -> 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) { 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);