add sync banner and unsynced badges for repo commits ahead of nostr

This commit is contained in:
2026-10-08 09:30:18 +07:00
parent 967a59007d
commit 35dcdf1bf6
3 changed files with 206 additions and 29 deletions
+10 -2
View File
@@ -1,13 +1,13 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use signed_state::CheckoutStatus; use signed_state::{CheckoutStatus, CheckoutSyncStatus};
#[derive(Default)] #[derive(Default)]
pub(super) struct Banners { pub(super) struct Banners {
dismissed: HashSet<(PathBuf, String)>, dismissed: HashSet<(PathBuf, String)>,
sync_dismissed: HashSet<PathBuf>,
ready_requested: bool, ready_requested: bool,
/// Re-requested only when the announced HEAD or the base default changes.
ready_head: Option<String>, ready_head: Option<String>,
ready_statuses: Vec<CheckoutStatus>, ready_statuses: Vec<CheckoutStatus>,
push_statuses: Vec<CheckoutStatus>, push_statuses: Vec<CheckoutStatus>,
@@ -24,6 +24,14 @@ impl Banners {
.insert((status.path.clone(), status.branch.clone())); .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>) { pub(super) fn ready_requested_at(&self) -> (bool, &Option<String>) {
(self.ready_requested, &self.ready_head) (self.ready_requested, &self.ready_head)
} }
+127 -4
View File
@@ -23,10 +23,10 @@ use gpui_component::{
use nostr::nips::nip19::Nip19Coordinate; use nostr::nips::nip19::Nip19Coordinate;
use nostr::prelude::{RelayUrl, ToBech32, Url}; use nostr::prelude::{RelayUrl, ToBech32, Url};
use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_core::{Announcement, RepoAddr, RepoStatus};
use signed_git::{FileCommit, GitCache, Repo}; use signed_git::{FileCommit, GitCache, RefSync, Repo};
use signed_state::{ use signed_state::{
Backend, CheckoutStatus, CheckoutsStore, LocalReposStore, Mirrors, Nip34Binding, Nip34Kind, Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
ProfileStore, RepoListStore, RepoStore, Nip34Binding, Nip34Kind, ProfileStore, RepoListStore, RepoStore, SyncStatusStore,
}; };
use signed_ui::{ use signed_ui::{
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger, Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger,
@@ -153,6 +153,7 @@ impl RepoDetailView {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
let sync_status = SyncStatusStore::global(cx);
let files = cx.new(RepoFilesView::new); let files = cx.new(RepoFilesView::new);
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone())); let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
let refs = RefSwitcher::new(window, cx); 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. // Defer loading the repository until the window is ready.
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.load_repo(window, cx); this.load_repo(window, cx);
@@ -1535,6 +1541,26 @@ impl RepoDetailView {
.find(|status| !self.banners.dismissal(status)) .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> { pub(super) fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.push_suggestion(cx)?; let status = self.push_suggestion(cx)?;
let path = status.path.clone(); 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> { pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let store = self.store.read(cx); let store = self.store.read(cx);
let warning = store.last_push_warning.clone()?; 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 { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let banner = self let banner = self
.render_ready_banner(cx) .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(|| { let error = self.error.clone().or_else(|| {
self.store self.store
+69 -23
View File
@@ -10,8 +10,9 @@ use dock::{
}; };
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ObjectFit,
SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white, Render, SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list,
white,
}; };
use gpui_base::Button as BaseButton; use gpui_base::Button as BaseButton;
use gpui_component::button::{Button, ButtonVariant, ButtonVariants}; use gpui_component::button::{Button, ButtonVariant, ButtonVariants};
@@ -23,7 +24,7 @@ use nostr::prelude::RelayUrl;
use signed_core::{Announcement, RepoAddr}; use signed_core::{Announcement, RepoAddr};
use signed_state::{ use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile,
ProfileStore, RepoListStore, ResolvedLocalRepo, ProfileStore, RepoListStore, ResolvedLocalRepo, SyncStatusStore,
}; };
use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers}; use signed_ui::{Avatar, NavItem, PixelAvatar, title_bar_drag_handlers};
@@ -59,13 +60,15 @@ pub struct SidebarPanel {
profile: Option<WeakEntity<PlaceholderPanel>>, profile: Option<WeakEntity<PlaceholderPanel>>,
relays: Option<WeakEntity<PlaceholderPanel>>, relays: Option<WeakEntity<PlaceholderPanel>>,
banner: SharedString, banner: SharedString,
scanning: bool,
/// User's announced repositories. /// User's announced repositories.
announcements: Arc<Vec<Announcement>>, announcements: Arc<Vec<Announcement>>,
/// Local repositories found by the scan that are not announced yet. /// Local repositories found by the scan that are not announced yet.
local_repos: Arc<Vec<ResolvedLocalRepo>>, local_repos: Arc<Vec<ResolvedLocalRepo>>,
scanning: bool,
/// Unpushed commit counts per announced repository. /// Unpushed commit counts per announced repository.
unpushed: HashMap<RepoAddr, usize>, unpushed: HashMap<RepoAddr, usize>,
/// Commits not on Nostr per announced repository.
unsynced: HashMap<RepoAddr, usize>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -75,6 +78,7 @@ impl SidebarPanel {
let repos = RepoListStore::global(cx); let repos = RepoListStore::global(cx);
let local = LocalReposStore::global(cx); let local = LocalReposStore::global(cx);
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
let sync_status = SyncStatusStore::global(cx);
let mut subscriptions = Vec::new(); 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 { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
@@ -124,6 +134,7 @@ impl SidebarPanel {
local_repos: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()),
scanning: false, scanning: false,
unpushed: HashMap::new(), unpushed: HashMap::new(),
unsynced: HashMap::new(),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -158,6 +169,7 @@ impl SidebarPanel {
if announcements_changed { if announcements_changed {
self.request_push_watches(cx); self.request_push_watches(cx);
self.unpushed.clear(); self.unpushed.clear();
self.unsynced.clear();
} }
announcements_changed || local_changed || scanning_changed announcements_changed || local_changed || scanning_changed
@@ -183,6 +195,26 @@ impl SidebarPanel {
true 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>) { fn request_push_watches(&self, cx: &mut Context<Self>) {
let checkouts = CheckoutsStore::global(cx); let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| { checkouts.update(cx, |checkouts, cx| {
@@ -482,28 +514,28 @@ impl SidebarPanel {
PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall(); PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
let announcement = announcement.clone(); let announcement = announcement.clone();
let unpushed = self let addr = announcement.addr();
.unpushed let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
.get(&announcement.addr()) let unsynced = self.unsynced.get(&addr).copied().unwrap_or(0);
.copied()
.unwrap_or(0);
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar); let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
if unpushed > 0 { if unpushed > 0 || unsynced > 0 {
row = row.suffix( let badges = h_flex().gap_0p5().flex_shrink_0();
v_flex()
.flex_shrink_0() let badges = if unpushed > 0 {
.size_4() badges.child(Self::count_badge(unpushed, cx.theme().red_light))
.items_center() } else {
.justify_center() badges
.rounded_full() };
.line_height(relative(1.))
.bg(cx.theme().red_light) let badges = if unsynced > 0 {
.text_color(white()) badges.child(Self::count_badge(unsynced, cx.theme().info))
.text_size(px(8.)) } else {
.child(SharedString::from(unpushed.to_string())), badges
); };
row = row.suffix(badges);
} }
row.on_click(cx.listener(move |this, _ev, window, cx| { 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( fn render_local_row(
&self, &self,
entry: &ResolvedLocalRepo, entry: &ResolvedLocalRepo,