Rework git toasts (#26420)

The notifications from git output could take up variable amounts of
screen space, and they were quite obnoxious when a git command printed
lots of output, such as fetching many new branches or verbose push
hooks.

This change makes the push/pull/fetch buttons trigger a small
notification toast, based on the output of the command that was ran. For
errors or commands with more output the user may want to see, there's an
"Open Log" button which opens a new buffer with the output of that
command.

It also uses this behavior for long error notifications for other git
commands like `commit` and `checkout`. The output of those commands can
be quite long due to arbitrary githooks running.

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
This commit is contained in:
Julia Ryan 2025-03-11 14:39:29 -07:00 committed by GitHub
parent e8208643bb
commit 2b94a35aaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 415 additions and 418 deletions

1
Cargo.lock generated
View file

@ -5460,6 +5460,7 @@ dependencies = [
"log", "log",
"menu", "menu",
"multi_buffer", "multi_buffer",
"notifications",
"panel", "panel",
"picker", "picker",
"postage", "postage",

View file

@ -393,6 +393,7 @@
"alt-shift-open": "projects::OpenRemote", "alt-shift-open": "projects::OpenRemote",
"alt-ctrl-shift-o": "projects::OpenRemote", "alt-ctrl-shift-o": "projects::OpenRemote",
"alt-ctrl-shift-b": "branches::OpenRecent", "alt-ctrl-shift-b": "branches::OpenRecent",
"alt-shift-enter": "toast::RunAction",
"ctrl-~": "workspace::NewTerminal", "ctrl-~": "workspace::NewTerminal",
"save": "workspace::Save", "save": "workspace::Save",
"ctrl-s": "workspace::Save", "ctrl-s": "workspace::Save",

View file

@ -514,6 +514,7 @@
"ctrl-~": "workspace::NewTerminal", "ctrl-~": "workspace::NewTerminal",
"cmd-s": "workspace::Save", "cmd-s": "workspace::Save",
"cmd-k s": "workspace::SaveWithoutFormat", "cmd-k s": "workspace::SaveWithoutFormat",
"alt-shift-enter": "toast::RunAction",
"cmd-shift-s": "workspace::SaveAs", "cmd-shift-s": "workspace::SaveAs",
"cmd-shift-n": "workspace::NewWindow", "cmd-shift-n": "workspace::NewWindow",
"ctrl-`": "terminal_panel::ToggleFocus", "ctrl-`": "terminal_panel::ToggleFocus",

View file

@ -417,22 +417,17 @@ impl ComponentPreview {
} }
} }
fn test_status_toast(&self, window: &mut Window, cx: &mut Context<Self>) { fn test_status_toast(&self, cx: &mut Context<Self>) {
if let Some(workspace) = self.workspace.upgrade() { if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| { workspace.update(cx, |workspace, cx| {
let status_toast = StatusToast::new( let status_toast =
"`zed/new-notification-system` created!", StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
window,
cx,
|this, _, cx| {
this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted)) this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
.action( .action("Open Pull Request", |_, cx| {
"Open Pull Request", cx.open_url("https://github.com/")
cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")), })
) });
}, workspace.toggle_status_toast(status_toast, cx)
);
workspace.toggle_status_toast(window, cx, status_toast)
}); });
} }
} }
@ -478,8 +473,8 @@ impl Render for ComponentPreview {
div().w_full().pb_4().child( div().w_full().pb_4().child(
Button::new("toast-test", "Launch Toast") Button::new("toast-test", "Launch Toast")
.on_click(cx.listener({ .on_click(cx.listener({
move |this, _, window, cx| { move |this, _, _window, cx| {
this.test_status_toast(window, cx); this.test_status_toast(cx);
cx.notify(); cx.notify();
} }
})) }))

View file

@ -92,7 +92,7 @@ impl UpstreamTracking {
} }
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct RemoteCommandOutput { pub struct RemoteCommandOutput {
pub stdout: String, pub stdout: String,
pub stderr: String, pub stderr: String,
@ -912,7 +912,7 @@ fn run_remote_command(
let output = output?; let output = output?;
if !output.status.success() { if !output.status.success() {
Err(anyhow!( Err(anyhow!(
"Operation failed:\n{}", "{}",
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
)) ))
} else { } else {

View file

@ -36,6 +36,7 @@ linkme.workspace = true
log.workspace = true log.workspace = true
menu.workspace = true menu.workspace = true
multi_buffer.workspace = true multi_buffer.workspace = true
notifications.workspace = true
panel.workspace = true panel.workspace = true
picker.workspace = true picker.workspace = true
postage.workspace = true postage.workspace = true

View file

@ -2,7 +2,7 @@ use crate::askpass_modal::AskPassModal;
use crate::commit_modal::CommitModal; use crate::commit_modal::CommitModal;
use crate::git_panel_settings::StatusStyle; use crate::git_panel_settings::StatusStyle;
use crate::project_diff::Diff; use crate::project_diff::Diff;
use crate::remote_output_toast::{RemoteAction, RemoteOutputToast}; use crate::remote_output::{self, RemoteAction, SuccessMessage};
use crate::repository_selector::filtered_repository_entries; use crate::repository_selector::filtered_repository_entries;
use crate::{branch_picker, render_remote_button}; use crate::{branch_picker, render_remote_button};
use crate::{ use crate::{
@ -64,10 +64,11 @@ use ui::{
use util::{maybe, post_inc, ResultExt, TryFutureExt}; use util::{maybe, post_inc, ResultExt, TryFutureExt};
use workspace::{AppState, OpenOptions, OpenVisible}; use workspace::{AppState, OpenOptions, OpenVisible};
use notifications::status_toast::{StatusToast, ToastIcon};
use workspace::{ use workspace::{
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
notifications::{DetachAndPromptErr, NotificationId}, notifications::DetachAndPromptErr,
Toast, Workspace, Workspace,
}; };
actions!( actions!(
@ -948,7 +949,7 @@ impl GitPanel {
} }
result result
.map_err(|e| { .map_err(|e| {
this.show_err_toast(e, cx); this.show_error_toast("checkout", e, cx);
}) })
.ok(); .ok();
}) })
@ -1200,7 +1201,7 @@ impl GitPanel {
} }
result result
.map_err(|e| { .map_err(|e| {
this.show_err_toast(e, cx); this.show_error_toast(if stage { "add" } else { "reset" }, e, cx);
}) })
.ok(); .ok();
cx.notify(); cx.notify();
@ -1357,7 +1358,7 @@ impl GitPanel {
this.commit_editor this.commit_editor
.update(cx, |editor, cx| editor.clear(window, cx)); .update(cx, |editor, cx| editor.clear(window, cx));
} }
Err(e) => this.show_err_toast(e, cx), Err(e) => this.show_error_toast("commit", e, cx),
} }
}) })
.ok(); .ok();
@ -1399,7 +1400,7 @@ impl GitPanel {
editor.set_text(prior_commit.message, window, cx) editor.set_text(prior_commit.message, window, cx)
}); });
} }
Err(e) => this.show_err_toast(e, cx), Err(e) => this.show_error_toast("reset", e, cx),
} }
}) })
.ok(); .ok();
@ -1611,28 +1612,29 @@ impl GitPanel {
telemetry::event!("Git Fetched"); telemetry::event!("Git Fetched");
let guard = self.start_remote_operation(); let guard = self.start_remote_operation();
let askpass = self.askpass_delegate("git fetch", window, cx); let askpass = self.askpass_delegate("git fetch", window, cx);
cx.spawn(|this, mut cx| async move { let this = cx.weak_entity();
let fetch = repo.update(&mut cx, |repo, cx| repo.fetch(askpass, cx))?; window
.spawn(cx, |mut cx| async move {
let fetch = repo.update(&mut cx, |repo, cx| repo.fetch(askpass, cx))?;
let remote_message = fetch.await?; let remote_message = fetch.await?;
drop(guard); drop(guard);
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| {
match remote_message { let action = RemoteAction::Fetch;
Ok(remote_message) => { match remote_message {
this.show_remote_output(RemoteAction::Fetch, remote_message, cx); Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
Err(e) => {
log::error!("Error while fetching {:?}", e);
this.show_error_toast(action.name(), e, cx)
}
} }
Err(e) => {
log::error!("Error while fetching {:?}", e);
this.show_err_toast(e, cx);
}
}
anyhow::Ok(())
})
.ok();
anyhow::Ok(()) anyhow::Ok(())
}) })
.ok(); .detach_and_log_err(cx);
anyhow::Ok(())
})
.detach_and_log_err(cx);
} }
pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@ -1656,7 +1658,7 @@ impl GitPanel {
} }
Err(e) => { Err(e) => {
log::error!("Failed to get current remote: {}", e); log::error!("Failed to get current remote: {}", e);
this.update(&mut cx, |this, cx| this.show_err_toast(e, cx)) this.update(&mut cx, |this, cx| this.show_error_toast("pull", e, cx))
.ok(); .ok();
return Ok(()); return Ok(());
} }
@ -1677,13 +1679,12 @@ impl GitPanel {
let remote_message = pull.await?; let remote_message = pull.await?;
drop(guard); drop(guard);
let action = RemoteAction::Pull(remote);
this.update(&mut cx, |this, cx| match remote_message { this.update(&mut cx, |this, cx| match remote_message {
Ok(remote_message) => { Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
this.show_remote_output(RemoteAction::Pull, remote_message, cx) Err(e) => {
} log::error!("Error while pulling {:?}", e);
Err(err) => { this.show_error_toast(action.name(), e, cx)
log::error!("Error while pull {:?}", err);
this.show_err_toast(err, cx)
} }
}) })
.ok(); .ok();
@ -1720,7 +1721,7 @@ impl GitPanel {
} }
Err(e) => { Err(e) => {
log::error!("Failed to get current remote: {}", e); log::error!("Failed to get current remote: {}", e);
this.update(&mut cx, |this, cx| this.show_err_toast(e, cx)) this.update(&mut cx, |this, cx| this.show_error_toast("push", e, cx))
.ok(); .ok();
return Ok(()); return Ok(());
} }
@ -1747,13 +1748,12 @@ impl GitPanel {
let remote_output = push.await?; let remote_output = push.await?;
drop(guard); drop(guard);
let action = RemoteAction::Push(branch.name, remote);
this.update(&mut cx, |this, cx| match remote_output { this.update(&mut cx, |this, cx| match remote_output {
Ok(remote_message) => { Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
this.show_remote_output(RemoteAction::Push(remote), remote_message, cx);
}
Err(e) => { Err(e) => {
log::error!("Error while pushing {:?}", e); log::error!("Error while pushing {:?}", e);
this.show_err_toast(e, cx); this.show_error_toast(action.name(), e, cx)
} }
})?; })?;
@ -2241,14 +2241,13 @@ impl GitPanel {
self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
} }
fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) { fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
let action = action.into();
let Some(workspace) = self.workspace.upgrade() else { let Some(workspace) = self.workspace.upgrade() else {
return; return;
}; };
let notif_id = NotificationId::Named("git-operation-error".into());
let message = e.to_string().trim().to_string(); let message = e.to_string().trim().to_string();
let toast;
if message if message
.matches(git::repository::REMOTE_CANCELLED_BY_USER) .matches(git::repository::REMOTE_CANCELLED_BY_USER)
.next() .next()
@ -2256,13 +2255,28 @@ impl GitPanel {
{ {
return; // Hide the cancelled by user message return; // Hide the cancelled by user message
} else { } else {
toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| { let project = self.project.clone();
window.dispatch_action(workspace::OpenLog.boxed_clone(), cx); workspace.update(cx, |workspace, cx| {
let workspace_weak = cx.weak_entity();
let toast =
StatusToast::new(format!("git {} failed", action.clone()), cx, |this, _cx| {
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
.action("View Log", move |window, cx| {
let message = message.clone();
let project = project.clone();
let action = action.clone();
workspace_weak
.update(cx, move |workspace, cx| {
Self::open_output(
project, action, workspace, &message, window, cx,
)
})
.ok();
})
});
workspace.toggle_status_toast(toast, cx)
}); });
} }
workspace.update(cx, |workspace, cx| {
workspace.show_toast(toast, cx);
});
} }
fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) { fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
@ -2270,16 +2284,62 @@ impl GitPanel {
return; return;
}; };
let notification_id = NotificationId::Named("git-remote-info".into());
workspace.update(cx, |workspace, cx| { workspace.update(cx, |workspace, cx| {
workspace.show_notification(notification_id.clone(), cx, |cx| { let SuccessMessage { message, style } = remote_output::format_output(&action, info);
let workspace = cx.weak_entity(); let workspace_weak = cx.weak_entity();
cx.new(|cx| RemoteOutputToast::new(action, info, notification_id, workspace, cx)) let operation = action.name();
let status_toast = StatusToast::new(message, cx, move |this, _cx| {
use remote_output::SuccessStyle::*;
let project = self.project.clone();
match style {
Toast { .. } => this,
ToastWithLog { output } => this
.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
.action("View Log", move |window, cx| {
let output = output.clone();
let project = project.clone();
let output =
format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
workspace_weak
.update(cx, move |workspace, cx| {
Self::open_output(
project, operation, workspace, &output, window, cx,
)
})
.ok();
}),
PushPrLink { link } => this
.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
.action("Open Pull Request", move |_, cx| cx.open_url(&link)),
}
}); });
workspace.toggle_status_toast(status_toast, cx)
}); });
} }
fn open_output(
project: Entity<Project>,
operation: impl Into<SharedString>,
workspace: &mut Workspace,
output: &str,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let operation = operation.into();
let buffer = cx.new(|cx| Buffer::local(output, cx));
let editor = cx.new(|cx| {
let mut editor = Editor::for_buffer(buffer, Some(project), window, cx);
editor.buffer().update(cx, |buffer, cx| {
buffer.set_title(format!("Output from git {operation}"), cx);
});
editor.set_read_only(true);
editor
});
workspace.add_item_to_center(Box::new(editor), window, cx);
}
pub fn render_spinner(&self) -> Option<impl IntoElement> { pub fn render_spinner(&self) -> Option<impl IntoElement> {
(!self.pending_remote_operations.borrow().is_empty()).then(|| { (!self.pending_remote_operations.borrow().is_empty()).then(|| {
Icon::new(IconName::ArrowCircle) Icon::new(IconName::ArrowCircle)

View file

@ -17,7 +17,7 @@ pub mod git_panel;
mod git_panel_settings; mod git_panel_settings;
pub mod picker_prompt; pub mod picker_prompt;
pub mod project_diff; pub mod project_diff;
mod remote_output_toast; pub(crate) mod remote_output;
pub mod repository_selector; pub mod repository_selector;
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {

View file

@ -0,0 +1,152 @@
use anyhow::Context as _;
use git::repository::{Remote, RemoteCommandOutput};
use linkify::{LinkFinder, LinkKind};
use ui::SharedString;
use util::ResultExt as _;
#[derive(Clone)]
pub enum RemoteAction {
Fetch,
Pull(Remote),
Push(SharedString, Remote),
}
impl RemoteAction {
pub fn name(&self) -> &'static str {
match self {
RemoteAction::Fetch => "fetch",
RemoteAction::Pull(_) => "pull",
RemoteAction::Push(_, _) => "push",
}
}
}
pub enum SuccessStyle {
Toast,
ToastWithLog { output: RemoteCommandOutput },
PushPrLink { link: String },
}
pub struct SuccessMessage {
pub message: String,
pub style: SuccessStyle,
}
pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> SuccessMessage {
match action {
RemoteAction::Fetch => {
if output.stderr.is_empty() {
SuccessMessage {
message: "Already up to date".into(),
style: SuccessStyle::Toast,
}
} else {
SuccessMessage {
message: "Synchronized with remotes".into(),
style: SuccessStyle::ToastWithLog { output },
}
}
}
RemoteAction::Pull(remote_ref) => {
let get_changes = |output: &RemoteCommandOutput| -> anyhow::Result<u32> {
let last_line = output
.stdout
.lines()
.last()
.context("Failed to get last line of output")?
.trim();
let files_changed = last_line
.split_whitespace()
.next()
.context("Failed to get first word of last line")?
.parse()?;
Ok(files_changed)
};
if output.stderr.starts_with("Everything up to date") {
SuccessMessage {
message: output.stderr.trim().to_owned(),
style: SuccessStyle::Toast,
}
} else if output.stdout.starts_with("Updating") {
let files_changed = get_changes(&output).log_err();
let message = if let Some(files_changed) = files_changed {
format!(
"Received {} file change{} from {}",
files_changed,
if files_changed == 1 { "" } else { "s" },
remote_ref.name
)
} else {
format!("Fast forwarded from {}", remote_ref.name)
};
SuccessMessage {
message,
style: SuccessStyle::ToastWithLog { output },
}
} else if output.stdout.starts_with("Merge") {
let files_changed = get_changes(&output).log_err();
let message = if let Some(files_changed) = files_changed {
format!(
"Merged {} file change{} from {}",
files_changed,
if files_changed == 1 { "" } else { "s" },
remote_ref.name
)
} else {
format!("Merged from {}", remote_ref.name)
};
SuccessMessage {
message,
style: SuccessStyle::ToastWithLog { output },
}
} else if output.stdout.contains("Successfully rebased") {
SuccessMessage {
message: format!("Successfully rebased from {}", remote_ref.name),
style: SuccessStyle::ToastWithLog { output },
}
} else {
SuccessMessage {
message: format!("Successfully pulled from {}", remote_ref.name),
style: SuccessStyle::ToastWithLog { output },
}
}
}
RemoteAction::Push(branch_name, remote_ref) => {
if output.stderr.contains("* [new branch]") {
let style = if output.stderr.contains("Create a pull request") {
let finder = LinkFinder::new();
let first_link = finder
.links(&output.stderr)
.filter(|link| *link.kind() == LinkKind::Url)
.map(|link| link.start()..link.end())
.next();
if let Some(link) = first_link {
let link = output.stderr[link].to_string();
SuccessStyle::PushPrLink { link }
} else {
SuccessStyle::ToastWithLog { output }
}
} else {
SuccessStyle::ToastWithLog { output }
};
SuccessMessage {
message: format!("Published {} to {}", branch_name, remote_ref.name),
style,
}
} else if output.stderr.starts_with("Everything up to date") {
SuccessMessage {
message: output.stderr.trim().to_owned(),
style: SuccessStyle::Toast,
}
} else {
SuccessMessage {
message: "Successfully pushed new branch".to_owned(),
style: SuccessStyle::ToastWithLog { output },
}
}
}
}
}

View file

@ -1,227 +0,0 @@
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 mut message: SharedString;
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();
if message.is_empty() {
message = output.stderr.trim().to_string().into();
if message.is_empty() {
message = "Push Successful".into();
}
remote = None;
} else {
let remote_message = get_remote_lines(&output.stderr);
remote = if remote_message.is_empty() {
None
} else {
let finder = LinkFinder::new();
let links = finder
.links(&remote_message)
.filter(|link| *link.kind() == LinkKind::Url)
.map(|link| link.start()..link.end())
.collect_vec();
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")
}

View file

@ -401,10 +401,7 @@ impl DispatchTree {
.bindings_for_action(action) .bindings_for_action(action)
.filter(|binding| { .filter(|binding| {
let (bindings, _) = keymap.bindings_for_input(&binding.keystrokes, context_stack); let (bindings, _) = keymap.bindings_for_input(&binding.keystrokes, context_stack);
bindings bindings.iter().any(|b| b.action.partial_eq(action))
.iter()
.next()
.is_some_and(|b| b.action.partial_eq(action))
}) })
.cloned() .cloned()
.collect() .collect()

View file

@ -1,15 +1,8 @@
use std::sync::Arc; use std::rc::Rc;
use gpui::{ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement}; use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement};
use ui::prelude::*; use ui::{prelude::*, Tooltip};
use workspace::ToastView; use workspace::{ToastAction, ToastView};
#[derive(Clone)]
pub struct ToastAction {
id: ElementId,
label: SharedString,
on_click: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct ToastIcon { pub struct ToastIcon {
@ -40,49 +33,33 @@ impl From<IconName> for ToastIcon {
} }
} }
impl ToastAction {
pub fn new(
label: SharedString,
on_click: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
) -> Self {
let id = ElementId::Name(label.clone());
Self {
id,
label,
on_click,
}
}
}
#[derive(IntoComponent)] #[derive(IntoComponent)]
#[component(scope = "Notification")] #[component(scope = "Notification")]
pub struct StatusToast { pub struct StatusToast {
icon: Option<ToastIcon>, icon: Option<ToastIcon>,
text: SharedString, text: SharedString,
action: Option<ToastAction>, action: Option<ToastAction>,
this_handle: Entity<Self>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
} }
impl StatusToast { impl StatusToast {
pub fn new( pub fn new(
text: impl Into<SharedString>, text: impl Into<SharedString>,
window: &mut Window,
cx: &mut App, cx: &mut App,
f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self, f: impl FnOnce(Self, &mut Context<Self>) -> Self,
) -> Entity<Self> { ) -> Entity<Self> {
cx.new(|cx| { cx.new(|cx| {
let focus_handle = cx.focus_handle(); let focus_handle = cx.focus_handle();
window.refresh();
f( f(
Self { Self {
text: text.into(), text: text.into(),
icon: None, icon: None,
action: None, action: None,
this_handle: cx.entity(),
focus_handle, focus_handle,
}, },
window,
cx, cx,
) )
}) })
@ -96,9 +73,18 @@ impl StatusToast {
pub fn action( pub fn action(
mut self, mut self,
label: impl Into<SharedString>, label: impl Into<SharedString>,
f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, f: impl Fn(&mut Window, &mut App) + 'static,
) -> Self { ) -> Self {
self.action = Some(ToastAction::new(label.into(), Some(Arc::new(f)))); let this_handle = self.this_handle.clone();
self.action = Some(ToastAction::new(
label.into(),
Some(Rc::new(move |window, cx| {
this_handle.update(cx, |_, cx| {
cx.emit(DismissEvent);
});
f(window, cx);
})),
));
self self
} }
} }
@ -122,18 +108,24 @@ impl Render for StatusToast {
.when_some(self.action.as_ref(), |this, action| { .when_some(self.action.as_ref(), |this, action| {
this.child( this.child(
Button::new(action.id.clone(), action.label.clone()) Button::new(action.id.clone(), action.label.clone())
.tooltip(Tooltip::for_action_title(
action.label.clone(),
&workspace::RunAction,
))
.color(Color::Muted) .color(Color::Muted)
.when_some(action.on_click.clone(), |el, handler| { .when_some(action.on_click.clone(), |el, handler| {
el.on_click(move |click_event, window, cx| { el.on_click(move |_click_event, window, cx| handler(window, cx))
handler(click_event, window, cx)
})
}), }),
) )
}) })
} }
} }
impl ToastView for StatusToast {} impl ToastView for StatusToast {
fn action(&self) -> Option<ToastAction> {
self.action.clone()
}
}
impl Focusable for StatusToast { impl Focusable for StatusToast {
fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
@ -144,56 +136,44 @@ impl Focusable for StatusToast {
impl EventEmitter<DismissEvent> for StatusToast {} impl EventEmitter<DismissEvent> for StatusToast {}
impl ComponentPreview for StatusToast { impl ComponentPreview for StatusToast {
fn preview(window: &mut Window, cx: &mut App) -> AnyElement { fn preview(_window: &mut Window, cx: &mut App) -> AnyElement {
let text_example = StatusToast::new("Operation completed", window, cx, |this, _, _| this); let text_example = StatusToast::new("Operation completed", cx, |this, _| this);
let action_example = let action_example = StatusToast::new("Update ready to install", cx, |this, _cx| {
StatusToast::new("Update ready to install", window, cx, |this, _, cx| { this.action("Restart", |_, _| {})
this.action("Restart", cx.listener(|_, _, _, _| {})) });
});
let icon_example = StatusToast::new( let icon_example = StatusToast::new(
"Nathan Sobo accepted your contact request", "Nathan Sobo accepted your contact request",
window,
cx, cx,
|this, _, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Muted)), |this, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Muted)),
); );
let success_example = StatusToast::new( let success_example = StatusToast::new("Pushed 4 changes to `zed/main`", cx, |this, _| {
"Pushed 4 changes to `zed/main`", this.icon(ToastIcon::new(IconName::Check).color(Color::Success))
window, });
cx,
|this, _, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Success)),
);
let error_example = StatusToast::new( let error_example = StatusToast::new(
"git push: Couldn't find remote origin `iamnbutler/zed`", "git push: Couldn't find remote origin `iamnbutler/zed`",
window,
cx, cx,
|this, _, cx| { |this, _cx| {
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
.action("More Info", cx.listener(|_, _, _, _| {})) .action("More Info", |_, _| {})
}, },
); );
let warning_example = let warning_example = StatusToast::new("You have outdated settings", cx, |this, _cx| {
StatusToast::new("You have outdated settings", window, cx, |this, _, cx| { this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning)) .action("More Info", |_, _| {})
.action("More Info", cx.listener(|_, _, _, _| {})) });
});
let pr_example = StatusToast::new( let pr_example =
"`zed/new-notification-system` created!", StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| {
window,
cx,
|this, _, cx| {
this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted)) this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
.action( .action("Open Pull Request", |_, cx| {
"Open Pull Request", cx.open_url("https://github.com/")
cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")), })
) });
},
);
v_flex() v_flex()
.gap_6() .gap_6()

View file

@ -1,12 +1,70 @@
use std::time::{Duration, Instant}; use std::{
rc::Rc,
time::{Duration, Instant},
};
use gpui::{AnyView, DismissEvent, Entity, FocusHandle, ManagedView, Subscription, Task}; use gpui::{actions, AnyView, DismissEvent, Entity, FocusHandle, ManagedView, Subscription, Task};
use ui::{animation::DefaultAnimations, prelude::*}; use ui::{animation::DefaultAnimations, prelude::*};
use crate::Workspace;
const DEFAULT_TOAST_DURATION: Duration = Duration::from_millis(2400); const DEFAULT_TOAST_DURATION: Duration = Duration::from_millis(2400);
const MINIMUM_RESUME_DURATION: Duration = Duration::from_millis(800); const MINIMUM_RESUME_DURATION: Duration = Duration::from_millis(800);
pub trait ToastView: ManagedView {} actions!(toast, [RunAction]);
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(|_workspace, _: &RunAction, window, cx| {
let workspace = cx.entity();
let window = window.window_handle();
cx.defer(move |cx| {
let action = workspace
.read(cx)
.toast_layer
.read(cx)
.active_toast
.as_ref()
.and_then(|active_toast| active_toast.action.clone());
if let Some(on_click) = action.and_then(|action| action.on_click) {
window
.update(cx, |_, window, cx| {
on_click(window, cx);
})
.ok();
}
});
});
})
.detach();
}
pub trait ToastView: ManagedView {
fn action(&self) -> Option<ToastAction>;
}
#[derive(Clone)]
pub struct ToastAction {
pub id: ElementId,
pub label: SharedString,
pub on_click: Option<Rc<dyn Fn(&mut Window, &mut App) + 'static>>,
}
impl ToastAction {
pub fn new(
label: SharedString,
on_click: Option<Rc<dyn Fn(&mut Window, &mut App) + 'static>>,
) -> Self {
let id = ElementId::Name(label.clone());
Self {
id,
label,
on_click,
}
}
}
trait ToastViewHandle { trait ToastViewHandle {
fn view(&self) -> AnyView; fn view(&self) -> AnyView;
@ -20,6 +78,7 @@ impl<V: ToastView> ToastViewHandle for Entity<V> {
pub struct ActiveToast { pub struct ActiveToast {
toast: Box<dyn ToastViewHandle>, toast: Box<dyn ToastViewHandle>,
action: Option<ToastAction>,
_subscriptions: [Subscription; 1], _subscriptions: [Subscription; 1],
focus_handle: FocusHandle, focus_handle: FocusHandle,
} }
@ -50,52 +109,43 @@ impl ToastLayer {
} }
} }
pub fn toggle_toast<V>( pub fn toggle_toast<V>(&mut self, cx: &mut Context<Self>, new_toast: Entity<V>)
&mut self, where
window: &mut Window,
cx: &mut Context<Self>,
new_toast: Entity<V>,
) where
V: ToastView, V: ToastView,
{ {
if let Some(active_toast) = &self.active_toast { if let Some(active_toast) = &self.active_toast {
let is_close = active_toast.toast.view().downcast::<V>().is_ok(); let is_close = active_toast.toast.view().downcast::<V>().is_ok();
let did_close = self.hide_toast(window, cx); let did_close = self.hide_toast(cx);
if is_close || !did_close { if is_close || !did_close {
return; return;
} }
} }
self.show_toast(new_toast, window, cx); self.show_toast(new_toast, cx);
} }
pub fn show_toast<V>( pub fn show_toast<V>(&mut self, new_toast: Entity<V>, cx: &mut Context<Self>)
&mut self, where
new_toast: Entity<V>,
window: &mut Window,
cx: &mut Context<Self>,
) where
V: ToastView, V: ToastView,
{ {
let action = new_toast.read(cx).action();
let focus_handle = cx.focus_handle(); let focus_handle = cx.focus_handle();
self.active_toast = Some(ActiveToast { self.active_toast = Some(ActiveToast {
toast: Box::new(new_toast.clone()), toast: Box::new(new_toast.clone()),
_subscriptions: [cx.subscribe_in( action,
&new_toast, _subscriptions: [cx.subscribe(&new_toast, |this, _, _: &DismissEvent, cx| {
window, this.hide_toast(cx);
|this, _, _: &DismissEvent, window, cx| { })],
this.hide_toast(window, cx);
},
)],
focus_handle, focus_handle,
}); });
self.start_dismiss_timer(DEFAULT_TOAST_DURATION, window, cx); self.start_dismiss_timer(DEFAULT_TOAST_DURATION, cx);
cx.notify(); cx.notify();
} }
pub fn hide_toast(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool { pub fn hide_toast(&mut self, cx: &mut Context<Self>) -> bool {
self.active_toast.take();
cx.notify(); cx.notify();
true true
@ -128,12 +178,7 @@ impl ToastLayer {
} }
/// Starts a timer to automatically dismiss the toast after the specified duration /// Starts a timer to automatically dismiss the toast after the specified duration
pub fn start_dismiss_timer( pub fn start_dismiss_timer(&mut self, duration: Duration, cx: &mut Context<Self>) {
&mut self,
duration: Duration,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.clear_dismiss_timer(cx); self.clear_dismiss_timer(cx);
let instant_started = std::time::Instant::now(); let instant_started = std::time::Instant::now();
@ -141,11 +186,7 @@ impl ToastLayer {
cx.background_executor().timer(duration).await; cx.background_executor().timer(duration).await;
if let Some(this) = this.upgrade() { if let Some(this) = this.upgrade() {
this.update(&mut cx, |this, cx| { this.update(&mut cx, |this, cx| this.hide_toast(cx)).ok();
this.active_toast.take();
cx.notify();
})
.ok();
} }
}); });
@ -158,11 +199,11 @@ impl ToastLayer {
} }
/// Restarts the dismiss timer with a new duration /// Restarts the dismiss timer with a new duration
pub fn restart_dismiss_timer(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn restart_dismiss_timer(&mut self, cx: &mut Context<Self>) {
let Some(duration) = self.duration_remaining else { let Some(duration) = self.duration_remaining else {
return; return;
}; };
self.start_dismiss_timer(duration, window, cx); self.start_dismiss_timer(duration, cx);
cx.notify(); cx.notify();
} }
@ -194,14 +235,14 @@ impl Render for ToastLayer {
h_flex() h_flex()
.id("active-toast-container") .id("active-toast-container")
.occlude() .occlude()
.on_hover(move |hover_start, window, cx| { .on_hover(move |hover_start, _window, cx| {
let Some(this) = handle.upgrade() else { let Some(this) = handle.upgrade() else {
return; return;
}; };
if *hover_start { if *hover_start {
this.update(cx, |this, _| this.pause_dismiss_timer()); this.update(cx, |this, _| this.pause_dismiss_timer());
} else { } else {
this.update(cx, |this, cx| this.restart_dismiss_timer(window, cx)); this.update(cx, |this, cx| this.restart_dismiss_timer(cx));
} }
cx.stop_propagation(); cx.stop_propagation();
}) })

View file

@ -14,7 +14,7 @@ mod toast_layer;
mod toolbar; mod toolbar;
mod workspace_settings; mod workspace_settings;
pub use toast_layer::{ToastLayer, ToastView}; pub use toast_layer::{RunAction, ToastAction, ToastLayer, ToastView};
use anyhow::{anyhow, Context as _, Result}; use anyhow::{anyhow, Context as _, Result};
use call::{call_settings::CallSettings, ActiveCall}; use call::{call_settings::CallSettings, ActiveCall};
@ -384,6 +384,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
init_settings(cx); init_settings(cx);
component::init(); component::init();
theme_preview::init(cx); theme_preview::init(cx);
toast_layer::init(cx);
cx.on_action(Workspace::close_global); cx.on_action(Workspace::close_global);
cx.on_action(reload); cx.on_action(reload);
@ -5016,15 +5017,9 @@ impl Workspace {
}) })
} }
pub fn toggle_status_toast<V: ToastView>( pub fn toggle_status_toast<V: ToastView>(&mut self, entity: Entity<V>, cx: &mut App) {
&mut self, self.toast_layer
window: &mut Window, .update(cx, |toast_layer, cx| toast_layer.toggle_toast(cx, entity))
cx: &mut App,
entity: Entity<V>,
) {
self.toast_layer.update(cx, |toast_layer, cx| {
toast_layer.toggle_toast(window, cx, entity)
})
} }
pub fn toggle_centered_layout( pub fn toggle_centered_layout(