diff --git a/Cargo.lock b/Cargo.lock index 9d0e823a79..959ea95ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5460,6 +5460,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 319ffd2b81..5c5ef39708 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 3edc8bc8f7..608ec76f85 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -514,6 +514,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 839234724d..e913f80177 100644 --- a/crates/component_preview/src/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -417,22 +417,17 @@ impl ComponentPreview { } } - 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) }); } } @@ -478,8 +473,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 8dd500ca6b..31f4a5d4c4 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, @@ -912,7 +912,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 2dea431753..ddff1f440e 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -36,6 +36,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 15f2a02dcc..f660fe40b8 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::{ @@ -64,10 +64,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!( @@ -948,7 +949,7 @@ impl GitPanel { } result .map_err(|e| { - this.show_err_toast(e, cx); + this.show_error_toast("checkout", e, cx); }) .ok(); }) @@ -1200,7 +1201,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(); @@ -1357,7 +1358,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(); @@ -1399,7 +1400,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(); @@ -1611,28 +1612,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); + 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) + } } - Err(e) => { - log::error!("Error while fetching {:?}", e); - this.show_err_toast(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) { @@ -1656,7 +1658,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(()); } @@ -1677,13 +1679,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(); @@ -1720,7 +1721,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(()); } @@ -1747,13 +1748,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) } })?; @@ -2241,14 +2241,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() @@ -2256,13 +2255,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) { @@ -2270,16 +2284,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 d153177b2d..a49434a258 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 0000000000..9ec58d8d82 --- /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 dd00da080b..0000000000 --- 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 db6789e88c..96075c48b7 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 cb67662f8e..5f5bb192ac 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 1ebcda4c03..7de61b9489 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 2f7584079f..2c2fbe6ece 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); @@ -5016,15 +5017,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(