feat: add sync detection for repositories (#28)
Reviewed-on: #28
This commit was merged in pull request #28.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user