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())
}
/// 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`.
/// 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.
// Their polls stop too.
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
if matches!(event, BackendEvent::SignerChanged) {
this.status_requested.clear();
@@ -246,7 +245,6 @@ impl CheckoutsStore {
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
});
+1 -5
View File
@@ -30,9 +30,7 @@ pub fn init(
cx: &mut App,
) -> Entity<Backend> {
// rustls uses the `aws_lc_rs` provider by default.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok();
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (client, signer) = cx.foreground_executor().block_on(async move {
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));
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), 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);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), 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>) {
self._subscriptions
.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);
cx.notify();
}));
self.refresh_ready_statuses(cx);
}
+64 -96
View File
@@ -13,14 +13,16 @@ use gpui::{
SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list,
};
use gpui_base::Button as BaseButton;
use gpui_component::badge::Badge;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState;
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_state::{
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};
@@ -33,32 +35,22 @@ mod settings_dialog;
use self::onboarding_dialog::OnboardingState;
/// Left-dock panel with navigation entries.
/// Entries open content panels in the dock area.
pub struct SidebarPanel {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
explore: Option<WeakEntity<RepoListView>>,
logged_in: bool,
/// Repositories the current user announced, listed under the All Repositories heading.
/// Recreated when the signer changes.
my_repos: Option<Entity<RepoListStore>>,
repos: Option<Entity<RepoListStore>>,
/// 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.
/// Picked at random from the bundled `backgrounds/` assets.
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,
}
impl SidebarPanel {
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 logged_in = backend.read(cx).current_user().is_some();
@@ -66,70 +58,60 @@ impl SidebarPanel {
match event {
BackendEvent::SignerChanged => {
this.logged_in = backend.read(cx).current_user().is_some();
this.refresh_my_repos(cx);
this.refresh_repos(cx);
}
BackendEvent::SignerRequired => {
this.logged_in = false;
this.banner = pick_banner();
this.my_repos = None;
this.my_repos_subscription = None;
this.repos = None;
this.repos_subscription = None;
}
_ => return,
}
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 {
focus_handle: cx.focus_handle(),
dock_area,
logged_in,
explore: None,
my_repos: None,
my_repos_subscription: None,
repos: None,
repos_subscription: None,
banner: pick_banner(),
_local_repos_subscription: local_repos_subscription,
_checkouts_subscription: checkouts_subscription,
_subscription: subscription,
};
if logged_in {
panel.refresh_my_repos(cx);
panel.refresh_repos(cx);
cx.notify();
}
panel
}
/// Recreate the store listing the current user's repositories.
///
/// Watch each repository for unpushed local work.
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None;
fn refresh_repos(&mut self, cx: &mut Context<Self>) {
self.repos_subscription = None;
let checkouts = CheckoutsStore::global(cx);
let backend = Backend::global(cx);
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() {
self.my_repos_subscription = Some(cx.observe(store, |_this, store, cx| {
cx.notify();
// These are the signed-in user's own repositories.
// Request their ready-to-push statuses, deduplicated per repository.
// The rows carry a badge while local work is unpushed.
let addrs: Vec<_> = store
// Create a new repo list store for the signed-in user, if they are logged in.
self.repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
if let Some(store) = self.repos.as_ref() {
self.repos_subscription = Some(cx.observe(store, move |_, store, cx| {
let addrs: Vec<Coordinate> = store
.read(cx)
.announcements
.iter()
.map(|a| a.addr())
.collect();
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| {
for addr in addrs {
checkouts.request_push_statuses(&addr, cx);
@@ -193,22 +175,21 @@ impl SidebarPanel {
}
/// Open a local repository's detail view in the dock's center.
///
/// 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>) {
let detail =
cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx));
let _ = self.dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(detail), window, cx);
});
self.dock_area
.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(detail), window, cx);
})
.ok();
}
/// The All Repositories section of the sidebar.
/// A header with the create button above the current user's repositories.
/// 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();
fn render_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.repos.as_ref();
let local = LocalReposStore::global(cx);
let local_repos = local.read(cx).repos.clone();
let scanning = local.read(cx).scanning;
@@ -260,22 +241,19 @@ impl SidebarPanel {
)
.when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone();
// Local repositories already published to NIP-34 appear above.
// 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 ids: HashSet<String> = announcements.iter().map(|a| a.id.clone()).collect();
let local_repos: Vec<PathBuf> = local_repos
.iter()
.filter(|path| {
let Some(name) = path.file_name() else {
return true;
};
!announced_ids.contains(&identifier_from_name(&name.to_string_lossy()))
!ids.contains(&identifier_from_name(&name.to_string_lossy()))
})
.cloned()
.collect();
// One merged list, the user's NIP-34 repositories first.
// Local repositories discovered by the scan follow.
let total = announcements.len() + local_repos.len();
@@ -288,10 +266,12 @@ impl SidebarPanel {
.py_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(if scanning {
"Scanning for local repositories…"
} else {
"No repositories yet"
.map(|this| {
if scanning {
this.child("Scanning for local repositories…")
} else {
this.child("No repositories yet")
}
}),
)
} else {
@@ -299,16 +279,11 @@ impl SidebarPanel {
uniform_list(
"repos",
total,
cx.processor(move |this, range: Range<usize>, _window, cx| {
cx.processor(move |this, range: Range<usize>, _, cx| {
range
.map(|ix| {
this.render_repo_row_at(
&announcements,
&local_repos,
ix,
cx,
)
.into_any_element()
this.render_repo_at(&announcements, &local_repos, ix, cx)
.into_any_element()
})
.collect()
}),
@@ -321,7 +296,7 @@ impl SidebarPanel {
}
/// One row of the merged sidebar list, a NIP-34 or a local repository.
fn render_repo_row_at(
fn render_repo_at(
&self,
announcements: &[Announcement],
local_repos: &[PathBuf],
@@ -345,27 +320,24 @@ impl SidebarPanel {
announcement: &Announcement,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = announcement
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let name = announcement.name().map(SharedString::from);
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.
// 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)
.push_statuses_of(&announcement.addr())
.iter()
.map(|status| status.ahead as usize)
.sum();
let announcement = announcement.clone();
let mut row = NavItem::new(format!("my-repo:{}", announcement.id), name, avatar);
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
if unpushed > 0 {
row = row.suffix(CountBadge::new(unpushed));
row = row.suffix(Badge::new().count(unpushed).xsmall());
}
row.on_click(
@@ -373,29 +345,26 @@ 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.
/// Clicking opens the detail view, which offers to initialize it.
fn render_local_row(&self, path: &Path, cx: &mut Context<Self>) -> impl IntoElement {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let path = path.to_path_buf();
let avatar = PixelAvatar::new(path.to_string_lossy());
NavItem::new(
format!("local-repo:{}", path.display()),
name,
PixelAvatar::new(path.to_string_lossy()),
)
.suffix(
Icon::new(IconName::TriangleAlert)
.small()
.text_color(cx.theme().warning),
)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_local_repo(path.clone(), window, cx);
}))
NavItem::new(format!("local-repo:{}", path.display()), name, avatar)
.suffix(
Icon::new(IconName::TriangleAlert)
.small()
.text_color(cx.theme().warning),
)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.open_local_repo(path.clone(), window, cx);
}))
}
/// Show the Import Identity dialog.
@@ -434,7 +403,6 @@ impl SidebarPanel {
}
/// 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 {
v_flex()
.size_full()
@@ -598,7 +566,7 @@ impl Render for SidebarPanel {
)),
),
)
.child(self.render_my_repos(cx)),
.child(self.render_repos(cx)),
)
.child(
v_flex()
@@ -267,23 +267,14 @@ impl SettingsControls {
/// Open the Settings dialog.
pub fn open(window: &mut Window, cx: &mut App) {
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| {
let dialog_state = dialog_state.clone();
let controls = controls.clone();
dialog
.title("Settings")
.width(px(650.))
.h(px(560.))
.child(settings_view(&dialog_state.0, cx))
.child(settings_view(&controls, cx))
});
}