From 0bea86d065b0b8009bb7f2cbb6bde4f7d0f1d90a Mon Sep 17 00:00:00 2001 From: Julia Ryan Date: Tue, 11 Mar 2025 14:39:29 -0700 Subject: [PATCH] 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 --- Cargo.lock | 1 + assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + .../src/component_preview.rs | 25 +- crates/git/src/repository.rs | 4 +- crates/git_ui/Cargo.toml | 1 + crates/git_ui/src/git_panel.rs | 158 ++++++++---- crates/git_ui/src/git_ui.rs | 2 +- crates/git_ui/src/remote_output.rs | 152 ++++++++++++ crates/git_ui/src/remote_output_toast.rs | 227 ------------------ crates/gpui/src/key_dispatch.rs | 5 +- crates/notifications/src/status_toast.rs | 118 ++++----- crates/workspace/src/toast_layer.rs | 123 ++++++---- crates/workspace/src/workspace.rs | 15 +- 14 files changed, 415 insertions(+), 418 deletions(-) create mode 100644 crates/git_ui/src/remote_output.rs delete mode 100644 crates/git_ui/src/remote_output_toast.rs diff --git a/Cargo.lock b/Cargo.lock index 8126b5be0a40061310f66a90a0ef5733849ffbed..9eaa3c53c12617826df0cbda86bdc19cfc4c77b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5455,6 +5455,7 @@ dependencies = [ "log", "menu", "multi_buffer", + "notifications", "panel", "picker", "postage", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 00f5d99cb03a5ee745a944dd418da980e86df021..9d1eb246aaedaf15703e206608b9c577dba9e4be 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -393,6 +393,7 @@ "alt-shift-open": "projects::OpenRemote", "alt-ctrl-shift-o": "projects::OpenRemote", "alt-ctrl-shift-b": "branches::OpenRecent", + "alt-shift-enter": "toast::RunAction", "ctrl-~": "workspace::NewTerminal", "save": "workspace::Save", "ctrl-s": "workspace::Save", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 88a67efdf1aa6b5f484e467e69288431381d1acd..1689c9ebfd527d4b58cc79cbae521fa73388085a 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -487,6 +487,7 @@ "ctrl-~": "workspace::NewTerminal", "cmd-s": "workspace::Save", "cmd-k s": "workspace::SaveWithoutFormat", + "alt-shift-enter": "toast::RunAction", "cmd-shift-s": "workspace::SaveAs", "cmd-shift-n": "workspace::NewWindow", "ctrl-`": "terminal_panel::ToggleFocus", diff --git a/crates/component_preview/src/component_preview.rs b/crates/component_preview/src/component_preview.rs index 522e06f50899f3f9b6d2951421e3a3176617d140..d66360186c59da3be2da7b240e2618e5cffc07ee 100644 --- a/crates/component_preview/src/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -341,22 +341,17 @@ impl ComponentPreview { .into_any_element() } - fn test_status_toast(&self, window: &mut Window, cx: &mut Context) { + fn test_status_toast(&self, cx: &mut Context) { if let Some(workspace) = self.workspace.upgrade() { workspace.update(cx, |workspace, cx| { - let status_toast = StatusToast::new( - "`zed/new-notification-system` created!", - window, - cx, - |this, _, cx| { + let status_toast = + StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| { this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted)) - .action( - "Open Pull Request", - cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")), - ) - }, - ); - workspace.toggle_status_toast(window, cx, status_toast) + .action("Open Pull Request", |_, cx| { + cx.open_url("https://github.com/") + }) + }); + workspace.toggle_status_toast(status_toast, cx) }); } } @@ -406,8 +401,8 @@ impl Render for ComponentPreview { div().w_full().pb_4().child( Button::new("toast-test", "Launch Toast") .on_click(cx.listener({ - move |this, _, window, cx| { - this.test_status_toast(window, cx); + move |this, _, _window, cx| { + this.test_status_toast(cx); cx.notify(); } })) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index b8ad664ed94280ada8dc92c65925626dc9ad7bc6..d9bfc0dcfba898f27c8bfff6a3e03fcc7d84739c 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -92,7 +92,7 @@ impl UpstreamTracking { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct RemoteCommandOutput { pub stdout: String, pub stderr: String, @@ -910,7 +910,7 @@ fn run_remote_command( let output = output?; if !output.status.success() { Err(anyhow!( - "Operation failed:\n{}", + "{}", String::from_utf8_lossy(&output.stderr) )) } else { diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index f7a8a36f460e1eff7bd4f59654963d1e81da64c0..4d7d300d19ef7d65d531abd7fcf44f4628726f68 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -37,6 +37,7 @@ linkme.workspace = true log.workspace = true menu.workspace = true multi_buffer.workspace = true +notifications.workspace = true panel.workspace = true picker.workspace = true postage.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 06f01b3bada0d996e2404c6283eace367da3c9e4..6e8104b8ae229900ac3011080deb12facf875c31 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2,7 +2,7 @@ use crate::askpass_modal::AskPassModal; use crate::commit_modal::CommitModal; use crate::git_panel_settings::StatusStyle; 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::{branch_picker, render_remote_button}; use crate::{ @@ -65,10 +65,11 @@ use ui::{ use util::{maybe, post_inc, ResultExt, TryFutureExt}; use workspace::{AppState, OpenOptions, OpenVisible}; +use notifications::status_toast::{StatusToast, ToastIcon}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, - notifications::{DetachAndPromptErr, NotificationId}, - Toast, Workspace, + notifications::DetachAndPromptErr, + Workspace, }; actions!( @@ -1078,7 +1079,7 @@ impl GitPanel { } result .map_err(|e| { - this.show_err_toast(e, cx); + this.show_error_toast("checkout", e, cx); }) .ok(); }) @@ -1330,7 +1331,7 @@ impl GitPanel { } result .map_err(|e| { - this.show_err_toast(e, cx); + this.show_error_toast(if stage { "add" } else { "reset" }, e, cx); }) .ok(); cx.notify(); @@ -1487,7 +1488,7 @@ impl GitPanel { this.commit_editor .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(); @@ -1529,7 +1530,7 @@ impl GitPanel { 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(); @@ -1741,28 +1742,29 @@ impl GitPanel { telemetry::event!("Git Fetched"); let guard = self.start_remote_operation(); let askpass = self.askpass_delegate("git fetch", window, cx); - cx.spawn(|this, mut cx| async move { - let fetch = repo.update(&mut cx, |repo, cx| repo.fetch(askpass, cx))?; + let this = cx.weak_entity(); + window + .spawn(cx, |mut cx| async move { + let fetch = repo.update(&mut cx, |repo, cx| repo.fetch(askpass, cx))?; - 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) => { - log::error!("Error while fetching {:?}", e); - this.show_err_toast(e, cx); + let remote_message = fetch.await?; + drop(guard); + this.update(&mut cx, |this, cx| { + let action = RemoteAction::Fetch; + match remote_message { + 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) + } } - } + anyhow::Ok(()) + }) + .ok(); anyhow::Ok(()) }) - .ok(); - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + .detach_and_log_err(cx); } pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context) { @@ -1786,7 +1788,7 @@ impl GitPanel { } Err(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(); return Ok(()); } @@ -1807,13 +1809,12 @@ impl GitPanel { let remote_message = pull.await?; drop(guard); + let action = RemoteAction::Pull(remote); this.update(&mut cx, |this, cx| match remote_message { - Ok(remote_message) => { - this.show_remote_output(RemoteAction::Pull, remote_message, cx) - } - Err(err) => { - log::error!("Error while pull {:?}", err); - this.show_err_toast(err, cx) + Ok(remote_message) => this.show_remote_output(action, remote_message, cx), + Err(e) => { + log::error!("Error while pulling {:?}", e); + this.show_error_toast(action.name(), e, cx) } }) .ok(); @@ -1850,7 +1851,7 @@ impl GitPanel { } Err(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(); return Ok(()); } @@ -1877,13 +1878,12 @@ impl GitPanel { let remote_output = push.await?; drop(guard); + let action = RemoteAction::Push(branch.name, remote); this.update(&mut cx, |this, cx| match remote_output { - Ok(remote_message) => { - this.show_remote_output(RemoteAction::Push(remote), remote_message, cx); - } + Ok(remote_message) => this.show_remote_output(action, remote_message, cx), Err(e) => { log::error!("Error while pushing {:?}", e); - this.show_err_toast(e, cx); + this.show_error_toast(action.name(), e, cx) } })?; @@ -2397,14 +2397,13 @@ impl GitPanel { 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, e: anyhow::Error, cx: &mut App) { + let action = action.into(); let Some(workspace) = self.workspace.upgrade() else { return; }; - let notif_id = NotificationId::Named("git-operation-error".into()); let message = e.to_string().trim().to_string(); - let toast; if message .matches(git::repository::REMOTE_CANCELLED_BY_USER) .next() @@ -2412,13 +2411,28 @@ impl GitPanel { { return; // Hide the cancelled by user message } else { - toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| { - window.dispatch_action(workspace::OpenLog.boxed_clone(), cx); + let project = self.project.clone(); + 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) { @@ -2426,16 +2440,62 @@ impl GitPanel { 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)) + let SuccessMessage { message, style } = remote_output::format_output(&action, info); + let workspace_weak = cx.weak_entity(); + 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, + operation: impl Into, + workspace: &mut Workspace, + output: &str, + window: &mut Window, + cx: &mut Context, + ) { + 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 { (!self.pending_remote_operations.borrow().is_empty()).then(|| { Icon::new(IconName::ArrowCircle) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index d153177b2d2a00fcd0992331897f4d4b5df807e2..a49434a258c2c78259ec7f75f112b0fe9d4cca27 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -17,7 +17,7 @@ pub mod git_panel; mod git_panel_settings; pub mod picker_prompt; pub mod project_diff; -mod remote_output_toast; +pub(crate) mod remote_output; pub mod repository_selector; pub fn init(cx: &mut App) { diff --git a/crates/git_ui/src/remote_output.rs b/crates/git_ui/src/remote_output.rs new file mode 100644 index 0000000000000000000000000000000000000000..9ec58d8d82036d2fd80a6da5f700d7ee69d79b1c --- /dev/null +++ b/crates/git_ui/src/remote_output.rs @@ -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 { + 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 }, + } + } + } + } +} diff --git a/crates/git_ui/src/remote_output_toast.rs b/crates/git_ui/src/remote_output_toast.rs deleted file mode 100644 index dd00da080b3dcc34346052b5af3b22e36b75162e..0000000000000000000000000000000000000000 --- a/crates/git_ui/src/remote_output_toast.rs +++ /dev/null @@ -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>, -} - -pub struct RemoteOutputToast { - _workspace: WeakEntity, - _id: NotificationId, - message: SharedString, - remote_info: Option, - _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, - cx: &mut Context, - ) -> 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 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::>() - .join("\n") -} diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index db6789e88c9b291d71a359bf2d6a2ffc08aaed11..96075c48b784e9ca98f8aa9cd44655421327e909 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -401,10 +401,7 @@ impl DispatchTree { .bindings_for_action(action) .filter(|binding| { let (bindings, _) = keymap.bindings_for_input(&binding.keystrokes, context_stack); - bindings - .iter() - .next() - .is_some_and(|b| b.action.partial_eq(action)) + bindings.iter().any(|b| b.action.partial_eq(action)) }) .cloned() .collect() diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs index cb67662f8e69d1c95227840fe39232a5fc5b494f..5f5bb192ac62694e1577b4c8e0902705506f1680 100644 --- a/crates/notifications/src/status_toast.rs +++ b/crates/notifications/src/status_toast.rs @@ -1,15 +1,8 @@ -use std::sync::Arc; +use std::rc::Rc; -use gpui::{ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement}; -use ui::prelude::*; -use workspace::ToastView; - -#[derive(Clone)] -pub struct ToastAction { - id: ElementId, - label: SharedString, - on_click: Option>, -} +use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement}; +use ui::{prelude::*, Tooltip}; +use workspace::{ToastAction, ToastView}; #[derive(Clone, Copy)] pub struct ToastIcon { @@ -40,49 +33,33 @@ impl From for ToastIcon { } } -impl ToastAction { - pub fn new( - label: SharedString, - on_click: Option>, - ) -> Self { - let id = ElementId::Name(label.clone()); - - Self { - id, - label, - on_click, - } - } -} - #[derive(IntoComponent)] #[component(scope = "Notification")] pub struct StatusToast { icon: Option, text: SharedString, action: Option, + this_handle: Entity, focus_handle: FocusHandle, } impl StatusToast { pub fn new( text: impl Into, - window: &mut Window, cx: &mut App, - f: impl FnOnce(Self, &mut Window, &mut Context) -> Self, + f: impl FnOnce(Self, &mut Context) -> Self, ) -> Entity { cx.new(|cx| { let focus_handle = cx.focus_handle(); - window.refresh(); f( Self { text: text.into(), icon: None, action: None, + this_handle: cx.entity(), focus_handle, }, - window, cx, ) }) @@ -96,9 +73,18 @@ impl StatusToast { pub fn action( mut self, label: impl Into, - f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + f: impl Fn(&mut Window, &mut App) + 'static, ) -> 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 } } @@ -122,18 +108,24 @@ impl Render for StatusToast { .when_some(self.action.as_ref(), |this, action| { this.child( Button::new(action.id.clone(), action.label.clone()) + .tooltip(Tooltip::for_action_title( + action.label.clone(), + &workspace::RunAction, + )) .color(Color::Muted) .when_some(action.on_click.clone(), |el, handler| { - el.on_click(move |click_event, window, cx| { - handler(click_event, window, cx) - }) + el.on_click(move |_click_event, window, cx| handler(window, cx)) }), ) }) } } -impl ToastView for StatusToast {} +impl ToastView for StatusToast { + fn action(&self) -> Option { + self.action.clone() + } +} impl Focusable for StatusToast { fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { @@ -144,56 +136,44 @@ impl Focusable for StatusToast { impl EventEmitter for StatusToast {} impl ComponentPreview for StatusToast { - fn preview(window: &mut Window, cx: &mut App) -> AnyElement { - let text_example = StatusToast::new("Operation completed", window, cx, |this, _, _| this); + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + let text_example = StatusToast::new("Operation completed", cx, |this, _| this); - let action_example = - StatusToast::new("Update ready to install", window, cx, |this, _, cx| { - this.action("Restart", cx.listener(|_, _, _, _| {})) - }); + let action_example = StatusToast::new("Update ready to install", cx, |this, _cx| { + this.action("Restart", |_, _| {}) + }); let icon_example = StatusToast::new( "Nathan Sobo accepted your contact request", - window, 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( - "Pushed 4 changes to `zed/main`", - window, - cx, - |this, _, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Success)), - ); + let success_example = StatusToast::new("Pushed 4 changes to `zed/main`", cx, |this, _| { + this.icon(ToastIcon::new(IconName::Check).color(Color::Success)) + }); let error_example = StatusToast::new( "git push: Couldn't find remote origin `iamnbutler/zed`", - window, cx, - |this, _, cx| { + |this, _cx| { this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) - .action("More Info", cx.listener(|_, _, _, _| {})) + .action("More Info", |_, _| {}) }, ); - let warning_example = - StatusToast::new("You have outdated settings", window, cx, |this, _, cx| { - this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning)) - .action("More Info", cx.listener(|_, _, _, _| {})) - }); + let warning_example = StatusToast::new("You have outdated settings", cx, |this, _cx| { + this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning)) + .action("More Info", |_, _| {}) + }); - let pr_example = StatusToast::new( - "`zed/new-notification-system` created!", - window, - cx, - |this, _, cx| { + let pr_example = + StatusToast::new("`zed/new-notification-system` created!", cx, |this, _cx| { this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted)) - .action( - "Open Pull Request", - cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")), - ) - }, - ); + .action("Open Pull Request", |_, cx| { + cx.open_url("https://github.com/") + }) + }); v_flex() .gap_6() diff --git a/crates/workspace/src/toast_layer.rs b/crates/workspace/src/toast_layer.rs index 1ebcda4c03ba02359002ab10b506b1d641cf5835..7de61b948935fd3a2de9d866133e10bbfa706167 100644 --- a/crates/workspace/src/toast_layer.rs +++ b/crates/workspace/src/toast_layer.rs @@ -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 crate::Workspace; + const DEFAULT_TOAST_DURATION: Duration = Duration::from_millis(2400); 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; +} + +#[derive(Clone)] +pub struct ToastAction { + pub id: ElementId, + pub label: SharedString, + pub on_click: Option>, +} + +impl ToastAction { + pub fn new( + label: SharedString, + on_click: Option>, + ) -> Self { + let id = ElementId::Name(label.clone()); + + Self { + id, + label, + on_click, + } + } +} trait ToastViewHandle { fn view(&self) -> AnyView; @@ -20,6 +78,7 @@ impl ToastViewHandle for Entity { pub struct ActiveToast { toast: Box, + action: Option, _subscriptions: [Subscription; 1], focus_handle: FocusHandle, } @@ -50,52 +109,43 @@ impl ToastLayer { } } - pub fn toggle_toast( - &mut self, - window: &mut Window, - cx: &mut Context, - new_toast: Entity, - ) where + pub fn toggle_toast(&mut self, cx: &mut Context, new_toast: Entity) + where V: ToastView, { if let Some(active_toast) = &self.active_toast { let is_close = active_toast.toast.view().downcast::().is_ok(); - let did_close = self.hide_toast(window, cx); + let did_close = self.hide_toast(cx); if is_close || !did_close { return; } } - self.show_toast(new_toast, window, cx); + self.show_toast(new_toast, cx); } - pub fn show_toast( - &mut self, - new_toast: Entity, - window: &mut Window, - cx: &mut Context, - ) where + pub fn show_toast(&mut self, new_toast: Entity, cx: &mut Context) + where V: ToastView, { + let action = new_toast.read(cx).action(); let focus_handle = cx.focus_handle(); self.active_toast = Some(ActiveToast { toast: Box::new(new_toast.clone()), - _subscriptions: [cx.subscribe_in( - &new_toast, - window, - |this, _, _: &DismissEvent, window, cx| { - this.hide_toast(window, cx); - }, - )], + action, + _subscriptions: [cx.subscribe(&new_toast, |this, _, _: &DismissEvent, cx| { + this.hide_toast(cx); + })], focus_handle, }); - self.start_dismiss_timer(DEFAULT_TOAST_DURATION, window, cx); + self.start_dismiss_timer(DEFAULT_TOAST_DURATION, cx); cx.notify(); } - pub fn hide_toast(&mut self, _window: &mut Window, cx: &mut Context) -> bool { + pub fn hide_toast(&mut self, cx: &mut Context) -> bool { + self.active_toast.take(); cx.notify(); true @@ -128,12 +178,7 @@ impl ToastLayer { } /// Starts a timer to automatically dismiss the toast after the specified duration - pub fn start_dismiss_timer( - &mut self, - duration: Duration, - _window: &mut Window, - cx: &mut Context, - ) { + pub fn start_dismiss_timer(&mut self, duration: Duration, cx: &mut Context) { self.clear_dismiss_timer(cx); let instant_started = std::time::Instant::now(); @@ -141,11 +186,7 @@ impl ToastLayer { cx.background_executor().timer(duration).await; if let Some(this) = this.upgrade() { - this.update(&mut cx, |this, cx| { - this.active_toast.take(); - cx.notify(); - }) - .ok(); + this.update(&mut cx, |this, cx| this.hide_toast(cx)).ok(); } }); @@ -158,11 +199,11 @@ impl ToastLayer { } /// Restarts the dismiss timer with a new duration - pub fn restart_dismiss_timer(&mut self, window: &mut Window, cx: &mut Context) { + pub fn restart_dismiss_timer(&mut self, cx: &mut Context) { let Some(duration) = self.duration_remaining else { return; }; - self.start_dismiss_timer(duration, window, cx); + self.start_dismiss_timer(duration, cx); cx.notify(); } @@ -194,14 +235,14 @@ impl Render for ToastLayer { h_flex() .id("active-toast-container") .occlude() - .on_hover(move |hover_start, window, cx| { + .on_hover(move |hover_start, _window, cx| { let Some(this) = handle.upgrade() else { return; }; if *hover_start { this.update(cx, |this, _| this.pause_dismiss_timer()); } 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(); }) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index fe4a0227ee804f432eb2eca0054ea72930fe9d10..87f3148b63710d25bade24f26c3d7d5027444487 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -14,7 +14,7 @@ mod toast_layer; mod toolbar; mod workspace_settings; -pub use toast_layer::{ToastLayer, ToastView}; +pub use toast_layer::{RunAction, ToastAction, ToastLayer, ToastView}; use anyhow::{anyhow, Context as _, Result}; use call::{call_settings::CallSettings, ActiveCall}; @@ -384,6 +384,7 @@ pub fn init(app_state: Arc, cx: &mut App) { init_settings(cx); component::init(); theme_preview::init(cx); + toast_layer::init(cx); cx.on_action(Workspace::close_global); cx.on_action(reload); @@ -5018,15 +5019,9 @@ impl Workspace { }) } - pub fn toggle_status_toast( - &mut self, - window: &mut Window, - cx: &mut App, - entity: Entity, - ) { - self.toast_layer.update(cx, |toast_layer, cx| { - toast_layer.toggle_toast(window, cx, entity) - }) + pub fn toggle_status_toast(&mut self, entity: Entity, cx: &mut App) { + self.toast_layer + .update(cx, |toast_layer, cx| toast_layer.toggle_toast(cx, entity)) } pub fn toggle_centered_layout(