Add user-visible output for remote operations (#25849)

This PR adds toasts for reporting success and errors from remote git
operations. This PR also adds a focus handle to notifications, in
anticipation of making them keyboard accessible.

Release Notes:

- N/A

---------

Co-authored-by: julia <julia@zed.dev>
This commit is contained in:
Mikayla Maki
2025-03-03 09:20:15 +00:00
committed by GitHub
co-authored by julia
parent 508b9d3b5d
commit 73ac19958a
38 changed files with 713 additions and 192 deletions
+122 -42
View File
@@ -1,5 +1,6 @@
use crate::branch_picker::{self, BranchList};
use crate::git_panel_settings::StatusStyle;
use crate::remote_output_toast::{RemoteAction, RemoteOutputToast};
use crate::repository_selector::RepositorySelectorPopoverMenu;
use crate::{
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
@@ -12,8 +13,8 @@ use editor::{
ShowScrollbar,
};
use git::repository::{
Branch, CommitDetails, CommitSummary, PushOptions, Remote, ResetMode, Upstream,
UpstreamTracking, UpstreamTrackingStatus,
Branch, CommitDetails, CommitSummary, PushOptions, Remote, RemoteCommandOutput, ResetMode,
Upstream, UpstreamTracking, UpstreamTrackingStatus,
};
use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
@@ -43,6 +44,7 @@ use ui::{
PopoverButton, PopoverMenu, Scrollbar, ScrollbarState, Tooltip,
};
use util::{maybe, post_inc, ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
notifications::{DetachAndPromptErr, NotificationId},
@@ -283,6 +285,7 @@ impl GitPanel {
let commit_editor = cx.new(|cx| {
commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
});
commit_editor.update(cx, |editor, cx| {
editor.clear(window, cx);
});
@@ -1330,62 +1333,114 @@ impl GitPanel {
};
let guard = self.start_remote_operation();
let fetch = repo.read(cx).fetch();
cx.spawn(|_, _| async move {
fetch.await??;
cx.spawn(|this, mut cx| async move {
let remote_message = fetch.await?;
drop(guard);
this.update(&mut cx, |this, cx| {
match remote_message {
Ok(remote_message) => {
this.show_remote_output(RemoteAction::Fetch, remote_message, cx);
}
Err(e) => {
this.show_err_toast(e, cx);
}
}
anyhow::Ok(())
})
.ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo) = self.active_repository.clone() else {
return;
};
let Some(branch) = repo.read(cx).current_branch() else {
return;
};
let branch = branch.clone();
let guard = self.start_remote_operation();
let remote = self.get_current_remote(window, cx);
cx.spawn(move |this, mut cx| async move {
let remote = remote.await?;
let remote = match remote.await {
Ok(Some(remote)) => remote,
Ok(None) => {
return Ok(());
}
Err(e) => {
log::error!("Failed to get current remote: {}", e);
this.update(&mut cx, |this, cx| this.show_err_toast(e, cx))
.ok();
return Ok(());
}
};
this.update(&mut cx, |this, cx| {
let Some(repo) = this.active_repository.clone() else {
return Err(anyhow::anyhow!("No active repository"));
};
let Some(branch) = repo.read(cx).current_branch() else {
return Err(anyhow::anyhow!("No active branch"));
};
Ok(repo.read(cx).pull(branch.name.clone(), remote.name))
})??
.await??;
let pull = repo.update(&mut cx, |repo, _cx| {
repo.pull(branch.name.clone(), remote.name.clone())
})?;
let remote_message = pull.await?;
drop(guard);
this.update(&mut cx, |this, cx| match remote_message {
Ok(remote_message) => {
this.show_remote_output(RemoteAction::Pull, remote_message, cx)
}
Err(err) => this.show_err_toast(err, cx),
})
.ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo) = self.active_repository.clone() else {
return;
};
let Some(branch) = repo.read(cx).current_branch() else {
return;
};
let branch = branch.clone();
let guard = self.start_remote_operation();
let options = action.options;
let remote = self.get_current_remote(window, cx);
cx.spawn(move |this, mut cx| async move {
let remote = remote.await?;
let remote = match remote.await {
Ok(Some(remote)) => remote,
Ok(None) => {
return Ok(());
}
Err(e) => {
log::error!("Failed to get current remote: {}", e);
this.update(&mut cx, |this, cx| this.show_err_toast(e, cx))
.ok();
return Ok(());
}
};
this.update(&mut cx, |this, cx| {
let Some(repo) = this.active_repository.clone() else {
return Err(anyhow::anyhow!("No active repository"));
};
let push = repo.update(&mut cx, |repo, _cx| {
repo.push(branch.name.clone(), remote.name.clone(), options)
})?;
let Some(branch) = repo.read(cx).current_branch() else {
return Err(anyhow::anyhow!("No active branch"));
};
Ok(repo
.read(cx)
.push(branch.name.clone(), remote.name, options))
})??
.await??;
let remote_output = push.await?;
drop(guard);
this.update(&mut cx, |this, cx| match remote_output {
Ok(remote_message) => {
this.show_remote_output(RemoteAction::Push(remote), remote_message, cx);
}
Err(e) => {
this.show_err_toast(e, cx);
}
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
@@ -1395,7 +1450,7 @@ impl GitPanel {
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl Future<Output = Result<Remote>> {
) -> impl Future<Output = Result<Option<Remote>>> {
let repo = self.active_repository.clone();
let workspace = self.workspace.clone();
let mut cx = window.to_async(cx);
@@ -1418,7 +1473,7 @@ impl GitPanel {
if current_remotes.len() == 0 {
return Err(anyhow::anyhow!("No active remote"));
} else if current_remotes.len() == 1 {
return Ok(current_remotes.pop().unwrap());
return Ok(Some(current_remotes.pop().unwrap()));
} else {
let current_remotes: Vec<_> = current_remotes
.into_iter()
@@ -1436,9 +1491,9 @@ impl GitPanel {
})?
.await?;
return Ok(Remote {
Ok(selection.map(|selection| Remote {
name: current_remotes[selection].clone(),
});
}))
}
}
}
@@ -1789,16 +1844,40 @@ impl GitPanel {
};
let notif_id = NotificationId::Named("git-operation-error".into());
let message = e.to_string();
workspace.update(cx, |workspace, cx| {
let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
let mut message = e.to_string().trim().to_string();
let toast;
if message.matches("Authentication failed").count() >= 1 {
message = format!(
"{}\n\n{}",
message, "Please set your credentials via the CLI"
);
toast = Toast::new(notif_id, message);
} else {
toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
});
}
workspace.update(cx, |workspace, cx| {
workspace.show_toast(toast, cx);
});
}
fn render_spinner(&self) -> Option<impl IntoElement> {
fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let notification_id = NotificationId::Named("git-remote-info".into());
workspace.update(cx, |workspace, cx| {
workspace.show_notification(notification_id.clone(), cx, |cx| {
let workspace = cx.weak_entity();
cx.new(|cx| RemoteOutputToast::new(action, info, notification_id, workspace, cx))
});
});
}
pub fn render_spinner(&self) -> Option<impl IntoElement> {
(!self.pending_remote_operations.borrow().is_empty()).then(|| {
Icon::new(IconName::ArrowCircle)
.size(IconSize::XSmall)
@@ -2274,9 +2353,10 @@ impl GitPanel {
let Some(repo) = self.active_repository.clone() else {
return Task::ready(Err(anyhow::anyhow!("no active repo")));
};
let show = repo.read(cx).show(sha);
cx.spawn(|_, _| async move { show.await? })
repo.update(cx, |repo, cx| {
let show = repo.show(sha);
cx.spawn(|_, _| async move { show.await? })
})
}
fn deploy_entry_context_menu(
+1
View File
@@ -11,6 +11,7 @@ pub mod git_panel;
mod git_panel_settings;
pub mod picker_prompt;
pub mod project_diff;
mod remote_output_toast;
pub mod repository_selector;
pub fn init(cx: &mut App) {
+5 -2
View File
@@ -26,7 +26,7 @@ pub fn prompt(
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<usize>> {
) -> Task<Result<Option<usize>>> {
if options.is_empty() {
return Task::ready(Err(anyhow!("No options")));
}
@@ -43,7 +43,10 @@ pub fn prompt(
})
})?;
rx.await?
match rx.await {
Ok(selection) => Some(selection).transpose(),
Err(_) => anyhow::Ok(None), // User cancelled
}
})
}
+214
View File
@@ -0,0 +1,214 @@
use std::{ops::Range, time::Duration};
use git::repository::{Remote, RemoteCommandOutput};
use gpui::{
DismissEvent, EventEmitter, FocusHandle, Focusable, HighlightStyle, InteractiveText,
StyledText, Task, UnderlineStyle, WeakEntity,
};
use itertools::Itertools;
use linkify::{LinkFinder, LinkKind};
use ui::{
div, h_flex, px, v_flex, vh, Clickable, Color, Context, FluentBuilder, Icon, IconButton,
IconName, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement,
Render, SharedString, Styled, StyledExt, Window,
};
use workspace::{
notifications::{Notification, NotificationId},
Workspace,
};
pub enum RemoteAction {
Fetch,
Pull,
Push(Remote),
}
struct InfoFromRemote {
name: SharedString,
remote_text: SharedString,
links: Vec<Range<usize>>,
}
pub struct RemoteOutputToast {
_workspace: WeakEntity<Workspace>,
_id: NotificationId,
message: SharedString,
remote_info: Option<InfoFromRemote>,
_dismiss_task: Task<()>,
focus_handle: FocusHandle,
}
impl Focusable for RemoteOutputToast {
fn focus_handle(&self, _cx: &ui::App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Notification for RemoteOutputToast {}
const REMOTE_OUTPUT_TOAST_SECONDS: u64 = 5;
impl RemoteOutputToast {
pub fn new(
action: RemoteAction,
output: RemoteCommandOutput,
id: NotificationId,
workspace: WeakEntity<Workspace>,
cx: &mut Context<Self>,
) -> Self {
let task = cx.spawn({
let workspace = workspace.clone();
let id = id.clone();
|_, mut cx| async move {
cx.background_executor()
.timer(Duration::from_secs(REMOTE_OUTPUT_TOAST_SECONDS))
.await;
workspace
.update(&mut cx, |workspace, cx| {
workspace.dismiss_notification(&id, cx);
})
.ok();
}
});
let message;
let remote;
match action {
RemoteAction::Fetch | RemoteAction::Pull => {
if output.is_empty() {
message = "Up to date".into();
} else {
message = output.stderr.into();
}
remote = None;
}
RemoteAction::Push(remote_ref) => {
message = output.stdout.trim().to_string().into();
let remote_message = get_remote_lines(&output.stderr);
let finder = LinkFinder::new();
let links = finder
.links(&remote_message)
.filter(|link| *link.kind() == LinkKind::Url)
.map(|link| link.start()..link.end())
.collect_vec();
remote = Some(InfoFromRemote {
name: remote_ref.name,
remote_text: remote_message.into(),
links,
});
}
}
Self {
_workspace: workspace,
_id: id,
message,
remote_info: remote,
_dismiss_task: task,
focus_handle: cx.focus_handle(),
}
}
}
impl Render for RemoteOutputToast {
fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
div()
.occlude()
.w_full()
.max_h(vh(0.8, window))
.elevation_3(cx)
.child(
v_flex()
.p_3()
.overflow_hidden()
.child(
h_flex()
.justify_between()
.items_start()
.child(
h_flex()
.gap_2()
.child(Icon::new(IconName::GitBranch).color(Color::Default))
.child(Label::new("Git")),
)
.child(h_flex().child(
IconButton::new("close", IconName::Close).on_click(
cx.listener(|_, _, _, cx| cx.emit(gpui::DismissEvent)),
),
)),
)
.child(Label::new(self.message.clone()).size(LabelSize::Default))
.when_some(self.remote_info.as_ref(), |this, remote_info| {
this.child(
div()
.border_1()
.border_color(Color::Muted.color(cx))
.rounded_lg()
.text_sm()
.mt_1()
.p_1()
.child(
h_flex()
.gap_2()
.child(Icon::new(IconName::Cloud).color(Color::Default))
.child(
Label::new(remote_info.name.clone())
.size(LabelSize::Default),
),
)
.map(|div| {
let styled_text =
StyledText::new(remote_info.remote_text.clone())
.with_highlights(remote_info.links.iter().map(
|link| {
(
link.clone(),
HighlightStyle {
underline: Some(UnderlineStyle {
thickness: px(1.0),
..Default::default()
}),
..Default::default()
},
)
},
));
let this = cx.weak_entity();
let text = InteractiveText::new("remote-message", styled_text)
.on_click(
remote_info.links.clone(),
move |ix, _window, cx| {
this.update(cx, |this, cx| {
if let Some(remote_info) = &this.remote_info {
cx.open_url(
&remote_info.remote_text
[remote_info.links[ix].clone()],
)
}
})
.ok();
},
);
div.child(text)
}),
)
}),
)
}
}
impl EventEmitter<DismissEvent> for RemoteOutputToast {}
fn get_remote_lines(output: &str) -> String {
output
.lines()
.filter_map(|line| line.strip_prefix("remote:"))
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}