update checkout store

This commit is contained in:
2026-09-04 20:13:10 +07:00
parent 15c2122f38
commit 1f3afea81b
6 changed files with 73 additions and 117 deletions
+5
View File
@@ -271,6 +271,11 @@ impl Announcement {
crate::repo_addr(self.owner, self.id.clone()) crate::repo_addr(self.owner, self.id.clone())
} }
/// The name of the repository, or a default if none is provided.
pub fn name(&self) -> String {
self.name.clone().unwrap_or("Untitled".into())
}
/// Whether this announcement is a fork of the repository at `base`. /// Whether this announcement is a fork of the repository at `base`.
/// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged. /// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged.
/// ///
-2
View File
@@ -122,7 +122,6 @@ impl CheckoutsStore {
})); }));
// Another identity's repositories must not keep the old statuses alive. // Another identity's repositories must not keep the old statuses alive.
// Their polls stop too.
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
if matches!(event, BackendEvent::SignerChanged) { if matches!(event, BackendEvent::SignerChanged) {
this.status_requested.clear(); this.status_requested.clear();
@@ -246,7 +245,6 @@ impl CheckoutsStore {
let task = cx.spawn(async move |this, cx| { let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await; cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx)) this.update(cx, |this, cx| this.run_refresh(cx))
}); });
+1 -5
View File
@@ -30,9 +30,7 @@ pub fn init(
cx: &mut App, cx: &mut App,
) -> Entity<Backend> { ) -> Entity<Backend> {
// rustls uses the `aws_lc_rs` provider by default. // rustls uses the `aws_lc_rs` provider by default.
rustls::crypto::aws_lc_rs::default_provider() let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
.install_default()
.ok();
let (client, signer) = cx.foreground_executor().block_on(async move { let (client, signer) = cx.foreground_executor().block_on(async move {
let path = db_path.as_ref().to_path_buf(); let path = db_path.as_ref().to_path_buf();
@@ -44,9 +42,7 @@ pub fn init(
let entity = cx.new(|cx| Backend::new(client, signer, cx)); let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx); Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx); ProfileStore::set_global(cx.new(ProfileStore::new), cx);
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx); RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The local git clone cache, the grasp mirrors.
GitStore::set_global(repos_root, cx); GitStore::set_global(repos_root, cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
@@ -1836,10 +1836,8 @@ impl RepoDetailView {
fn attach_store(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) { fn attach_store(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) {
self._subscriptions self._subscriptions
.push(cx.observe(store, |this, _store, cx| { .push(cx.observe(store, |this, _store, cx| {
cx.notify();
// The first refresh fills the announced HEAD.
// It defaults the banner's base branch, re-request when it changes.
this.refresh_ready_statuses(cx); this.refresh_ready_statuses(cx);
cx.notify();
})); }));
self.refresh_ready_statuses(cx); self.refresh_ready_statuses(cx);
} }
+53 -85
View File
@@ -13,14 +13,16 @@ use gpui::{
SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list, SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list,
}; };
use gpui_base::Button as BaseButton; use gpui_base::Button as BaseButton;
use gpui_component::badge::Badge;
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState; use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use nostr::nips::nip01::Coordinate;
use signed_core::{Announcement, identifier_from_name}; use signed_core::{Announcement, identifier_from_name};
use signed_state::{ use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
}; };
use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use super::{RepoDetailView, RepoListView, open_repo_panel}; use super::{RepoDetailView, RepoListView, open_repo_panel};
@@ -33,32 +35,22 @@ mod settings_dialog;
use self::onboarding_dialog::OnboardingState; use self::onboarding_dialog::OnboardingState;
/// Left-dock panel with navigation entries.
/// Entries open content panels in the dock area.
pub struct SidebarPanel { pub struct SidebarPanel {
focus_handle: FocusHandle, focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>, explore: Option<WeakEntity<RepoListView>>,
logged_in: bool, logged_in: bool,
/// Repositories the current user announced, listed under the All Repositories heading. /// Repositories the current user announced, listed under the All Repositories heading.
/// Recreated when the signer changes. repos: Option<Entity<RepoListStore>>,
my_repos: Option<Entity<RepoListStore>>,
/// Observes the current user's repo store so the list re-renders. /// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option<Subscription>, repos_subscription: Option<Subscription>,
/// Banner artwork behind the sign-in screen. /// Banner artwork behind the sign-in screen.
/// Picked at random from the bundled `backgrounds/` assets.
banner: SharedString, banner: SharedString,
/// Observes the local-repository scan so new discoveries re-render.
_local_repos_subscription: Subscription,
/// Observes the checkouts store.
/// Its ready-to-push statuses feed the badges on the user's repo rows.
_checkouts_subscription: Subscription,
_subscription: Subscription, _subscription: Subscription,
} }
impl SidebarPanel { impl SidebarPanel {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self { pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let local_repos_store = LocalReposStore::global(cx);
let backend = Backend::global(cx); let backend = Backend::global(cx);
let logged_in = backend.read(cx).current_user().is_some(); let logged_in = backend.read(cx).current_user().is_some();
@@ -66,70 +58,60 @@ impl SidebarPanel {
match event { match event {
BackendEvent::SignerChanged => { BackendEvent::SignerChanged => {
this.logged_in = backend.read(cx).current_user().is_some(); this.logged_in = backend.read(cx).current_user().is_some();
this.refresh_my_repos(cx); this.refresh_repos(cx);
} }
BackendEvent::SignerRequired => { BackendEvent::SignerRequired => {
this.logged_in = false; this.logged_in = false;
this.banner = pick_banner(); this.banner = pick_banner();
this.my_repos = None; this.repos = None;
this.my_repos_subscription = None; this.repos_subscription = None;
} }
_ => return, _ => return,
} }
cx.notify(); cx.notify();
}); });
let local_repos_subscription = cx.observe(&local_repos_store, |_, _, cx| {
cx.notify();
});
let checkouts_store = CheckoutsStore::global(cx);
let checkouts_subscription = cx.observe(&checkouts_store, |_, _, cx| {
cx.notify();
});
let mut panel = Self { let mut panel = Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
dock_area, dock_area,
logged_in, logged_in,
explore: None, explore: None,
my_repos: None, repos: None,
my_repos_subscription: None, repos_subscription: None,
banner: pick_banner(), banner: pick_banner(),
_local_repos_subscription: local_repos_subscription,
_checkouts_subscription: checkouts_subscription,
_subscription: subscription, _subscription: subscription,
}; };
if logged_in { if logged_in {
panel.refresh_my_repos(cx); panel.refresh_repos(cx);
cx.notify();
} }
panel panel
} }
/// Recreate the store listing the current user's repositories. /// Recreate the store listing the current user's repositories.
///
/// Watch each repository for unpushed local work. /// Watch each repository for unpushed local work.
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) { fn refresh_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None; self.repos_subscription = None;
let checkouts = CheckoutsStore::global(cx);
let backend = Backend::global(cx); let backend = Backend::global(cx);
let author = backend.read(cx).current_user(); let author = backend.read(cx).current_user();
self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
if let Some(store) = self.my_repos.as_ref() { // Create a new repo list store for the signed-in user, if they are logged in.
self.my_repos_subscription = Some(cx.observe(store, |_this, store, cx| { self.repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
cx.notify();
// These are the signed-in user's own repositories. if let Some(store) = self.repos.as_ref() {
// Request their ready-to-push statuses, deduplicated per repository. self.repos_subscription = Some(cx.observe(store, move |_, store, cx| {
// The rows carry a badge while local work is unpushed. let addrs: Vec<Coordinate> = store
let addrs: Vec<_> = store
.read(cx) .read(cx)
.announcements .announcements
.iter() .iter()
.map(|a| a.addr()) .map(|a| a.addr())
.collect(); .collect();
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| { checkouts.update(cx, |checkouts, cx| {
for addr in addrs { for addr in addrs {
checkouts.request_push_statuses(&addr, cx); checkouts.request_push_statuses(&addr, cx);
@@ -193,22 +175,21 @@ impl SidebarPanel {
} }
/// Open a local repository's detail view in the dock's center. /// Open a local repository's detail view in the dock's center.
///
/// The detail view offers to publish it to NIP-34. /// The detail view offers to publish it to NIP-34.
fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) { fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let detail = let detail =
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx)); cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
let _ = self.dock_area.update(cx, |dock_area, cx| { self.dock_area
.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(detail), window, cx); add_center_panel(dock_area, panel_handle(detail), window, cx);
}); })
.ok();
} }
/// The All Repositories section of the sidebar. fn render_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
/// A header with the create button above the current user's repositories. let store = self.repos.as_ref();
/// Rendered lazily through a [`uniform_list`].
/// Followed by local git repositories from the startup scan.
fn render_my_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.my_repos.as_ref();
let local = LocalReposStore::global(cx); let local = LocalReposStore::global(cx);
let local_repos = local.read(cx).repos.clone(); let local_repos = local.read(cx).repos.clone();
let scanning = local.read(cx).scanning; let scanning = local.read(cx).scanning;
@@ -260,22 +241,19 @@ impl SidebarPanel {
) )
.when_some(store, |builder, store| { .when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone(); let announcements = store.read(cx).announcements.clone();
// Local repositories already published to NIP-34 appear above. let ids: HashSet<String> = announcements.iter().map(|a| a.id.clone()).collect();
// Hide them from the local section here.
// Matched by the identifier derived from the directory name.
// Same derivation as the init dialog's default name.
let announced_ids: HashSet<String> =
announcements.iter().map(|a| a.id.clone()).collect();
let local_repos: Vec<PathBuf> = local_repos let local_repos: Vec<PathBuf> = local_repos
.iter() .iter()
.filter(|path| { .filter(|path| {
let Some(name) = path.file_name() else { let Some(name) = path.file_name() else {
return true; return true;
}; };
!announced_ids.contains(&identifier_from_name(&name.to_string_lossy())) !ids.contains(&identifier_from_name(&name.to_string_lossy()))
}) })
.cloned() .cloned()
.collect(); .collect();
// One merged list, the user's NIP-34 repositories first. // One merged list, the user's NIP-34 repositories first.
// Local repositories discovered by the scan follow. // Local repositories discovered by the scan follow.
let total = announcements.len() + local_repos.len(); let total = announcements.len() + local_repos.len();
@@ -288,10 +266,12 @@ impl SidebarPanel {
.py_1() .py_1()
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(if scanning { .map(|this| {
"Scanning for local repositories…" if scanning {
this.child("Scanning for local repositories…")
} else { } else {
"No repositories yet" this.child("No repositories yet")
}
}), }),
) )
} else { } else {
@@ -299,15 +279,10 @@ impl SidebarPanel {
uniform_list( uniform_list(
"repos", "repos",
total, total,
cx.processor(move |this, range: Range<usize>, _window, cx| { cx.processor(move |this, range: Range<usize>, _, cx| {
range range
.map(|ix| { .map(|ix| {
this.render_repo_row_at( this.render_repo_at(&announcements, &local_repos, ix, cx)
&announcements,
&local_repos,
ix,
cx,
)
.into_any_element() .into_any_element()
}) })
.collect() .collect()
@@ -321,7 +296,7 @@ impl SidebarPanel {
} }
/// One row of the merged sidebar list, a NIP-34 or a local repository. /// One row of the merged sidebar list, a NIP-34 or a local repository.
fn render_repo_row_at( fn render_repo_at(
&self, &self,
announcements: &[Announcement], announcements: &[Announcement],
local_repos: &[PathBuf], local_repos: &[PathBuf],
@@ -345,27 +320,24 @@ impl SidebarPanel {
announcement: &Announcement, announcement: &Announcement,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
let name = announcement let name = announcement.name().map(SharedString::from);
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
let announcement = announcement.clone();
// Badge with the unpushed commit count of the repository's local checkouts. // Badge with the unpushed commit count of the repository's local checkouts.
// The commits are ready to push to the grasp servers. // The commits are ready to push to the grasp servers.
let unpushed: usize = CheckoutsStore::global(cx) let checkout = CheckoutsStore::global(cx);
let unpushed = checkout
.read(cx) .read(cx)
.push_statuses_of(&announcement.addr()) .push_statuses_of(&announcement.addr())
.iter() .iter()
.map(|status| status.ahead as usize) .map(|status| status.ahead as usize)
.sum(); .sum();
let announcement = announcement.clone(); let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
let mut row = NavItem::new(format!("my-repo:{}", announcement.id), name, avatar);
if unpushed > 0 { if unpushed > 0 {
row = row.suffix(CountBadge::new(unpushed)); row = row.suffix(Badge::new().count(unpushed).xsmall());
} }
row.on_click( row.on_click(
@@ -373,21 +345,18 @@ impl SidebarPanel {
) )
} }
/// One local repository row, a deterministic pixel avatar seeded from the path. /// One local repository row.
///
/// The directory name and a warning suffix, the repo is not yet set up for NIP-34. /// The directory name and a warning suffix, the repo is not yet set up for NIP-34.
/// Clicking opens the detail view, which offers to initialize it.
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement { fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
let name = path let name = path
.file_name() .file_name()
.map(|name| name.to_string_lossy().into_owned()) .map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string()); .unwrap_or_else(|| path.display().to_string());
let path = path.to_path_buf(); let path = path.to_path_buf();
let avatar = PixelAvatar::new(path.to_string_lossy());
NavItem::new( NavItem::new(format!("local-repo:{}", path.display()), name, avatar)
format!("local-repo:{}", path.display()),
name,
PixelAvatar::new(path.to_string_lossy()),
)
.suffix( .suffix(
Icon::new(IconName::TriangleAlert) Icon::new(IconName::TriangleAlert)
.small() .small()
@@ -434,7 +403,6 @@ impl SidebarPanel {
} }
/// Sign-in placeholder shown while logged out. /// Sign-in placeholder shown while logged out.
/// Banner artwork behind a scrim keeps the CTA buttons readable in both themes.
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div { fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
v_flex() v_flex()
.size_full() .size_full()
@@ -598,7 +566,7 @@ impl Render for SidebarPanel {
)), )),
), ),
) )
.child(self.render_my_repos(cx)), .child(self.render_repos(cx)),
) )
.child( .child(
v_flex() v_flex()
@@ -267,23 +267,14 @@ impl SettingsControls {
/// Open the Settings dialog. /// Open the Settings dialog.
pub fn open(window: &mut Window, cx: &mut App) { pub fn open(window: &mut Window, cx: &mut App) {
let controls = Rc::new(SettingsControls::new(window, cx)); let controls = Rc::new(SettingsControls::new(window, cx));
let store = SettingsStore::global(cx);
let window_handle = window.window_handle();
let store_subscription = cx.observe(&store, move |_, cx| {
window_handle
.update(cx, |_, window, _| window.refresh())
.ok();
});
let dialog_state = Rc::new((controls, store_subscription));
window.open_dialog(cx, move |dialog, _window, cx| { window.open_dialog(cx, move |dialog, _window, cx| {
let dialog_state = dialog_state.clone(); let controls = controls.clone();
dialog dialog
.title("Settings") .title("Settings")
.width(px(650.)) .width(px(650.))
.h(px(560.)) .h(px(560.))
.child(settings_view(&dialog_state.0, cx)) .child(settings_view(&controls, cx))
}); });
} }