feat: add sync detection for repositories (#28)
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run

Reviewed-on: #28
This commit was merged in pull request #28.
This commit is contained in:
2026-10-08 02:34:33 +00:00
parent 31256a4870
commit 4d8aa97e24
12 changed files with 691 additions and 37 deletions
+3
View File
@@ -2,7 +2,10 @@
* Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified. * Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.
* Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious. * Do not write organizational or comments that summarize the code. Comments should only be written in order to explain "why" the code is written in some way in the case there is a reason that is tricky / non-obvious.
* Keep doc comments and comments simple, one line, straightforward, and unbloated.
* Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files. * Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.
* Prefer `impl` blocks over standalone functions: place functionality as methods or associated functions on the relevant type, extending the type's existing `impl` block instead of creating a duplicate one in another file.
* Bind a global store handle to a local before using it (`let state = GlobalState::global(cx);` then `state.update(...)`) instead of chaining on `CheckoutsStore::global(cx)` inline.
* Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors. * Avoid using functions that panic like `unwrap()`, instead use mechanisms like `?` to propagate errors.
* Be careful with operations like indexing which may panic if the indexes are out of bounds. * Be careful with operations like indexing which may panic if the indexes are out of bounds.
* Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately: * Never silently discard errors with `let _ =` on fallible operations. Always handle errors appropriately:
+3
View File
@@ -10,6 +10,8 @@
- Add a dropdown menu on the signed-in user button in the sidebar - Add a dropdown menu on the signed-in user button in the sidebar
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential - Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
- Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others) - Add a quit action, bound to `secondary-q` (cmd on macOS, ctrl on others)
- Allow choosing the default branch when publishing a repository
- Detect when local commits are not on Nostr yet, showing an unsynced count badge on sidebar repository rows and a Sync now banner on the repository view
### Changed ### Changed
@@ -20,6 +22,7 @@
- Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays - Fetch the logged-in user's grasp list, code follows, followed repositories, contacts, profile metadata, mute list, and blossom servers via gossip at login instead of the bootstrap relays
- Connect to grasp relays and load the inbox only after the user's grasp list event arrives - Connect to grasp relays and load the inbox only after the user's grasp list event arrives
- Refresh the checkouts store only when the checkouts settings change - Refresh the checkouts store only when the checkouts settings change
- Restructure the core crate into focused modules for announcements, pull requests, git events, inbox, and upstream state
### Fixed ### Fixed
+2
View File
@@ -5,6 +5,7 @@ mod nip34;
mod patch; mod patch;
mod repo; mod repo;
mod scan; mod scan;
mod sync;
mod worktree; mod worktree;
#[cfg(test)] #[cfg(test)]
@@ -17,6 +18,7 @@ pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind};
pub use patch::PatchParser; pub use patch::PatchParser;
pub use repo::{Repo, RepoRefState}; pub use repo::{Repo, RepoRefState};
pub use scan::{LocalRepo, find_git_repos}; pub use scan::{LocalRepo, find_git_repos};
pub use sync::{RefSync, RepoSyncStatus};
pub use worktree::WorktreeSnapshot; pub use worktree::WorktreeSnapshot;
pub(crate) trait GixResultExt<T> { pub(crate) trait GixResultExt<T> {
+82 -1
View File
@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
@@ -13,6 +13,7 @@ use crate::GixResultExt as _;
use crate::diff::{CommitDiff, DiffStatus, FileDiff, HunkCollector}; use crate::diff::{CommitDiff, DiffStatus, FileDiff, HunkCollector};
use crate::history::{CommitList, FileCommit, MAX_LISTED_COMMITS}; use crate::history::{CommitList, FileCommit, MAX_LISTED_COMMITS};
use crate::nip34::{GraspSignals, Nip34Binding, Nip34Json, Nip34Kind}; use crate::nip34::{GraspSignals, Nip34Binding, Nip34Json, Nip34Kind};
use crate::sync::{RefSync, RepoSyncStatus};
use crate::worktree::WorktreeSnapshot; use crate::worktree::WorktreeSnapshot;
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024; const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
@@ -797,6 +798,86 @@ impl Repo {
self.inner.rev_parse_single(rev.as_bytes()).ok() self.inner.rev_parse_single(rev.as_bytes()).ok()
} }
/// Classifies every local branch against the Nostr state refs, ignoring tags.
pub fn sync_status(&self, remote_refs: &[(String, String)]) -> Result<RepoSyncStatus> {
let local_refs = self.ref_state()?;
let local = RepoSyncStatus::branches(&local_refs.refs);
let remote = RepoSyncStatus::branches(remote_refs);
let names: BTreeSet<&str> = local.keys().chain(remote.keys()).copied().collect();
let mut refs = Vec::new();
let mut ahead_total = 0;
let mut behind_total = 0;
for name in names {
let sync = match (local.get(name), remote.get(name)) {
(Some(local_commit), Some(remote_commit)) => {
self.branch_sync(local_commit, remote_commit)
}
(Some(_), None) => RefSync::LocalOnly,
(None, Some(_)) => RefSync::RemoteOnly,
(None, None) => continue,
};
match &sync {
RefSync::LocalAhead { ahead } => ahead_total += ahead,
RefSync::RemoteAhead { behind } => behind_total += behind,
RefSync::Diverged { ahead, behind } => {
ahead_total += ahead;
behind_total += behind;
}
RefSync::InSync | RefSync::LocalOnly | RefSync::RemoteOnly => {}
}
refs.push((name.to_owned(), sync));
}
Ok(RepoSyncStatus {
refs,
ahead_total,
behind_total,
})
}
/// Classifies a branch present on both sides.
fn branch_sync(&self, local_commit: &str, remote_commit: &str) -> RefSync {
if local_commit == remote_commit {
return RefSync::InSync;
}
match self.merge_base(local_commit, remote_commit) {
Ok(Some(base)) if base == remote_commit => RefSync::LocalAhead {
ahead: self.commits_ahead(&base, local_commit) as usize,
},
Ok(Some(base)) if base == local_commit => RefSync::RemoteAhead {
behind: self.commits_ahead(&base, remote_commit) as usize,
},
Ok(Some(base)) => RefSync::Diverged {
ahead: self.commits_ahead(&base, local_commit) as usize,
behind: self.commits_ahead(&base, remote_commit) as usize,
},
// Unrelated histories share no base, so every commit counts.
Ok(None) => RefSync::Diverged {
ahead: self.count_reachable(local_commit),
behind: self.count_reachable(remote_commit),
},
// Nostr tip missing locally, so at least that commit differs.
Err(_) => RefSync::RemoteAhead { behind: 1 },
}
}
/// Counts commits reachable from a commit id.
fn count_reachable(&self, commit: &str) -> usize {
let Ok(commit_id) = self.inner.rev_parse_single(commit.as_bytes()) else {
return 0;
};
match self.inner.rev_walk([commit_id]).all() {
Ok(walk) => walk.filter_map(Result::ok).count(),
Err(_) => 0,
}
}
/// Lists every worktree path relative to the root, directories first. /// Lists every worktree path relative to the root, directories first.
pub fn entries(&self) -> Result<Vec<PathBuf>> { pub fn entries(&self) -> Result<Vec<PathBuf>> {
let workdir = self.inner.workdir().context("repository has no worktree")?; let workdir = self.inner.workdir().context("repository has no worktree")?;
+38
View File
@@ -0,0 +1,38 @@
use std::collections::BTreeMap;
/// How a single branch compares between local and Nostr.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefSync {
InSync,
LocalAhead { ahead: usize },
RemoteAhead { behind: usize },
Diverged { ahead: usize, behind: usize },
LocalOnly,
RemoteOnly,
}
/// Branch comparison between a local repository and its Nostr state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoSyncStatus {
/// Branch short name and its classification.
pub refs: Vec<(String, RefSync)>,
pub ahead_total: usize,
pub behind_total: usize,
}
impl RepoSyncStatus {
/// True when every branch matches the Nostr state.
pub fn in_sync(&self) -> bool {
self.refs.iter().all(|(_, sync)| *sync == RefSync::InSync)
}
/// Maps branch short name to lowercase commit oid.
pub(crate) fn branches(refs: &[(String, String)]) -> BTreeMap<&str, String> {
refs.iter()
.filter_map(|(name, commit)| {
name.strip_prefix("refs/heads/")
.map(|branch| (branch, commit.to_ascii_lowercase()))
})
.collect()
}
}
+3
View File
@@ -9,6 +9,7 @@ mod push;
mod refresh; mod refresh;
mod repo; mod repo;
mod repos; mod repos;
mod sync_status;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -27,6 +28,7 @@ pub use repo::RepoStore;
pub use repos::RepoListStore; pub use repos::RepoListStore;
pub use signed_git::{Nip34Binding, Nip34Kind}; pub use signed_git::{Nip34Binding, Nip34Kind};
use signed_nostr::NostrBackend; use signed_nostr::NostrBackend;
pub use sync_status::{CheckoutSyncStatus, SyncStatusStore};
pub fn init( pub fn init(
db_path: impl AsRef<Path>, db_path: impl AsRef<Path>,
@@ -50,4 +52,5 @@ pub fn init(
RepoListStore::set_global(cx.new(RepoListStore::new), cx); RepoListStore::set_global(cx.new(RepoListStore::new), cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
SyncStatusStore::set_global(cx.new(SyncStatusStore::new), cx);
} }
+22 -7
View File
@@ -20,6 +20,7 @@ use crate::bootstrap::user_grasp_list_servers;
use crate::checkouts::CheckoutsStore; use crate::checkouts::CheckoutsStore;
use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls}; use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls};
use crate::repos::RepoListStore; use crate::repos::RepoListStore;
use crate::sync_status::SyncStatusStore;
// NIP-34 suggests patches when each event is under 60kb. // NIP-34 suggests patches when each event is under 60kb.
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024; const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
@@ -36,6 +37,9 @@ pub struct RepoStore {
// Views distinguish "no data yet" from a genuinely empty repository with it. // Views distinguish "no data yet" from a genuinely empty repository with it.
pub loaded: bool, pub loaded: bool,
pub head: Option<String>, pub head: Option<String>,
// Refs and timestamp of the latest state event, for sync detection.
pub state_refs: Vec<(String, String)>,
pub state_at: Option<Timestamp>,
pub issues: Vec<Event>, pub issues: Vec<Event>,
pub patches: Vec<Event>, pub patches: Vec<Event>,
pub pull_requests: Vec<Event>, pub pull_requests: Vec<Event>,
@@ -88,6 +92,8 @@ impl RepoStore {
nip34: None, nip34: None,
loaded: false, loaded: false,
head: None, head: None,
state_refs: Vec::new(),
state_at: None,
issues: Vec::new(), issues: Vec::new(),
patches: Vec::new(), patches: Vec::new(),
pull_requests: Vec::new(), pull_requests: Vec::new(),
@@ -116,6 +122,8 @@ impl RepoStore {
nip34, nip34,
loaded: true, loaded: true,
head: None, head: None,
state_refs: Vec::new(),
state_at: None,
issues: Vec::new(), issues: Vec::new(),
patches: Vec::new(), patches: Vec::new(),
pull_requests: Vec::new(), pull_requests: Vec::new(),
@@ -349,7 +357,8 @@ impl RepoStore {
.and_then(Announcement::from_event); .and_then(Announcement::from_event);
let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e));
let state = utils::latest(all_states).map(|state| RepoState::parse(&state)); let state =
utils::latest(all_states).map(|event| (RepoState::parse(&event), event.created_at));
let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
@@ -466,13 +475,13 @@ impl RepoStore {
let keep_hint = announcement.is_none() && !this.loaded; let keep_hint = announcement.is_none() && !this.loaded;
let first_pass = !this.loaded; let first_pass = !this.loaded;
let head_changed = state let state_changed = state.as_ref().is_some_and(|(state, _)| {
.as_ref() this.head.as_deref() != state.head.as_deref() || this.state_refs != state.refs
.is_some_and(|state| this.head.as_deref() != state.head.as_deref()); });
let changed = first_pass let changed = first_pass
|| (!keep_hint && this.announcement != announcement) || (!keep_hint && this.announcement != announcement)
|| head_changed || state_changed
|| this.issues != issues || this.issues != issues
|| this.patches != patches || this.patches != patches
|| this.pull_requests != pull_requests || this.pull_requests != pull_requests
@@ -499,8 +508,10 @@ impl RepoStore {
this.sync_maintainer_relays(&maintainers, cx); this.sync_maintainer_relays(&maintainers, cx);
if let Some(state) = state { if let Some((state, created_at)) = state {
this.head = state.head; this.head = state.head;
this.state_refs = state.refs;
this.state_at = Some(created_at);
} }
this.issues = issues; this.issues = issues;
@@ -1169,10 +1180,14 @@ impl RepoStore {
this.last_error = None; this.last_error = None;
this.last_push_warning = outcome.partial_warning(); this.last_push_warning = outcome.partial_warning();
if let Some((addr, path)) = &pushed_checkout { if let Some((addr, path)) = &pushed_checkout {
CheckoutsStore::global(cx).update(cx, |store, cx| { let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| {
store.checkout_pushed(addr, path, cx); store.checkout_pushed(addr, path, cx);
}); });
} }
let sync_status = SyncStatusStore::global(cx);
sync_status.update(cx, |store, cx| store.refresh(cx));
} }
Err(e) => { Err(e) => {
this.last_error = Some(format!("Push failed: {e}")); this.last_error = Some(format!("Push failed: {e}"));
+239
View File
@@ -0,0 +1,239 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
use nostr_sdk::prelude::*;
use signed_core::{Deletions, Filters, RepoAddr, RepoState};
use signed_git::{Repo, RepoSyncStatus};
use crate::backend::{Backend, BackendEvent};
use crate::checkouts::CheckoutsStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::RepoListStore;
// Nostr state changes rarely, so drift detection runs on a slow poll.
const SYNC_POLL: Duration = Duration::from_secs(60);
const MAX_SYNC_CHECKOUTS: usize = 8;
struct GlobalSyncStatusStore(Entity<SyncStatusStore>);
impl Global for GlobalSyncStatusStore {}
/// A checkout compared against the repository state published on Nostr.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutSyncStatus {
pub path: PathBuf,
pub status: RepoSyncStatus,
}
pub struct SyncStatusStore {
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
refresh: RefreshGate,
poll_pending: bool,
_subscriptions: Vec<Subscription>,
}
impl SyncStatusStore {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalSyncStatusStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalSyncStatusStore(entity));
}
pub fn new(cx: &mut Context<Self>) -> Self {
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);
}));
// Checkout associations decide which paths get compared.
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
this.refresh(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),
_ => {}
}),
);
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}");
}
});
}
Self {
statuses: HashMap::new(),
refresh: RefreshGate::default(),
poll_pending: false,
_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()
}
/// 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)
}
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
return;
}
if self.refresh.request() != RefreshRequest::Schedule {
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);
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();
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<(String, String)>> = HashMap::new();
for (addr, _) 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(), RepoState::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 {
continue;
};
let mut list = Vec::new();
for path in paths.iter().take(MAX_SYNC_CHECKOUTS) {
let synced =
Repo::try_open(path).and_then(|repo| repo.sync_status(nostr_refs).ok());
if let Some(status) = synced {
list.push(CheckoutSyncStatus {
path: path.clone(),
status,
});
}
}
if !list.is_empty() {
statuses.insert(addr.clone(), list);
}
}
Ok::<_, Error>(statuses)
});
cx.spawn(async move |this, cx| {
let statuses = match work.await {
Ok(statuses) => statuses,
Err(error) => {
log::warn!("failed to compute nostr sync statuses: {error}");
return this.update(cx, |this, _cx| {
this.refresh.abort();
});
}
};
let again = this.update(cx, |this, cx| {
if this.statuses != statuses {
this.statuses = statuses;
cx.notify();
}
this.refresh.finish()
})?;
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}
Ok::<_, Error>(())
})
.detach();
}
}
+10 -2
View File
@@ -1,13 +1,13 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use signed_state::CheckoutStatus; use signed_state::{CheckoutStatus, CheckoutSyncStatus};
#[derive(Default)] #[derive(Default)]
pub(super) struct Banners { pub(super) struct Banners {
dismissed: HashSet<(PathBuf, String)>, dismissed: HashSet<(PathBuf, String)>,
sync_dismissed: HashSet<PathBuf>,
ready_requested: bool, ready_requested: bool,
/// Re-requested only when the announced HEAD or the base default changes.
ready_head: Option<String>, ready_head: Option<String>,
ready_statuses: Vec<CheckoutStatus>, ready_statuses: Vec<CheckoutStatus>,
push_statuses: Vec<CheckoutStatus>, push_statuses: Vec<CheckoutStatus>,
@@ -24,6 +24,14 @@ impl Banners {
.insert((status.path.clone(), status.branch.clone())); .insert((status.path.clone(), status.branch.clone()));
} }
pub(super) fn sync_dismissal(&self, checkout: &CheckoutSyncStatus) -> bool {
self.sync_dismissed.contains(&checkout.path)
}
pub(super) fn sync_dismiss(&mut self, checkout: &CheckoutSyncStatus) {
self.sync_dismissed.insert(checkout.path.clone());
}
pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) { pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
(self.ready_requested, &self.ready_head) (self.ready_requested, &self.ready_head)
} }
+127 -4
View File
@@ -23,10 +23,10 @@ use gpui_component::{
use nostr::nips::nip19::Nip19Coordinate; use nostr::nips::nip19::Nip19Coordinate;
use nostr::prelude::{RelayUrl, ToBech32, Url}; use nostr::prelude::{RelayUrl, ToBech32, Url};
use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_core::{Announcement, RepoAddr, RepoStatus};
use signed_git::{FileCommit, GitCache, Repo}; use signed_git::{FileCommit, GitCache, RefSync, Repo};
use signed_state::{ use signed_state::{
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Mirrors, Nip34Binding, Nip34Kind, Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
ProfileStore, RepoListStore, RepoStore, Nip34Binding, Nip34Kind, ProfileStore, RepoListStore, RepoStore, SyncStatusStore,
}; };
use signed_ui::{ use signed_ui::{
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger, Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger,
@@ -153,6 +153,7 @@ impl RepoDetailView {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
let sync_status = SyncStatusStore::global(cx);
let files = cx.new(RepoFilesView::new); let files = cx.new(RepoFilesView::new);
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone())); let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
let refs = RefSwitcher::new(window, cx); let refs = RefSwitcher::new(window, cx);
@@ -189,6 +190,11 @@ 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. // Defer loading the repository until the window is ready.
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.load_repo(window, cx); this.load_repo(window, cx);
@@ -1535,6 +1541,26 @@ impl RepoDetailView {
.find(|status| !self.banners.dismissal(status)) .find(|status| !self.banners.dismissal(status))
} }
/// The first checkout of this owned repository with commits not on Nostr.
///
/// Not dismissed in this panel.
fn sync_suggestion(&self, cx: &App) -> Option<CheckoutSyncStatus> {
let store = self.store.read(cx);
let addr = store.addr()?;
let user = Backend::global(cx).read(cx).current_user()?;
if !store.is_author(&user) {
return None;
}
let sync_status = SyncStatusStore::global(cx);
let statuses = sync_status.read(cx).statuses_of(addr);
statuses.into_iter().find(|checkout| {
checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout)
})
}
pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> { pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.push_suggestion(cx)?; let status = self.push_suggestion(cx)?;
let path = status.path.clone(); let path = status.path.clone();
@@ -1620,6 +1646,102 @@ impl RepoDetailView {
) )
} }
pub(super) fn render_sync_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let checkout = self.sync_suggestion(cx)?;
let path = checkout.path.clone();
// The push busy flag lives on the store; it disables the banner's triggers.
let pushing = self.store.read(cx).pushing;
let (branch, ahead) = checkout
.status
.refs
.iter()
.filter_map(|(name, sync)| match sync {
RefSync::LocalAhead { ahead } => Some((name, *ahead)),
RefSync::Diverged { ahead, .. } => Some((name, *ahead)),
_ => None,
})
.max_by_key(|(_, ahead)| *ahead)?;
let branch = SharedString::from(branch.clone());
let commits = if ahead == 1 {
SharedString::from("1 commit")
} else {
SharedString::from(format!("{} commits", ahead))
};
Some(
h_flex()
.p_4()
.gap_2()
.w_full()
.items_center()
.justify_between()
.bg(cx.theme().muted)
.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().info)
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(branch),
)
.child("is")
.child(
h_flex()
.px_1()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().info)
.bg(cx.theme().info.mix_oklab(transparent_white(), 0.04))
.text_xs()
.font_semibold()
.font_family(cx.theme().mono_font_family.clone())
.child(commits),
)
.child("ahead of Nostr"),
)
.child(
h_flex()
.gap_1()
.child(
Button::new("sync-banner")
.icon(CustomIconName::Refresh)
.label("Sync now")
.small()
.info()
.loading(pushing)
.disabled(pushing)
.on_click(cx.listener(move |this, _event, window, cx| {
this.push_unpushed_checkout(path.clone(), window, cx);
})),
)
.child(
Button::new("sync-banner-dismiss")
.icon(IconName::Close)
.tooltip("Dismiss")
.small()
.ghost()
.disabled(pushing)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banners.sync_dismiss(&checkout);
cx.notify();
})),
),
)
.into_any_element(),
)
}
pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> { pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let store = self.store.read(cx); let store = self.store.read(cx);
let warning = store.last_push_warning.clone()?; let warning = store.last_push_warning.clone()?;
@@ -2046,7 +2168,8 @@ impl Render for RepoDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let banner = self let banner = self
.render_ready_banner(cx) .render_ready_banner(cx)
.or_else(|| self.render_push_banner(cx)); .or_else(|| self.render_push_banner(cx))
.or_else(|| self.render_sync_banner(cx));
let error = self.error.clone().or_else(|| { let error = self.error.clone().or_else(|| {
self.store self.store
+69 -23
View File
@@ -10,8 +10,9 @@ use dock::{
}; };
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ObjectFit,
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white, Render, SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list,
white,
}; };
use gpui_base::Button as BaseButton; use gpui_base::Button as BaseButton;
use gpui_component::button::{Button, ButtonVariant, ButtonVariants}; use gpui_component::button::{Button, ButtonVariant, ButtonVariants};
@@ -23,7 +24,7 @@ use nostr::prelude::RelayUrl;
use signed_core::{Announcement, RepoAddr}; use signed_core::{Announcement, RepoAddr};
use signed_state::{ use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
ProfileStore, RepoListStore, ResolvedLocalRepo, ProfileStore, RepoListStore, ResolvedLocalRepo, SyncStatusStore,
}; };
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers}; use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
@@ -59,13 +60,15 @@ pub struct SidebarPanel {
profile: Option<WeakEntity<PlaceholderPanel>>, profile: Option<WeakEntity<PlaceholderPanel>>,
relays: Option<WeakEntity<PlaceholderPanel>>, relays: Option<WeakEntity<PlaceholderPanel>>,
banner: SharedString, banner: SharedString,
scanning: bool,
/// User's announced repositories. /// User's announced repositories.
announcements: Arc<Vec<Announcement>>, announcements: Arc<Vec<Announcement>>,
/// Local repositories found by the scan that are not announced yet. /// Local repositories found by the scan that are not announced yet.
local_repos: Arc<Vec<ResolvedLocalRepo>>, local_repos: Arc<Vec<ResolvedLocalRepo>>,
scanning: bool,
/// Unpushed commit counts per announced repository. /// Unpushed commit counts per announced repository.
unpushed: HashMap<RepoAddr, usize>, unpushed: HashMap<RepoAddr, usize>,
/// Commits not on Nostr per announced repository.
unsynced: HashMap<RepoAddr, usize>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -75,6 +78,7 @@ impl SidebarPanel {
let repos = RepoListStore::global(cx); let repos = RepoListStore::global(cx);
let local = LocalReposStore::global(cx); let local = LocalReposStore::global(cx);
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
let sync_status = SyncStatusStore::global(cx);
let mut subscriptions = Vec::new(); let mut subscriptions = Vec::new();
@@ -112,6 +116,12 @@ impl SidebarPanel {
} }
})); }));
subscriptions.push(cx.observe(&sync_status, |this, _sync, cx| {
if this.refresh_unsynced(cx) {
cx.notify();
}
}));
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
@@ -124,6 +134,7 @@ impl SidebarPanel {
local_repos: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()),
scanning: false, scanning: false,
unpushed: HashMap::new(), unpushed: HashMap::new(),
unsynced: HashMap::new(),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -158,6 +169,7 @@ impl SidebarPanel {
if announcements_changed { if announcements_changed {
self.request_push_watches(cx); self.request_push_watches(cx);
self.unpushed.clear(); self.unpushed.clear();
self.unsynced.clear();
} }
announcements_changed || local_changed || scanning_changed announcements_changed || local_changed || scanning_changed
@@ -183,6 +195,26 @@ impl SidebarPanel {
true 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>) { fn request_push_watches(&self, cx: &mut Context<Self>) {
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| { checkouts.update(cx, |checkouts, cx| {
@@ -482,28 +514,28 @@ impl SidebarPanel {
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall(); PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
let announcement = announcement.clone(); let announcement = announcement.clone();
let unpushed = self let addr = announcement.addr();
.unpushed let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
.get(&announcement.addr()) let unsynced = self.unsynced.get(&addr).copied().unwrap_or(0);
.copied()
.unwrap_or(0);
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar); let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
if unpushed > 0 { if unpushed > 0 || unsynced > 0 {
row = row.suffix( let badges = h_flex().gap_0p5().flex_shrink_0();
v_flex()
.flex_shrink_0() let badges = if unpushed > 0 {
.size_4() badges.child(Self::count_badge(unpushed, cx.theme().red_light))
.items_center() } else {
.justify_center() badges
.rounded_full() };
.line_height(relative(1.))
.bg(cx.theme().red_light) let badges = if unsynced > 0 {
.text_color(white()) badges.child(Self::count_badge(unsynced, cx.theme().info))
.text_size(px(8.)) } else {
.child(SharedString::from(unpushed.to_string())), badges
); };
row = row.suffix(badges);
} }
row.on_click(cx.listener(move |this, _ev, window, cx| { row.on_click(cx.listener(move |this, _ev, window, cx| {
@@ -517,6 +549,20 @@ impl SidebarPanel {
})) }))
} }
fn count_badge(count: usize, color: Hsla) -> Div {
v_flex()
.flex_shrink_0()
.size_4()
.items_center()
.justify_center()
.rounded_full()
.line_height(relative(1.))
.bg(color)
.text_color(white())
.text_size(px(8.))
.child(SharedString::from(count.to_string()))
}
fn render_local_row( fn render_local_row(
&self, &self,
entry: &ResolvedLocalRepo, entry: &ResolvedLocalRepo,
+93
View File
@@ -0,0 +1,93 @@
# Nostr sync detection plan
Detect when a local repository has diverged from its NIP-34 state on Nostr
(kind 30618) and suggest syncing when the app opens.
Scope: repositories already initialized on Nostr (NIP-34 binding present).
Purely local repositories without a binding do not get this feature.
Scenario: the repository is initialized on Nostr and also pushed to Gitea.
Work continues locally and is pushed to Gitea only, so the Nostr state falls
behind. Signed should notice the difference on startup and offer to sync.
## Prior art
- nak (`git.go`): `nak git sync` fetches the latest kind 30617/30618 events
from relays and writes them to local refs (`refs/nip34/state/*`); push and
pull then compare against those refs with ordinary git machinery
(e.g. `merge-base --is-ancestor` for fast-forward checks).
- ngit-cli: the same idea — the Nostr state is materialized as git refs in the
repository, then ahead/behind comparison applies.
## Current state of the codebase
| Piece | Location | Status |
|---|---|---|
| NIP-34 binding detection | `Repo::nip34_binding()` in `signed_git/src/repo.rs` | done |
| Full Nostr state (refs + HEAD) | `RepoState::parse` in `signed_core/src/state.rs` | parsed, but `RepoStore` keeps only `head` and drops the refs |
| Push-to-Nostr flow | `Backend::push_repo_from` / `RepoStore::push_checkout` / `push_repository` | done; stages the state event on grasp relays, pushes objects, broadcasts 30618 |
| "Ahead of origin" detection | `CheckoutsStore::checkout_push_status` — local branch vs `refs/remotes/origin/<branch>` | exists, but that is local vs Gitea, not local vs Nostr |
| UI suggestion surfaces | Sidebar unpushed badge (`SidebarPanel::refresh_unpushed`), push banner (`RepoDetailView::push_suggestion` / `render_push_banner`) | pattern exists for origin; nothing for Nostr |
Gap: nothing compares the local repository's refs against the Nostr
`RepoState` refs, and no "sync to Nostr" suggestion is based on that
comparison.
## Plan
### Phase 1 — Divergence computation in `signed_git`
Add a pure function in a new `sync.rs` module that takes local `ref_state()`
and the Nostr `RepoState.refs` and classifies each `refs/heads/*` ref:
```rust
pub enum RefSync {
InSync,
LocalAhead { ahead: usize }, // fast-forwardable push
RemoteAhead { behind: usize }, // Nostr has newer commits
Diverged { ahead: usize, behind: usize },
LocalOnly, // branch never published
RemoteOnly, // branch exists only on Nostr
}
pub struct RepoSyncStatus {
pub refs: Vec<(String, RefSync)>, // branch name -> classification
pub ahead_total: usize, // summary for badges
pub behind_total: usize,
}
```
Implemented with the existing `Repo::merge_base` and `commits_since` /
`commits_ahead` — no new dependencies.
### Phase 2 — Keep Nostr state refs and add a sync-status store in `signed_state`
1. `RepoStore`: store `state_refs: Vec<(String, String)>` (and the state event
timestamp) alongside `head` in `run_refresh` instead of discarding them.
2. New `SyncStatusStore` (following the `CheckoutsStore` pattern: global
entity, background compute, notify-on-change) that:
- watches own announcements (`RepoListStore` + `Backend::current_user`;
for repositories the user does not own, sync means PRs, which is out of
scope);
- reads the latest kind 30618 event per own repository from the Nostr LMDB
(`RepoListStore::sync_own_repo_states` already ensures those events are
synced);
- for each associated checkout (`CheckoutsStore::associations_of`)
computes `RepoSyncStatus` against the local refs on the background
executor;
- recomputes on app open, `BackendEvent::Synced` / `RepoUpdates` (state
kind), after a successful push, and on a slow poll (~60s, like
`PUSH_POLL`).
### Phase 3 — UI: suggest sync
- Sidebar: out-of-sync badge on own repository rows, reusing the `unpushed`
badge pattern, visually distinct (e.g. "N commits not on Nostr" tooltip).
- RepoDetailView: banner modeled on `render_push_banner`: "main is 3 commits
ahead of Nostr — Sync now", calling the existing `store.push_checkout(path)`.
## Open decisions
1. Badge placement: sidebar only, or sidebar plus repo-detail banner in the
first pass. Recommendation: both — the banner hosts the action button, the
badge drives the click.