feat: add sync detection for repositories #28
@@ -2,7 +2,10 @@
|
||||
|
||||
* 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.
|
||||
* 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 `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.
|
||||
* 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:
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
- 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 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
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
- Restructure the core crate into focused modules for announcements, pull requests, git events, inbox, and upstream state
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ mod nip34;
|
||||
mod patch;
|
||||
mod repo;
|
||||
mod scan;
|
||||
mod sync;
|
||||
mod worktree;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -17,6 +18,7 @@ pub use nip34::{GraspSignals, Nip34Binding, Nip34Kind};
|
||||
pub use patch::PatchParser;
|
||||
pub use repo::{Repo, RepoRefState};
|
||||
pub use scan::{LocalRepo, find_git_repos};
|
||||
pub use sync::{RefSync, RepoSyncStatus};
|
||||
pub use worktree::WorktreeSnapshot;
|
||||
|
||||
pub(crate) trait GixResultExt<T> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
@@ -13,6 +13,7 @@ use crate::GixResultExt as _;
|
||||
use crate::diff::{CommitDiff, DiffStatus, FileDiff, HunkCollector};
|
||||
use crate::history::{CommitList, FileCommit, MAX_LISTED_COMMITS};
|
||||
use crate::nip34::{GraspSignals, Nip34Binding, Nip34Json, Nip34Kind};
|
||||
use crate::sync::{RefSync, RepoSyncStatus};
|
||||
use crate::worktree::WorktreeSnapshot;
|
||||
|
||||
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||
@@ -797,6 +798,86 @@ impl Repo {
|
||||
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.
|
||||
pub fn entries(&self) -> Result<Vec<PathBuf>> {
|
||||
let workdir = self.inner.workdir().context("repository has no worktree")?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod push;
|
||||
mod refresh;
|
||||
mod repo;
|
||||
mod repos;
|
||||
mod sync_status;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -27,6 +28,7 @@ pub use repo::RepoStore;
|
||||
pub use repos::RepoListStore;
|
||||
pub use signed_git::{Nip34Binding, Nip34Kind};
|
||||
use signed_nostr::NostrBackend;
|
||||
pub use sync_status::{CheckoutSyncStatus, SyncStatusStore};
|
||||
|
||||
pub fn init(
|
||||
db_path: impl AsRef<Path>,
|
||||
@@ -50,4 +52,5 @@ pub fn init(
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||
SyncStatusStore::set_global(cx.new(SyncStatusStore::new), cx);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::bootstrap::user_grasp_list_servers;
|
||||
use crate::checkouts::CheckoutsStore;
|
||||
use crate::push::{GraspPush, PushOutcome, grasp_base_url, grasp06_prs_url, pr_clone_urls};
|
||||
use crate::repos::RepoListStore;
|
||||
use crate::sync_status::SyncStatusStore;
|
||||
|
||||
// NIP-34 suggests patches when each event is under 60kb.
|
||||
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.
|
||||
pub loaded: bool,
|
||||
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 patches: Vec<Event>,
|
||||
pub pull_requests: Vec<Event>,
|
||||
@@ -88,6 +92,8 @@ impl RepoStore {
|
||||
nip34: None,
|
||||
loaded: false,
|
||||
head: None,
|
||||
state_refs: Vec::new(),
|
||||
state_at: None,
|
||||
issues: Vec::new(),
|
||||
patches: Vec::new(),
|
||||
pull_requests: Vec::new(),
|
||||
@@ -116,6 +122,8 @@ impl RepoStore {
|
||||
nip34,
|
||||
loaded: true,
|
||||
head: None,
|
||||
state_refs: Vec::new(),
|
||||
state_at: None,
|
||||
issues: Vec::new(),
|
||||
patches: Vec::new(),
|
||||
pull_requests: Vec::new(),
|
||||
@@ -349,7 +357,8 @@ impl RepoStore {
|
||||
.and_then(Announcement::from_event);
|
||||
|
||||
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) =
|
||||
(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 first_pass = !this.loaded;
|
||||
|
||||
let head_changed = state
|
||||
.as_ref()
|
||||
.is_some_and(|state| this.head.as_deref() != state.head.as_deref());
|
||||
let state_changed = state.as_ref().is_some_and(|(state, _)| {
|
||||
this.head.as_deref() != state.head.as_deref() || this.state_refs != state.refs
|
||||
});
|
||||
|
||||
let changed = first_pass
|
||||
|| (!keep_hint && this.announcement != announcement)
|
||||
|| head_changed
|
||||
|| state_changed
|
||||
|| this.issues != issues
|
||||
|| this.patches != patches
|
||||
|| this.pull_requests != pull_requests
|
||||
@@ -499,8 +508,10 @@ impl RepoStore {
|
||||
|
||||
this.sync_maintainer_relays(&maintainers, cx);
|
||||
|
||||
if let Some(state) = state {
|
||||
if let Some((state, created_at)) = state {
|
||||
this.head = state.head;
|
||||
this.state_refs = state.refs;
|
||||
this.state_at = Some(created_at);
|
||||
}
|
||||
|
||||
this.issues = issues;
|
||||
@@ -1169,10 +1180,14 @@ impl RepoStore {
|
||||
this.last_error = None;
|
||||
this.last_push_warning = outcome.partial_warning();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
let sync_status = SyncStatusStore::global(cx);
|
||||
sync_status.update(cx, |store, cx| store.refresh(cx));
|
||||
}
|
||||
Err(e) => {
|
||||
this.last_error = Some(format!("Push failed: {e}"));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use signed_state::CheckoutStatus;
|
||||
use signed_state::{CheckoutStatus, CheckoutSyncStatus};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Banners {
|
||||
dismissed: HashSet<(PathBuf, String)>,
|
||||
sync_dismissed: HashSet<PathBuf>,
|
||||
ready_requested: bool,
|
||||
/// Re-requested only when the announced HEAD or the base default changes.
|
||||
ready_head: Option<String>,
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
@@ -24,6 +24,14 @@ impl Banners {
|
||||
.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>) {
|
||||
(self.ready_requested, &self.ready_head)
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ use gpui_component::{
|
||||
use nostr::nips::nip19::Nip19Coordinate;
|
||||
use nostr::prelude::{RelayUrl, ToBech32, Url};
|
||||
use signed_core::{Announcement, RepoAddr, RepoStatus};
|
||||
use signed_git::{FileCommit, GitCache, Repo};
|
||||
use signed_git::{FileCommit, GitCache, RefSync, Repo};
|
||||
use signed_state::{
|
||||
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Mirrors, Nip34Binding, Nip34Kind,
|
||||
ProfileStore, RepoListStore, RepoStore,
|
||||
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
|
||||
Nip34Binding, Nip34Kind, ProfileStore, RepoListStore, RepoStore, SyncStatusStore,
|
||||
};
|
||||
use signed_ui::{
|
||||
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger,
|
||||
@@ -153,6 +153,7 @@ 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);
|
||||
@@ -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.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load_repo(window, cx);
|
||||
@@ -1535,6 +1541,26 @@ impl RepoDetailView {
|
||||
.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> {
|
||||
let status = self.push_suggestion(cx)?;
|
||||
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> {
|
||||
let store = self.store.read(cx);
|
||||
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 {
|
||||
let banner = self
|
||||
.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(|| {
|
||||
self.store
|
||||
|
||||
@@ -10,8 +10,9 @@ use dock::{
|
||||
};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white,
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ObjectFit,
|
||||
Render, SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list,
|
||||
white,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::button::{Button, ButtonVariant, ButtonVariants};
|
||||
@@ -23,7 +24,7 @@ use nostr::prelude::RelayUrl;
|
||||
use signed_core::{Announcement, RepoAddr};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
|
||||
ProfileStore, RepoListStore, ResolvedLocalRepo,
|
||||
ProfileStore, RepoListStore, ResolvedLocalRepo, SyncStatusStore,
|
||||
};
|
||||
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
|
||||
|
||||
@@ -59,13 +60,15 @@ pub struct SidebarPanel {
|
||||
profile: Option<WeakEntity<PlaceholderPanel>>,
|
||||
relays: Option<WeakEntity<PlaceholderPanel>>,
|
||||
banner: SharedString,
|
||||
scanning: bool,
|
||||
/// User's announced repositories.
|
||||
announcements: Arc<Vec<Announcement>>,
|
||||
/// Local repositories found by the scan that are not announced yet.
|
||||
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||
scanning: bool,
|
||||
/// Unpushed commit counts per announced repository.
|
||||
unpushed: HashMap<RepoAddr, usize>,
|
||||
/// Commits not on Nostr per announced repository.
|
||||
unsynced: HashMap<RepoAddr, usize>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -75,6 +78,7 @@ 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();
|
||||
|
||||
@@ -112,6 +116,12 @@ 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,
|
||||
@@ -124,6 +134,7 @@ impl SidebarPanel {
|
||||
local_repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
unpushed: HashMap::new(),
|
||||
unsynced: HashMap::new(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
@@ -158,6 +169,7 @@ impl SidebarPanel {
|
||||
if announcements_changed {
|
||||
self.request_push_watches(cx);
|
||||
self.unpushed.clear();
|
||||
self.unsynced.clear();
|
||||
}
|
||||
|
||||
announcements_changed || local_changed || scanning_changed
|
||||
@@ -183,6 +195,26 @@ 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| {
|
||||
@@ -482,28 +514,28 @@ impl SidebarPanel {
|
||||
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
|
||||
let announcement = announcement.clone();
|
||||
|
||||
let unpushed = self
|
||||
.unpushed
|
||||
.get(&announcement.addr())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
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 mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
||||
|
||||
if unpushed > 0 {
|
||||
row = row.suffix(
|
||||
v_flex()
|
||||
.flex_shrink_0()
|
||||
.size_4()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.line_height(relative(1.))
|
||||
.bg(cx.theme().red_light)
|
||||
.text_color(white())
|
||||
.text_size(px(8.))
|
||||
.child(SharedString::from(unpushed.to_string())),
|
||||
);
|
||||
if unpushed > 0 || unsynced > 0 {
|
||||
let badges = h_flex().gap_0p5().flex_shrink_0();
|
||||
|
||||
let badges = if unpushed > 0 {
|
||||
badges.child(Self::count_badge(unpushed, cx.theme().red_light))
|
||||
} else {
|
||||
badges
|
||||
};
|
||||
|
||||
let badges = if unsynced > 0 {
|
||||
badges.child(Self::count_badge(unsynced, cx.theme().info))
|
||||
} else {
|
||||
badges
|
||||
};
|
||||
|
||||
row = row.suffix(badges);
|
||||
}
|
||||
|
||||
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(
|
||||
&self,
|
||||
entry: &ResolvedLocalRepo,
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user