diff --git a/crates/workspace/src/views/repo/banners.rs b/crates/workspace/src/views/repo/banners.rs index 06a4967..e0fca04 100644 --- a/crates/workspace/src/views/repo/banners.rs +++ b/crates/workspace/src/views/repo/banners.rs @@ -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, ready_requested: bool, - /// Re-requested only when the announced HEAD or the base default changes. ready_head: Option, ready_statuses: Vec, push_statuses: Vec, @@ -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) { (self.ready_requested, &self.ready_head) } diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 784e692..14c39c4 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -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 { 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 { + 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) -> Option { 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) -> Option { + 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) -> Option { 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) -> 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 diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 2cd910b..4b0165f 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -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>, relays: Option>, banner: SharedString, + scanning: bool, /// User's announced repositories. announcements: Arc>, /// Local repositories found by the scan that are not announced yet. local_repos: Arc>, - scanning: bool, /// Unpushed commit counts per announced repository. unpushed: HashMap, + /// Commits not on Nostr per announced repository. + unsynced: HashMap, _subscriptions: Vec, } @@ -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) -> 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) { 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,