This commit is contained in:
2026-09-05 15:27:35 +07:00
parent 1f3afea81b
commit 7ff09502b9
8 changed files with 314 additions and 182 deletions
+96 -42
View File
@@ -184,24 +184,31 @@ pub struct RepoDetailView {
tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions keeping the selectors' confirm events alive.
_subscriptions: Vec<Subscription>,
/// Observes the checkouts store.
/// Its statuses feed the ready-to-contribute banner of the repository panel.
_checkouts_subscription: Subscription,
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
banner_dismissed: HashSet<(PathBuf, String)>,
/// The announced HEAD the ready statuses were last requested with.
/// Whether they were requested at all.
/// Re-requested only when the HEAD, the base default, changes.
/// E.g. when the store's first refresh lands.
/// e.g. when the store's first refresh lands.
ready_requested: bool,
ready_head: Option<String>,
/// The global checkouts store's ready-to-contribute statuses of this
/// repository, last seen when they drove a render.
///
/// The store notifies on any recompute pass; the observer re-renders this
/// panel only when these slices changed.
ready_statuses: Vec<CheckoutStatus>,
/// The global checkouts store's ready-to-push statuses of this repository,
/// last seen when they drove a render.
push_statuses: Vec<CheckoutStatus>,
/// Upstream repository, from this fork's `u` tag, the user asked to open.
/// Its announcement is still being fetched.
pending_upstream: Option<RepoAddr>,
}
impl RepoDetailView {
/// Open a repository announced on NIP-34.
/// Open a repository announced.
///
/// The store connects to the announcement's relays and loads issues, PRs and statuses.
pub fn new(
dock_area: WeakEntity<DockArea>,
@@ -228,8 +235,6 @@ impl RepoDetailView {
}
/// Open a local repository discovered by the scan.
/// There is no announcement and no nostr store until the user publishes it to NIP-34.
/// The header shows an Init button instead of the NIP-34 actions.
pub fn new_local(
dock_area: WeakEntity<DockArea>,
local_path: PathBuf,
@@ -240,6 +245,7 @@ impl RepoDetailView {
}
/// Shared construction.
///
/// File explorer state, ref selectors and the deferred repository load.
fn new_common(
dock_area: WeakEntity<DockArea>,
@@ -271,7 +277,7 @@ impl RepoDetailView {
.searchable(true)
});
let subscriptions = vec![
let mut subscriptions = vec![
cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| {
// `Change` fires only when the selection actually changed.
// Picking the already-selected branch emits nothing.
@@ -291,6 +297,17 @@ impl RepoDetailView {
}),
];
// The ready-to-contribute and ready-to-push banners are driven by the
// global checkouts store. It notifies on every recompute; compare the
// statuses of this repository so unrelated updates (the sidebar badges,
// other open panels) do not re-render this panel.
let checkouts = CheckoutsStore::global(cx);
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
if this.refresh_statuses(cx) {
cx.notify();
}
}));
// Defer loading the repository until the window is ready.
cx.defer_in(window, |this, window, cx| {
this.load_repo(window, cx);
@@ -328,15 +345,15 @@ impl RepoDetailView {
tag_select,
switching_ref: false,
ref_generation: 0,
focus_handle: cx.focus_handle(),
tasks: Vec::new(),
_subscriptions: subscriptions,
_checkouts_subscription: cx
.observe(&CheckoutsStore::global(cx), |_this, _store, cx| cx.notify()),
banner_dismissed: HashSet::new(),
ready_requested: false,
ready_head: None,
ready_statuses: Vec::new(),
push_statuses: Vec::new(),
pending_upstream: None,
focus_handle: cx.focus_handle(),
tasks: Vec::new(),
_subscriptions: subscriptions,
}
}
@@ -371,16 +388,20 @@ impl RepoDetailView {
})?;
Ok(())
});
self.tasks.push(task);
return;
}
let Some(initial) = self.initial.as_ref() else {
return;
};
let cache = GitStore::global(cx).cache().clone();
let addr = initial.addr();
let clone_urls: Vec<String> = initial.clone.iter().map(ToString::to_string).collect();
// Captured before the loads start.
// A branch/tag switch bumps the generation, discarding the refresh below.
let refresh_generation = self.ref_generation;
@@ -1414,8 +1435,7 @@ impl RepoDetailView {
RepoAction::Delete => this.delete_repository(window, cx),
}),
)
.px_4()
.pb_4()
.p_4()
.w_full()
.gap_8()
.border_b_1()
@@ -1880,6 +1900,26 @@ impl RepoDetailView {
});
}
/// The ready-to-push statuses of this repository in the global checkouts
/// store changed since they last drove a render.
///
/// Updates the cached slices. `None` store (a local, not yet published,
/// repository) has no statuses.
fn refresh_statuses(&mut self, cx: &mut Context<Self>) -> bool {
let Some(entity) = self.store.clone() else {
return false;
};
let addr = entity.read(cx).addr().clone();
let checkouts = CheckoutsStore::global(cx).read(cx);
let ready_statuses = checkouts.ready_statuses_of(&addr);
let push_statuses = checkouts.push_statuses_of(&addr);
let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses;
self.ready_statuses = ready_statuses;
self.push_statuses = push_statuses;
changed
}
/// The first checkout ready for a pull request on this repository.
/// Not covered by an open PR of the signed-in user.
/// Not dismissed in this panel.
@@ -1893,7 +1933,7 @@ impl RepoDetailView {
return None;
}
let statuses = CheckoutsStore::global(cx).read(cx).statuses_of(&addr);
let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr);
'status: for status in statuses {
if self
@@ -1916,15 +1956,19 @@ impl RepoDetailView {
}
/// The first checkout of this owned repository with unpushed commits.
///
/// Not dismissed in this panel.
fn push_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let entity = self.store.as_ref()?;
let user = Backend::global(cx).read(cx).current_user()?;
if !entity.read(cx).is_author(&user) {
return None;
}
let addr = entity.read(cx).addr().clone();
let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr);
statuses.into_iter().find(|status| {
!self
.banner_dismissed
@@ -1933,52 +1977,60 @@ impl RepoDetailView {
}
/// The ready-to-push banner of an owned repository.
///
/// A local checkout has unpushed commits, with a Push action and a dismiss control.
fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let status = self.push_suggestion(cx)?;
let key = (status.path.clone(), status.branch.clone());
let path = status.path.clone();
let commits = if status.ahead == 1 {
"1 commit".to_owned()
} else {
format!("{} commits", status.ahead)
};
let message = SharedString::from(format!(
"{} has {} ready to push in {}",
status.branch,
commits,
status.path.display()
));
let key = (status.path.clone(), status.branch.clone());
let view = cx.entity().clone();
let path = status.path.clone();
let pushing = self.pushing;
Some(
h_flex()
.gap_2()
.px_4()
.pt_1()
.gap_2()
.w_full()
.items_center()
.justify_between()
.child(div().text_sm().child(message))
.child(
Alert::info("repo-unpushed", message)
.banner()
.flex_1()
.on_close(move |_event, _window, cx| {
view.update(cx, |this, _| {
this.banner_dismissed.insert(key.clone());
});
}),
)
.child(
Button::new("push-checkout-banner")
.small()
.icon(CustomIconName::Init)
.label("Push")
.loading(pushing)
.disabled(pushing)
.on_click(cx.listener(move |this, _event, window, cx| {
this.push_unpushed_checkout(path.clone(), window, cx);
})),
h_flex()
.gap_1()
.child(
Button::new("push-checkout-banner")
.icon(IconName::ArrowUp)
.label("Push")
.small()
.primary()
.loading(self.pushing)
.disabled(self.pushing)
.on_click(cx.listener(move |this, _event, window, cx| {
this.push_unpushed_checkout(path.clone(), window, cx);
})),
)
.child(
Button::new("close-repo")
.icon(IconName::Close)
.small()
.ghost()
.disabled(self.pushing)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.banner_dismissed.insert(key.clone());
cx.notify();
})),
),
)
.into_any_element(),
)
@@ -2257,8 +2309,10 @@ impl Render for RepoDetailView {
.image_cache(gpui::retain_all("repo"))
.id("repo")
.size_full()
.when_some(banner, |this, banner| {
this.child(v_flex().gap_1().py_4().bg(cx.theme().muted).child(banner))
})
.child(self.render_header(cx))
.when_some(banner, |this, banner| this.child(banner))
.when_some(self.error.clone(), |this, error| {
this.child(
Alert::error("repo-error", error)
+160 -97
View File
@@ -1,6 +1,7 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use assets::CustomIconName;
@@ -9,7 +10,7 @@ use dock::{
};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list,
};
use gpui_base::Button as BaseButton;
@@ -17,8 +18,7 @@ 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_core::{Announcement, RepoAddr, identifier_from_name};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
};
@@ -39,90 +39,172 @@ 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.
repos: Option<Entity<RepoListStore>>,
/// Observes the current user's repo store so the list re-renders.
repos_subscription: Option<Subscription>,
/// Banner artwork behind the sign-in screen.
/// Artwork for the sign-in screen.
banner: SharedString,
_subscription: Subscription,
/// The signed-in user's announced repositories, newest first.
announcements: Arc<Vec<Announcement>>,
/// Local repositories found by the scan that are not announced yet.
local_repos: Arc<Vec<PathBuf>>,
/// A local scan is currently running.
scanning: bool,
/// Unpushed local commits per announced repository, the row badge counts.
unpushed: HashMap<RepoAddr, usize>,
_subscriptions: Vec<Subscription>,
}
impl SidebarPanel {
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let logged_in = backend.read(cx).current_user().is_some();
let repos = RepoListStore::global(cx);
let local = LocalReposStore::global(cx);
let checkouts = CheckoutsStore::global(cx);
let subscription = cx.subscribe(&backend, |this, backend, event, cx| {
match event {
BackendEvent::SignerChanged => {
this.logged_in = backend.read(cx).current_user().is_some();
this.refresh_repos(cx);
}
BackendEvent::SignerRequired => {
this.logged_in = false;
this.banner = pick_banner();
this.repos = None;
this.repos_subscription = None;
}
_ => return,
let mut subscriptions = Vec::new();
// Identity changes swap the whole sidebar between the sign-in screen and the signed-in content.
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
let signer_changed = matches!(event, BackendEvent::SignerChanged);
let signer_required = matches!(event, BackendEvent::SignerRequired);
if !signer_changed && !signer_required {
return;
}
cx.notify();
});
let mut panel = Self {
if signer_required {
this.banner = pick_banner();
}
if this.refresh(cx) || signer_required {
cx.notify();
}
}));
// The merged list re-derives when announcements or the local scan change.
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
if this.refresh(cx) {
cx.notify();
}
}));
// The local scan re-derives when announcements or the local scan change.
subscriptions.push(cx.observe(&local, |this, _local, cx| {
if this.refresh(cx) {
cx.notify();
}
}));
// Push statuses are recomputed in the background; only the badge counts change.
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
if this.refresh_unpushed(cx) {
cx.notify();
}
}));
let mut this = Self {
focus_handle: cx.focus_handle(),
dock_area,
logged_in,
explore: None,
repos: None,
repos_subscription: None,
banner: pick_banner(),
_subscription: subscription,
announcements: Arc::new(Vec::new()),
local_repos: Arc::new(Vec::new()),
scanning: false,
unpushed: HashMap::new(),
_subscriptions: subscriptions,
};
if logged_in {
panel.refresh_repos(cx);
cx.notify();
}
// Seed the snapshot right away.
// The stores may already hold data from before the panel opened.
// The first render must not depend on a later store update.
this.refresh(cx);
panel
this
}
/// Recreate the store listing the current user's repositories.
/// The sidebar renders only its own derived fields, never the stores
/// directly. Because the panel is a cached view, a store update alone does
/// not re-render it: the observers notify this panel, which re-runs
/// `render` over the fresh snapshot.
///
/// Watch each repository for unpushed local work.
fn refresh_repos(&mut self, cx: &mut Context<Self>) {
self.repos_subscription = None;
let checkouts = CheckoutsStore::global(cx);
/// Returns `true` when a rendered field changed.
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
let backend = Backend::global(cx);
let author = backend.read(cx).current_user();
let user = backend.read(cx).current_user();
// 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)));
let repo_list = RepoListStore::global(cx);
let announcements = user
.as_ref()
.map(|user| repo_list.read(cx).announcements_of(user))
.unwrap_or_default();
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();
// A scanned repository is dropped from the local list
// once the user announces it, so it is not listed twice.
let local = LocalReposStore::global(cx);
let scanning = local.read(cx).scanning;
checkouts.update(cx, |checkouts, cx| {
for addr in addrs {
checkouts.request_push_statuses(&addr, cx);
}
});
}));
let local_repos = {
let ids: HashSet<String> = announcements.iter().map(|a| a.id.clone()).collect();
local
.read(cx)
.repos
.iter()
.filter(|path| {
let Some(name) = path.file_name() else {
return true;
};
!ids.contains(&identifier_from_name(&name.to_string_lossy()))
})
.cloned()
.collect()
};
let announcements_changed = *self.announcements != announcements;
let local_changed = *self.local_repos != local_repos;
let scanning_changed = self.scanning != scanning;
self.announcements = Arc::new(announcements);
self.local_repos = Arc::new(local_repos);
self.scanning = scanning;
if announcements_changed {
self.request_push_watches(cx);
self.unpushed.clear();
}
announcements_changed || local_changed || scanning_changed
}
/// Recompute the badge counts from the global checkouts store's ready-to-push statuses
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
let checkouts = CheckoutsStore::global(cx).read(cx);
let mut unpushed = HashMap::with_capacity(self.announcements.len());
for announcement in self.announcements.iter() {
let addr = announcement.addr();
let count = checkouts.unpushed(&addr);
if count > 0 {
unpushed.insert(addr, count);
}
}
if unpushed == self.unpushed {
return false;
}
self.unpushed = unpushed;
true
}
/// Keep the `ready to push` statuses of the announced repositories current.
fn request_push_watches(&self, cx: &mut Context<Self>) {
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| {
for announcement in self.announcements.iter() {
checkouts.request_push_statuses(&announcement.addr(), cx);
}
});
}
/// Open the Explore repository list panel in the dock area's center.
/// No-op if it is already open.
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self
.explore
@@ -189,10 +271,9 @@ impl SidebarPanel {
}
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;
let announcements = self.announcements.clone();
let local_repos = self.local_repos.clone();
let scanning = self.scanning;
v_flex()
.px_2()
@@ -239,27 +320,12 @@ impl SidebarPanel {
),
),
)
.when_some(store, |builder, store| {
let announcements = store.read(cx).announcements.clone();
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;
};
!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.
.map(|this| {
// Merged list, the user's NIP-34 repositories and local repositories discovered.
let total = announcements.len() + local_repos.len();
if total == 0 {
builder.child(
this.child(
div()
.flex_1()
.px_2()
@@ -275,7 +341,7 @@ impl SidebarPanel {
}),
)
} else {
builder.child(
this.child(
uniform_list(
"repos",
total,
@@ -325,14 +391,11 @@ impl SidebarPanel {
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 checkout = CheckoutsStore::global(cx);
let unpushed = checkout
.read(cx)
.push_statuses_of(&announcement.addr())
.iter()
.map(|status| status.ahead as usize)
.sum();
let unpushed = self
.unpushed
.get(&announcement.addr())
.copied()
.unwrap_or(0);
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
@@ -352,7 +415,7 @@ impl SidebarPanel {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
.unwrap_or("Untitled".into());
let path = path.to_path_buf();
let avatar = PixelAvatar::new(path.to_string_lossy());
@@ -508,10 +571,6 @@ impl Focusable for SidebarPanel {
impl Render for SidebarPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.logged_in {
return self.render_sign_in(window, cx);
}
let backend = Backend::global(cx);
let profile_store = ProfileStore::global(cx);
@@ -520,6 +579,10 @@ impl Render for SidebarPanel {
.current_user()
.map(|public_key| profile_store.read(cx).get(&public_key));
if profile.is_none() {
return self.render_sign_in(window, cx);
}
v_flex()
.size_full()
.justify_between()