chore: bind sync status to per-view directories (#32)
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

Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
2026-10-16 02:40:20 +00:00
parent 30ff255372
commit 946128433b
5 changed files with 280 additions and 162 deletions
+1 -1
View File
@@ -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<Path>,
+7 -1
View File
@@ -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}"));
+239 -118
View File
@@ -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<CheckoutSyncStatus>)>),
}
/// Probes tracked repositories and broadcasts results. Holds no statuses.
pub struct SyncStatusStore {
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
refresh: RefreshGate,
active: RefCell<HashSet<RepoAddr>>,
in_flight: bool,
pass_pending: bool,
poll_pending: bool,
sender: Sender<()>,
tasks: Vec<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>,
}
impl EventEmitter<SyncEvent> for SyncStatusStore {}
impl SyncStatusStore {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalSyncStatusStore>().0.clone()
@@ -45,150 +56,131 @@ impl SyncStatusStore {
}
pub fn new(cx: &mut Context<Self>) -> 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<CheckoutSyncStatus> {
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<Item = RepoAddr>) {
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<Self>) {
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<Self>) {
fn run_pass(&mut self, cx: &mut Context<Self>) {
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<Self>) {
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>) {
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<PathBuf>)> = 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<PathBuf>)> = {
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<RepoAddr, Vec<GitRef>> = HashMap::new();
for (addr, _) in &targets {
let mut updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)> = 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<RepoAddr, Vec<CheckoutSyncStatus>> = 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<Self>) {
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<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> {
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<Item = RepoAddr>, cx: &mut Context<Self>) {
let missing: Vec<RepoAddr> = 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<CheckoutSyncStatus> {
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<CheckoutSyncStatus>)>, cx: &mut Context<Self>) {
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<R>(&self, then: impl FnOnce(&mut HashSet<RepoAddr>) -> R) -> R {
let mut pending = self.pending.borrow_mut();
then(&mut pending)
}
}
+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);