This commit is contained in:
2026-09-03 15:22:37 +07:00
parent c0f06d095e
commit 018395d0c5
6 changed files with 767 additions and 42 deletions
+195 -14
View File
@@ -462,6 +462,18 @@ impl RepoDetailView {
// cached state, which is already shown.
signed_git::fetch_all(&repo).ok();
let worktree = repo.workdir().map(Path::to_path_buf);
// A fetch never moves a mirror's local branches, so a
// push landing on the grasp servers (own repo pushed
// from a checkout, or an update fetched here) would
// never show up. Fast-forward them from the remote,
// like `git pull --ff-only` on every branch; only the
// checked-out branch's worktree can change on disk.
let moved = match &worktree {
Some(worktree) => {
signed_git::fast_forward_branches(worktree).unwrap_or(false)
}
None => false,
};
let (branches, tags) = match &worktree {
Some(_) => (
signed_git::repo_branches(&repo).unwrap_or_default(),
@@ -471,7 +483,7 @@ impl RepoDetailView {
};
let current_branch = signed_git::current_branch(&repo).unwrap_or(None);
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
Ok::<_, Error>(Some((branches, tags, current_branch, head_commit)))
Ok::<_, Error>(Some((moved, branches, tags, current_branch, head_commit)))
})
}
.await;
@@ -480,7 +492,17 @@ impl RepoDetailView {
if refresh_generation != this.ref_generation {
return;
}
if let Ok(Some((branches, tags, current_branch, head_commit))) = refresh {
if let Ok(Some((moved, branches, tags, current_branch, head_commit))) = refresh {
if moved {
// The mirror caught up with the remote (e.g. the
// push of an owned checkout just landed): rebuild
// the explorer, previews and commit list from the
// updated worktree.
this.reload_worktree(cx);
cx.notify();
return;
}
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
@@ -938,6 +960,70 @@ impl RepoDetailView {
}));
}
/// Push the unpushed commits of the local checkout at
/// `path` (an owned repository's working copy) to the announced grasp servers,
/// failures appear in the panel's error banner,
/// and on success the push statuses are recomputed so the banner clears.
fn push_unpushed_checkout(
&mut self,
path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.pushing {
return;
}
let Some(announcement) = self.announcement(cx).cloned() else {
return;
};
// Keep the repository's announced default branch as the state
// event's `HEAD` when the checkout is on a side branch.
let head = self
.store
.as_ref()
.and_then(|store| store.read(cx).head.clone());
let addr = announcement.addr();
self.pushing = true;
self.error = None;
cx.notify();
let backend = Backend::global(cx);
let checkout = CheckoutsStore::global(cx);
let task = backend.update(cx, |backend, cx| {
backend.push_checkout(announcement, path.clone(), head, cx)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(()) => {
// The remote moved; recompute the push statuses so
// the banner disappears, and refresh the mirror so
// the pushed commits appear in the panel right away
// (fetch + fast-forward + explorer reload).
checkout.update(cx, |store, cx| {
store.request_push_statuses(&addr, cx);
});
this.load_repo(window, cx);
}
Err(error) => {
this.error = Some(format!("Push failed: {error}").into());
}
}
this.pushing = false;
cx.notify();
})?;
Ok(())
}));
}
/// Delete the repository from nostr (announcement, state and activity);
/// only offered to the repository owner. The sidebar list updates when
/// the deletion events arrive.
@@ -1840,33 +1926,57 @@ impl RepoDetailView {
self.refresh_ready_statuses(cx);
}
/// (Re)request the ready statuses of this repository when the announced
/// HEAD the base the checkouts are compared against changed since
/// the last request.
/// (Re)request the statuses of this repository when the announced
/// HEAD - the base the checkouts are compared against, changed since the last request.
/// Repositories the user owns are watched for unpushed commits,
/// other repositories for ready-to-contribute checkouts.
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else {
let Some(entity) = self.store.clone() else {
return;
};
let head = store.read(cx).head.clone();
let head = entity.read(cx).head.clone();
if self.ready_requested && self.ready_head == head {
return;
}
self.ready_requested = true;
self.ready_head = head.clone();
let addr = store.read(cx).addr().clone();
CheckoutsStore::global(cx).update(cx, |store, cx| {
let addr = entity.read(cx).addr().clone();
let backend = Backend::global(cx);
let checkout = CheckoutsStore::global(cx);
let owned = backend
.read(cx)
.current_user()
.is_some_and(|user| entity.read(cx).is_author(&user));
checkout.update(cx, |store, cx| {
// The ready statuses also keep the fast poll running while the
// panel is open (the sidebar's push watch alone polls slower).
store.request_statuses(&addr, head, cx);
if owned {
store.request_push_statuses(&addr, cx);
}
});
}
/// The first checkout ready for a pull request on this repository,
/// not covered by an open PR of the signed-in user and not dismissed in this panel.
/// not covered by an open PR of the signed-in user and not dismissed in
/// this panel. The repository's own checkouts are not suggested here:
/// their work is pushed (see [`Self::push_suggestion`]).
fn ready_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
let store = self.store.as_ref()?;
let addr = store.read(cx).addr().clone();
let user = Backend::global(cx).read(cx).current_user()?;
if store.read(cx).is_author(&user) {
return None;
}
let statuses = CheckoutsStore::global(cx).read(cx).statuses_of(&addr);
let user = Backend::global(cx).read(cx).current_user()?;
'status: for status in statuses {
if self
@@ -1888,6 +1998,75 @@ impl RepoDetailView {
None
}
/// 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
.contains(&(status.path.clone(), status.branch.clone()))
})
}
/// 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 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()
.w_full()
.items_center()
.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);
})),
)
.into_any_element(),
)
}
/// The "ready to contribute" banner of the repository panel: message,
/// a Create action opening the prefilled New PR panel, and a dismiss
/// control.
@@ -2165,14 +2344,16 @@ impl Render for RepoDetailView {
.or_else(|| self.readme_name.clone())
.unwrap_or_else(|| "Overview".into());
let banner = self
.render_ready_banner(cx)
.or_else(|| self.render_push_banner(cx));
v_flex()
.image_cache(image_cache("repo", MAX_IMAGES))
.id("repo")
.size_full()
.child(self.render_header(cx))
.when_some(self.render_ready_banner(cx), |this, banner| {
this.child(banner)
})
.when_some(banner, |this, banner| this.child(banner))
.when_some(self.error.clone(), |this, error| {
this.child(
Alert::error("repo-error", error)
+50 -6
View File
@@ -15,9 +15,11 @@ use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use signed_core::{Announcement, identifier_from_name};
use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
};
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use signed_ui::{CountBadge, NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use super::{RepoDetailView, RepoListView, open_repo_panel};
@@ -47,6 +49,9 @@ pub struct SidebarPanel {
banner: SharedString,
/// Observes the local-repository scan so new discoveries re-render.
_local_repos_subscription: Subscription,
/// Observes the checkouts store, whose ready-to-push statuses feed the
/// badges on the user's repository rows.
_checkouts_subscription: Subscription,
_subscription: Subscription,
}
@@ -77,6 +82,11 @@ impl SidebarPanel {
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,
@@ -86,6 +96,7 @@ impl SidebarPanel {
my_repos_subscription: None,
banner: pick_banner(),
_local_repos_subscription: local_repos_subscription,
_checkouts_subscription: checkouts_subscription,
_subscription: subscription,
};
@@ -96,7 +107,8 @@ impl SidebarPanel {
panel
}
/// (Re)create the store listing the current user's repositories.
/// (Re)create the store listing the current user's repositories,
/// and watch each of them for unpushed local work.
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None;
@@ -105,7 +117,24 @@ impl SidebarPanel {
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, |_, _, cx| cx.notify()));
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 repo) so
// the rows carry a badge while local work is unpushed.
let addrs: Vec<_> = 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);
}
});
}));
}
}
@@ -328,9 +357,24 @@ impl SidebarPanel {
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
let announcement = announcement.clone();
NavItem::new(format!("my-repo:{}", announcement.id), name, avatar).on_click(
// A small badge with the unpushed commit count of the repository's
// local checkouts (ready to push to the grasp servers).
let unpushed: usize = CheckoutsStore::global(cx)
.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);
if unpushed > 0 {
row = row.suffix(CountBadge::new(unpushed));
}
row.on_click(
cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)),
)
}