add push
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user