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
+2
View File
@@ -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> {
+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::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")?;
+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 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);
}
+22 -7
View File
@@ -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}"));
+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::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)
}
+127 -4
View File
@@ -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
+69 -23
View File
@@ -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,