feat: add sync detection for repositories #28
@@ -4,7 +4,8 @@
|
||||
* 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.
|
||||
* 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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user