feat: add sync detection for repositories #28

Merged
reya merged 5 commits from repo-sync into master 2026-10-08 02:34:36 +00:00
3 changed files with 206 additions and 29 deletions
Showing only changes of commit 35dcdf1bf6 - Show all commits
+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,