From 1b7e58bce855d753d86448d5ef4d5bacba4ae5c0 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Sun, 7 Jun 2026 18:55:18 +0200 Subject: [PATCH] Typed workspace errors (#57649) This migrates the `Workspace::show_error` method to take a generic type that implements `WorkspaceError` instead. That trait containts methods to show a proper error message but most importantly means to help with providing actions given certain errors. The intention for this is rather easy: Whenever we show an error to the user on the workspace level, that error should be - actionable to a degree, even if that is only opening logs or whatnot - show a message that is understandable to the user - for not-too-critical errors, should hide after a certain delay and not force the user to dismiss it using their mouse. For now, there exist trait implementations for string error types and anyhow errors. The long term goal here is to get rid of these altogether and migrate everything to be strongly typed and especially actionable instead. Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [X] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov Co-authored-by: Ben Brandt --- crates/agent_ui/src/agent_panel.rs | 2 +- crates/auto_update_ui/src/auto_update_ui.rs | 40 +- crates/collab_ui/src/collab_panel.rs | 2 +- crates/command_palette/src/command_palette.rs | 2 +- crates/copilot_ui/src/sign_in.rs | 2 +- crates/edit_prediction/src/zeta.rs | 31 +- .../src/edit_prediction_button.rs | 16 +- crates/editor/src/navigation.rs | 11 +- crates/extensions_ui/src/extensions_ui.rs | 13 +- crates/git_ui/src/git_panel.rs | 15 +- .../recent_projects/src/remote_connections.rs | 2 +- crates/workspace/src/notifications.rs | 1147 +++++++++++------ crates/workspace/src/pane.rs | 7 +- crates/workspace/src/workspace.rs | 27 +- crates/workspace/src/workspace_error.rs | 204 +++ crates/zed/src/zed.rs | 52 +- crates/zed_actions/src/lib.rs | 6 +- 17 files changed, 1132 insertions(+), 447 deletions(-) create mode 100644 crates/workspace/src/workspace_error.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 0e2320a2d9..882fba2092 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2030,7 +2030,7 @@ impl AgentPanel { Err(error) => { log::error!("failed to spawn agent panel terminal: {error:#}"); workspace - .update(cx, |workspace, cx| workspace.show_error(&error, cx)) + .update(cx, |workspace, cx| workspace.show_error(error, cx)) .log_err(); this.update(cx, |this, cx| { if this.pending_terminal_spawn == Some(terminal_id) { diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index ca37bbb1b8..b6f4faec93 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/crates/auto_update_ui/src/auto_update_ui.rs @@ -20,9 +20,10 @@ use util::{ResultExt as _, maybe}; use workspace::{ Workspace, notifications::{ - ErrorMessagePrompt, Notification, NotificationId, SuppressEvent, show_app_notification, + Notification, NotificationId, SuppressEvent, show_app_notification, simple_message_notification::MessageNotification, }, + workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError}, }; use zed_actions::ShowUpdateNotification; @@ -64,21 +65,28 @@ fn notify_release_notes_failed_to_show( _window: &mut Window, cx: &mut Context, ) { - struct ViewReleaseNotesError; - workspace.show_notification( - NotificationId::unique::(), - cx, - |cx| { - cx.new(move |cx| { - let url = release_notes_url(cx); - let mut prompt = ErrorMessagePrompt::new("Couldn't load release notes", cx); - if let Some(url) = url { - prompt = prompt.with_link_button("View in Browser".to_string(), url); - } - prompt - }) - }, - ); + let url = release_notes_url(cx); + + struct ReleaseNotesError { + url: Option, + } + + impl WorkspaceError for ReleaseNotesError { + fn primary_message(&self) -> SharedString { + "Couldn't load release notes".into() + } + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Error + } + fn primary_action(&self) -> ErrorAction { + self.url + .clone() + .map(|url| ErrorAction::link("View in Browser", url)) + .unwrap_or_else(ErrorAction::dismiss) + } + } + + workspace.show_error(ReleaseNotesError { url }, cx); } fn view_release_notes_locally( diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index b24c97e078..907c462aa6 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -166,7 +166,7 @@ pub fn init(cx: &mut App) { }) .detach_and_notify_err(workspace_handle, window, cx); } else { - workspace.show_error(&"There’s no active call; join one first.", cx); + workspace.show_error("There’s no active call; join one first.", cx); } }); workspace.register_action(|workspace, _: &ShareProject, window, cx| { diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 3104fecf20..569d8407b6 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -488,7 +488,7 @@ impl PickerDelegate for CommandPaletteDelegate { CommandInterceptResult { results: vec![CommandInterceptItem { action: OpenZedUrl { - url: query_for_link.clone(), + url: query_for_link.clone().into(), } .boxed_clone(), string: query_for_link, diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index fe9ac57bda..6d1f67607f 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -37,7 +37,7 @@ pub fn initiate_sign_out(copilot: Entity, window: &mut Window, cx: &mut Err(err) => cx.update(|window, cx| { if let Some(workspace) = Workspace::for_window(window, cx) { workspace.update(cx, |workspace, cx| { - workspace.show_error(&err, cx); + workspace.show_error(format!("Error: {err}"), cx); }) } else { log::error!("{:?}", err); diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 72463392da..9b9217ebec 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -20,7 +20,9 @@ use language::{ use release_channel::AppVersion; use text::{Anchor, Bias, Point}; use ui::SharedString; -use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; +use workspace::notifications::simple_message_notification::MessageNotification; +use workspace::notifications::{NotificationId, show_app_notification}; +use workspace::workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError}; use zeta_prompt::{ParsedOutput, ZetaPromptInput}; use std::{ops::Range, path::Path, sync::Arc}; @@ -503,14 +505,33 @@ fn handle_api_response( }) .ok(); - let error_message: SharedString = err.to_string().into(); + let message: SharedString = err.to_string().into(); + + struct UpdateRequiredError { + message: SharedString, + } + impl WorkspaceError for UpdateRequiredError { + fn primary_message(&self) -> SharedString { + self.message.clone() + } + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } + fn primary_action(&self) -> ErrorAction { + ErrorAction::link("Update Zed", "https://zed.dev/releases") + } + } + show_app_notification( NotificationId::unique::(), cx, move |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new(error_message.clone(), cx) - .with_link_button("Update Zed", "https://zed.dev/releases") + cx.new({ + let message = message.clone(); + move |cx| { + let error = UpdateRequiredError { message }; + MessageNotification::from_workspace_error(error, cx) + } }) }, ); diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 1593a70f5a..62d6209f1f 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -1118,7 +1118,7 @@ impl EditPredictionButton { .link_with_handler( "Learn More", OpenBrowser { - url: zed_urls::edit_prediction_docs(cx), + url: zed_urls::edit_prediction_docs(cx).into(), } .boxed_clone(), |_window, _cx| { @@ -1631,12 +1631,10 @@ fn emit_edit_prediction_menu_opened( ); } -fn copilot_settings_url(enterprise_uri: Option<&str>) -> String { +fn copilot_settings_url(enterprise_uri: Option<&str>) -> Arc { match enterprise_uri { - Some(uri) => { - format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH) - } - None => COPILOT_SETTINGS_URL.to_string(), + Some(uri) => format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH).into(), + None => COPILOT_SETTINGS_URL.into(), } } @@ -1672,7 +1670,7 @@ mod tests { ) }); - assert_eq!(url, "https://my-company.ghe.com/settings/copilot"); + assert_eq!(url.as_ref(), "https://my-company.ghe.com/settings/copilot"); } #[gpui::test] @@ -1702,7 +1700,7 @@ mod tests { ) }); - assert_eq!(url, "https://my-company.ghe.com/settings/copilot"); + assert_eq!(url.as_ref(), "https://my-company.ghe.com/settings/copilot"); } #[gpui::test] @@ -1723,6 +1721,6 @@ mod tests { ) }); - assert_eq!(url, "https://github.com/settings/copilot"); + assert_eq!(url.as_ref(), "https://github.com/settings/copilot"); } } diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs index c117aa97a3..a8fc021b33 100644 --- a/crates/editor/src/navigation.rs +++ b/crates/editor/src/navigation.rs @@ -1082,7 +1082,10 @@ impl Editor { if let Some(url) = url { cx.update(|window, cx| { if parse_zed_link(&url, cx).is_some() { - window.dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx); + window.dispatch_action( + Box::new(zed_actions::OpenZedUrl { url: url.into() }), + cx, + ); } else { cx.open_url(&url); } @@ -1727,8 +1730,10 @@ impl Editor { Some(Either::Left(url)) => { cx.update(|window, cx| { if parse_zed_link(&url, cx).is_some() { - window - .dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx); + window.dispatch_action( + Box::new(zed_actions::OpenZedUrl { url: url.into() }), + cx, + ); } else { cx.open_url(&url); } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index a08036059d..2d6f5fb2d2 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -132,7 +132,12 @@ pub fn init(cx: &mut App) { Err(err) => { workspace_handle .update(cx, |workspace, cx| { - workspace.show_portal_error(err.to_string(), cx); + workspace.show_error( + workspace::workspace_error::PortalError::new( + err.to_string(), + ), + cx, + ); }) .ok(); return None; @@ -149,10 +154,10 @@ pub fn init(cx: &mut App) { log::error!("Failed to install dev extension: {:?}", err); workspace_handle .update(cx, |workspace, cx| { + // NOTE: using `anyhow::context` here ends up not printing + // the error workspace.show_error( - // NOTE: using `anyhow::context` here ends up not printing - // the error - &format!("Failed to install dev extension: {}", err), + format!("Failed to install dev extension: {}", err), cx, ); }) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 8dd47948a8..d3d75147ca 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -82,7 +82,7 @@ use workspace::SERIALIZATION_THROTTLE_TIME; use workspace::{ Item, Workspace, dock::{DockPosition, Panel, PanelEvent}, - notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyTaskExt}, + notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt}, }; use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; @@ -826,7 +826,7 @@ impl GitPanel { GitStoreEvent::IndexWriteError(error) => { this.workspace .update(cx, |workspace, cx| { - workspace.show_error(error, cx); + workspace.show_error(format!("{error}"), cx); }) .ok(); } @@ -4080,16 +4080,7 @@ impl GitPanel { { if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) { let _ = workspace.update(cx, |workspace, cx| { - struct CommitMessageError; - let notification_id = NotificationId::unique::(); - workspace.show_notification(notification_id, cx, |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new( - format!("Failed to generate commit message: {err}"), - cx, - ) - }) - }); + workspace.show_error(format!("Failed to generate commit message: {err}"), cx); }); } } diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 6a453b7a5c..158ecf8fcd 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -181,7 +181,7 @@ pub async fn open_remote_project( workspace.update(cx, |workspace, cx| { for item in open_results.iter().flatten() { if let Err(e) = item { - workspace.show_error(&e, cx); + workspace.show_error(format!("{e}"), cx); } } }); diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index e7a1dbd5e0..42e75b1c8c 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -1,9 +1,11 @@ -use crate::{MultiWorkspace, SuppressNotification, Toast, Workspace}; +use crate::{ + MultiWorkspace, SuppressNotification, Toast, Workspace, workspace_error::WorkspaceError, +}; use anyhow::Context as _; use gpui::{ AnyEntity, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, - Task, TextStyleRefinement, UnderlineStyle, WeakEntity, svg, + Task, TextStyleRefinement, UnderlineStyle, WeakEntity, }; use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; @@ -158,24 +160,10 @@ impl Workspace { cx.notify(); } - pub fn show_error(&mut self, err: &E, cx: &mut Context) - where - E: std::fmt::Debug + std::fmt::Display, - { - self.show_notification(workspace_error_notification_id(), cx, |cx| { - cx.new(|cx| ErrorMessagePrompt::new(format!("Error: {err}"), cx)) - }); - } - - pub fn show_portal_error(&mut self, err: String, cx: &mut Context) { - struct PortalError; - - self.show_notification(NotificationId::unique::(), cx, |cx| { + pub fn show_error(&mut self, err: E, cx: &mut Context) { + self.show_notification(NotificationId::unique::(), cx, |cx| { cx.new(|cx| { - ErrorMessagePrompt::new(err.to_string(), cx).with_link_button( - "See docs", - "https://zed.dev/docs/linux#i-cant-open-any-files", - ) + simple_message_notification::MessageNotification::from_workspace_error(err, cx) }) }); } @@ -194,18 +182,17 @@ impl Workspace { pub fn show_toast(&mut self, toast: Toast, cx: &mut Context) { self.dismiss_notification(&toast.id, cx); self.show_notification(toast.id.clone(), cx, |cx| { - cx.new(|cx| match toast.on_click.as_ref() { - Some((click_msg, on_click)) => { - let on_click = on_click.clone(); - simple_message_notification::MessageNotification::new(toast.msg.clone(), cx) - .primary_message(click_msg.clone()) - .primary_on_click(move |window, cx| on_click(window, cx)) - } - None => { - simple_message_notification::MessageNotification::new(toast.msg.clone(), cx) - } + cx.new(|cx| { + simple_message_notification::MessageNotification::new(toast.message, cx).when_some( + toast.on_click, + |this, (click_msg, on_click)| { + this.primary_message(click_msg) + .primary_on_click(move |window, cx| on_click(window, cx)) + }, + ) }) }); + if toast.autohide { cx.spawn(async move |workspace, cx| { cx.background_executor() @@ -477,277 +464,189 @@ fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { } } -#[derive(Debug, Clone)] -pub struct ErrorMessagePrompt { - message: SharedString, - focus_handle: gpui::FocusHandle, - label_and_url_button: Option<(SharedString, SharedString)>, -} - -impl ErrorMessagePrompt { - pub fn new(message: S, cx: &mut App) -> Self - where - S: Into, - { - Self { - message: message.into(), - focus_handle: cx.focus_handle(), - label_and_url_button: None, - } - } - - pub fn with_link_button(mut self, label: S, url: S) -> Self - where - S: Into, - { - self.label_and_url_button = Some((label.into(), url.into())); - self - } -} - -impl Render for ErrorMessagePrompt { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() - .id("error_message_prompt_notification") - .occlude() - .elevation_3(cx) - .items_start() - .justify_between() - .p_2() - .gap_2() - .w_full() - .child( - v_flex() - .w_full() - .child( - h_flex() - .w_full() - .justify_between() - .child( - svg() - .size(window.text_style().font_size) - .flex_none() - .mr_2() - .mt(px(-2.0)) - .map(|icon| { - icon.path(IconName::Warning.path()) - .text_color(Color::Error.color(cx)) - }), - ) - .child( - h_flex() - .gap_1() - .child( - CopyButton::new("copy-error-message", self.message.clone()) - .tooltip_label("Copy Error Message"), - ) - .child( - ui::IconButton::new("close", ui::IconName::Close).on_click( - cx.listener(|_, _, _, cx| cx.emit(DismissEvent)), - ), - ), - ), - ) - .child( - div() - .id("error_message") - .max_w_96() - .max_h_40() - .overflow_y_scroll() - .child(Label::new(self.message.clone()).size(LabelSize::Small)), - ) - .when_some(self.label_and_url_button.clone(), |elm, (label, url)| { - elm.child( - div().mt_2().child( - ui::Button::new("error_message_prompt_notification_button", label) - .on_click(move |_, _, cx| cx.open_url(&url)), - ), - ) - }), - ) - } -} - -impl Focusable for ErrorMessagePrompt { - fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl EventEmitter for ErrorMessagePrompt {} -impl EventEmitter for ErrorMessagePrompt {} - -impl Notification for ErrorMessagePrompt {} - -#[derive(IntoElement, RegisterComponent)] -pub struct NotificationFrame { - title: Option, - show_suppress_button: bool, - show_close_button: bool, - close: Option>, - contents: Option, - suffix: Option, -} - -impl NotificationFrame { - pub fn new() -> Self { - Self { - title: None, - contents: None, - suffix: None, - show_suppress_button: true, - show_close_button: true, - close: None, - } - } - - pub fn with_title(mut self, title: Option>) -> Self { - self.title = title.map(Into::into); - self - } - - pub fn with_content(self, content: impl IntoElement) -> Self { - Self { - contents: Some(content.into_any_element()), - ..self - } - } - - /// Determines whether the given notification ID should be suppressible - /// Suppressed notifications will not be shown anymore - pub fn show_suppress_button(mut self, show: bool) -> Self { - self.show_suppress_button = show; - self - } - - pub fn show_close_button(mut self, show: bool) -> Self { - self.show_close_button = show; - self - } - - pub fn on_close(self, on_close: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { - Self { - close: Some(Box::new(on_close)), - ..self - } - } - - pub fn with_suffix(mut self, suffix: impl IntoElement) -> Self { - self.suffix = Some(suffix.into_any_element()); - self - } -} - -impl RenderOnce for NotificationFrame { - fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let entity = window.current_view(); - let show_suppress_button = self.show_suppress_button; - let suppress = show_suppress_button && window.modifiers().shift; - let (close_id, close_icon) = if suppress { - ("suppress", IconName::Minimize) - } else { - ("close", IconName::Close) - }; - - v_flex() - .occlude() - .p_3() - .gap_2() - .elevation_3(cx) - .child( - h_flex() - .gap_4() - .justify_between() - .items_start() - .child( - v_flex() - .gap_0p5() - .when_some(self.title.clone(), |div, title| { - div.child(Label::new(title)) - }) - .child(div().max_w_96().children(self.contents)), - ) - .when(self.show_close_button, |this| { - this.on_modifiers_changed(move |_, _, cx| cx.notify(entity)) - .child( - IconButton::new(close_id, close_icon) - .tooltip(move |_window, cx| { - if suppress { - Tooltip::with_meta( - "Suppress", - Some(&SuppressNotification), - "Click to Close", - cx, - ) - } else if show_suppress_button { - Tooltip::with_meta( - "Close", - Some(&menu::Cancel), - "Shift-click to Suppress", - cx, - ) - } else { - Tooltip::for_action("Close", &menu::Cancel, cx) - } - }) - .on_click({ - let close = self.close.take(); - move |_, window, cx| { - if let Some(close) = &close { - close(&suppress, window, cx) - } - } - }), - ) - }), - ) - .children(self.suffix) - } -} - -impl Component for NotificationFrame { - fn description() -> &'static str { - "The standard container used by workspace notifications, \ - providing a consistent title row, close and suppress affordances, \ - and a slot for the notification's contents." - } - - fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement { - single_example( - "Default", - NotificationFrame::new() - .with_title(Some("Notification Title")) - .with_content(Label::new( - "This is the content of a workspace notification.", - )) - .into_any_element(), - ) - .into_any_element() - } -} - pub mod simple_message_notification { use std::sync::Arc; + use std::time::{Duration, Instant}; use gpui::{ AnyElement, DismissEvent, EventEmitter, FocusHandle, Focusable, ParentElement, Render, - ScrollHandle, SharedString, Styled, + ScrollHandle, SharedString, Styled, Task, }; - use ui::{WithScrollbar, prelude::*}; + use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*}; - use crate::notifications::NotificationFrame; + use crate::SuppressNotification; + use crate::workspace_error::{ + ActionIcon, ErrorAction, ErrorActionHandler, ErrorSeverity, WorkspaceError, + }; use super::{Notification, SuppressEvent}; + const FADE_OUT_DURATION: Duration = Duration::from_secs(2); + const FADE_TO_FULL_OPACITY_DURATION: Duration = Duration::from_millis(200); + + pub(super) struct AutoHideState { + remaining_dismiss_duration: Duration, + timer_started: Option, + hovered: bool, + fade: Option, + task: Option>, + } + + enum AutoHideFade { + FadingOut { + started_at: Instant, + }, + FadingIn { + started_at: Instant, + start_opacity: f32, + }, + } + + impl AutoHideState { + fn new(duration: Duration, cx: &mut Context) -> Self { + let mut this = Self { + remaining_dismiss_duration: duration, + timer_started: None, + hovered: false, + fade: None, + task: None, + }; + this.schedule(cx); + this + } + + fn schedule(&mut self, cx: &mut Context) { + if self.task.is_some() || self.hovered { + return; + } + + let duration = self.remaining_dismiss_duration; + self.timer_started = Some(Instant::now()); + self.task = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(duration).await; + if let Err(error) = this.update(cx, |this, cx| { + if let Some(auto_hide) = this.auto_hide.as_mut() { + auto_hide.finish_timer(); + if !auto_hide.hovered { + auto_hide.start_fading_out(); + cx.notify(); + } + } + }) { + log::error!("failed to update auto-hiding notification: {error:?}"); + } + })); + } + + fn set_hovered(&mut self, hovered: bool, cx: &mut Context) { + if self.hovered == hovered { + return; + } + + self.hovered = hovered; + if hovered { + self.remaining_dismiss_duration = self.remaining_dismiss_duration(); + self.timer_started = None; + self.task.take(); + + if matches!(self.fade, Some(AutoHideFade::FadingOut { .. })) { + let start_opacity = self.opacity(); + self.fade = Some(AutoHideFade::FadingIn { + started_at: Instant::now(), + start_opacity, + }); + } + } else { + if matches!(self.fade, Some(AutoHideFade::FadingIn { .. })) { + self.fade = None; + } + self.schedule(cx); + } + cx.notify(); + } + + fn refresh_animation(&mut self) -> bool { + match self.fade { + Some(AutoHideFade::FadingOut { started_at }) + if started_at.elapsed() >= FADE_OUT_DURATION => + { + true + } + Some(AutoHideFade::FadingIn { started_at, .. }) + if started_at.elapsed() >= FADE_TO_FULL_OPACITY_DURATION => + { + self.fade = None; + false + } + _ => false, + } + } + + fn needs_animation_frame(&self) -> bool { + self.fade.is_some() + } + + fn opacity(&self) -> f32 { + match self.fade { + Some(AutoHideFade::FadingOut { started_at }) => { + 1.0 - duration_progress(started_at.elapsed(), FADE_OUT_DURATION) + } + Some(AutoHideFade::FadingIn { + started_at, + start_opacity, + }) => { + let progress = + duration_progress(started_at.elapsed(), FADE_TO_FULL_OPACITY_DURATION); + start_opacity + (1.0 - start_opacity) * progress + } + None => 1.0, + } + } + + fn finish_timer(&mut self) { + self.task.take(); + self.timer_started = None; + self.remaining_dismiss_duration = Duration::ZERO; + } + + fn start_fading_out(&mut self) { + self.fade = Some(AutoHideFade::FadingOut { + started_at: Instant::now(), + }); + } + + fn remaining_dismiss_duration(&self) -> Duration { + self.timer_started + .map_or(self.remaining_dismiss_duration, |timer_started| { + self.remaining_dismiss_duration + .saturating_sub(timer_started.elapsed()) + }) + } + } + + fn duration_progress(elapsed: Duration, duration: Duration) -> f32 { + if duration.is_zero() { + 1.0 + } else { + (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0) + } + } + + #[derive(RegisterComponent)] pub struct MessageNotification { focus_handle: FocusHandle, build_content: Box) -> AnyElement>, + button_style: Option, + content_icon: Option, + content_icon_color: Option, + secondary_content: Option, + copy_text: Option, primary_message: Option, - primary_icon: Option, + primary_icon: Option, primary_icon_color: Option, primary_on_click: Option)>>, secondary_message: Option, - secondary_icon: Option, + secondary_icon: Option, secondary_icon_color: Option, secondary_on_click: Option)>>, more_info_message: Option, @@ -756,6 +655,7 @@ pub mod simple_message_notification { show_suppress_button: bool, title: Option, scroll_handle: ScrollHandle, + auto_hide: Option, } impl Focusable for MessageNotification { @@ -769,6 +669,8 @@ pub mod simple_message_notification { impl Notification for MessageNotification {} + impl FluentBuilder for MessageNotification {} + impl MessageNotification { pub fn new(message: S, cx: &mut App) -> MessageNotification where @@ -786,6 +688,11 @@ pub mod simple_message_notification { { Self { build_content: Box::new(content), + button_style: None, + content_icon: None, + content_icon_color: None, + secondary_content: None, + copy_text: None, primary_message: None, primary_icon: None, primary_icon_color: None, @@ -801,9 +708,15 @@ pub mod simple_message_notification { title: None, focus_handle: cx.focus_handle(), scroll_handle: ScrollHandle::new(), + auto_hide: None, } } + pub fn button_style(mut self, style: ButtonStyle) -> Self { + self.button_style = Some(style); + self + } + pub fn primary_message(mut self, message: S) -> Self where S: Into, @@ -812,8 +725,15 @@ pub mod simple_message_notification { self } + /// Show `icon` at the start (left) of the primary action button label. pub fn primary_icon(mut self, icon: IconName) -> Self { - self.primary_icon = Some(icon); + self.primary_icon = Some(ActionIcon::start(icon)); + self + } + + /// Show `icon` at the end (right) of the primary action button label. + pub fn primary_end_icon(mut self, icon: IconName) -> Self { + self.primary_icon = Some(ActionIcon::end(icon)); self } @@ -846,8 +766,15 @@ pub mod simple_message_notification { self } + /// Show `icon` at the start (left) of the secondary action button label. pub fn secondary_icon(mut self, icon: IconName) -> Self { - self.secondary_icon = Some(icon); + self.secondary_icon = Some(ActionIcon::start(icon)); + self + } + + /// Show `icon` at the end (right) of the secondary action button label. + pub fn secondary_end_icon(mut self, icon: IconName) -> Self { + self.secondary_icon = Some(ActionIcon::end(icon)); self } @@ -911,99 +838,578 @@ pub mod simple_message_notification { self.title = Some(title.into()); self } + + pub fn content_icon(mut self, icon: IconName, color: Color) -> Self { + self.content_icon = Some(icon); + self.content_icon_color = Some(color); + self + } + + pub fn secondary_content>(mut self, text: S) -> Self { + self.secondary_content = Some(text.into()); + self + } + + pub fn copy_text>(mut self, text: S) -> Self { + self.copy_text = Some(text.into()); + self + } + + fn auto_dismiss(mut self, severity: ErrorSeverity, cx: &mut Context) -> Self { + if let Some(delay) = severity.auto_dismiss_delay() { + self.auto_hide = Some(AutoHideState::new(delay, cx)); + } + self + } + + pub fn from_workspace_error(error: E, cx: &mut Context) -> Self { + let primary_message = error.primary_message(); + let severity = error.severity(); + let primary_action = error.primary_action(); + let secondary_action = error.secondary_action(); + + Self::new(primary_message.clone(), cx) + .content_icon(IconName::Warning, Color::Error) + .button_style(ButtonStyle::Outlined) + .copy_text(primary_message) + .show_suppress_button(false) + .when_some(error.secondary_message(), |this, text| { + this.secondary_content(text) + }) + .map(|this| { + let ErrorAction { + label, + icon, + tooltip: _, + handler, + } = primary_action; + + this.primary_message(label) + .when_some(icon, |this, icon| match icon.position { + IconPosition::Start => this.primary_icon(icon.name), + IconPosition::End => this.primary_end_icon(icon.name), + }) + .map(|this| match handler { + ErrorActionHandler::Action(action) => { + this.primary_on_click(move |window, cx| { + window.dispatch_action(action.boxed_clone(), cx); + }) + } + ErrorActionHandler::Dismiss => { + this.primary_on_click(move |_, cx| cx.emit(DismissEvent)) + } + }) + }) + .when_some(secondary_action, |this, action| { + let ErrorAction { + label, + icon, + tooltip: _, + handler, + } = action; + + this.secondary_message(label) + .when_some(icon, |this, icon| match icon.position { + IconPosition::Start => this.secondary_icon(icon.name), + IconPosition::End => this.secondary_end_icon(icon.name), + }) + .map(|this| match handler { + ErrorActionHandler::Action(handler) => { + this.secondary_on_click(move |window, cx| { + window.dispatch_action(handler.boxed_clone(), cx); + }) + } + ErrorActionHandler::Dismiss => { + this.secondary_on_click(move |_, cx| cx.emit(DismissEvent)) + } + }) + }) + .auto_dismiss(severity, cx) + } + + fn on_hover_changed(&mut self, hovering: bool, cx: &mut Context) { + if let Some(auto_hide) = self.auto_hide.as_mut() { + auto_hide.set_hovered(hovering, cx); + } + } + + fn opacity(&self) -> f32 { + self.auto_hide + .as_ref() + .map_or(1.0, |auto_hide| auto_hide.opacity()) + } + + fn needs_animation_frame(&self) -> bool { + self.auto_hide + .as_ref() + .is_some_and(|auto_hide| auto_hide.needs_animation_frame()) + } } impl Render for MessageNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - NotificationFrame::new() - .with_title(self.title.clone()) - .with_content( - div() - .child( - div() - .id("message-notification-content") - .max_h(vh(0.6, window)) - .overflow_y_scroll() - .track_scroll(&self.scroll_handle.clone()) - .child((self.build_content)(window, cx)), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx), - ) - .show_close_button(self.show_close_button) - .show_suppress_button(self.show_suppress_button) - .on_close(cx.listener(|_, suppress, _, cx| { - if *suppress { - cx.emit(SuppressEvent); - } else { - cx.emit(DismissEvent); - } + if self + .auto_hide + .as_mut() + .is_some_and(|auto_hide| auto_hide.refresh_animation()) + { + cx.emit(DismissEvent); + } + + if self.needs_animation_frame() { + window.request_animation_frame(); + } + + let opacity = self.opacity(); + let has_auto_hide = self.auto_hide.is_some(); + let entity = window.current_view(); + let show_suppress_button = self.show_suppress_button; + let show_close_button = self.show_close_button; + let suppress = show_suppress_button && window.modifiers().shift; + let (close_id, close_icon) = if suppress { + ("suppress", IconName::Minimize) + } else { + ("close", IconName::Close) + }; + + let main_content = (self.build_content)(window, cx); + let line_height = window.line_height(); + + let copy_text = self.copy_text.clone(); + let header_actions = h_flex() + .gap_1() + .when_some(copy_text, |el, text| { + el.child( + CopyButton::new("copy-notification-message", text) + .tooltip_label("Copy Message"), + ) + }) + .when(show_close_button, |el| { + el.child( + IconButton::new(close_id, close_icon) + .tooltip(move |_window, cx| { + if suppress { + Tooltip::with_meta( + "Suppress", + Some(&SuppressNotification), + "Click to Close", + cx, + ) + } else if show_suppress_button { + Tooltip::with_meta( + "Close", + Some(&menu::Cancel), + "Shift-click to Suppress", + cx, + ) + } else { + Tooltip::for_action("Close", &menu::Cancel, cx) + } + }) + .on_click(cx.listener(move |_, _, _, cx| { + if suppress { + cx.emit(SuppressEvent); + } else { + cx.emit(DismissEvent); + } + })), + ) + }); + + let has_suffix = self.primary_message.is_some() + || self.secondary_message.is_some() + || self.more_info_message.is_some(); + + let suffix = h_flex() + .gap_1() + .children(self.primary_message.iter().map(|message| { + Button::new(("notification-primary", cx.entity_id()), message.clone()) + .when_some(self.button_style, |button, style| button.style(style)) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + if let Some(on_click) = this.primary_on_click.as_ref() { + (on_click)(window, cx) + }; + this.dismiss(cx) + })) + .when_some(self.primary_icon, |button, icon| { + let element = Icon::new(icon.name) + .size(IconSize::Small) + .color(self.primary_icon_color.unwrap_or(Color::Muted)); + match icon.position { + IconPosition::Start => button.start_icon(element), + IconPosition::End => button.end_icon(element), + } + }) })) - .with_suffix( - h_flex() - .gap_1() - .children(self.primary_message.iter().map(|message| { - let mut button = Button::new(message.clone(), message.clone()) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - if let Some(on_click) = this.primary_on_click.as_ref() { - (on_click)(window, cx) - }; - this.dismiss(cx) - })); - - if let Some(icon) = self.primary_icon { - button = button.start_icon( - Icon::new(icon) - .size(IconSize::Small) - .color(self.primary_icon_color.unwrap_or(Color::Muted)), - ); - } - - button + .children(self.secondary_message.iter().map(|message| { + Button::new(("notification-secondary", cx.entity_id()), message.clone()) + .when_some(self.button_style, |button, style| button.style(style)) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + if let Some(on_click) = this.secondary_on_click.as_ref() { + (on_click)(window, cx) + }; + this.dismiss(cx) })) - .children(self.secondary_message.iter().map(|message| { - let mut button = Button::new(message.clone(), message.clone()) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - if let Some(on_click) = this.secondary_on_click.as_ref() { - (on_click)(window, cx) - }; - this.dismiss(cx) - })); - - if let Some(icon) = self.secondary_icon { - button = button.start_icon( - Icon::new(icon) - .size(IconSize::Small) - .color(self.secondary_icon_color.unwrap_or(Color::Muted)), - ); + .when_some(self.secondary_icon, |button, icon| { + let element = Icon::new(icon.name) + .size(IconSize::Small) + .color(self.secondary_icon_color.unwrap_or(Color::Muted)); + match icon.position { + IconPosition::Start => button.start_icon(element), + IconPosition::End => button.end_icon(element), } + }) + })) + .child( + h_flex().w_full().justify_end().children( + self.more_info_message + .iter() + .zip(self.more_info_url.iter()) + .map(|(message, url)| { + let url = url.clone(); + Button::new(message.clone(), message.clone()) + .label_size(LabelSize::Small) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Indicator) + .color(Color::Muted), + ) + .on_click(cx.listener(move |_, _, _, cx| { + cx.open_url(&url); + })) + }), + ), + ); - button - })) + // Wrap the icon to vertically align with the first line of the primary + // message (mirrors `ui::Callout`'s alignment pattern). The body, secondary + // text and suffix all share a single column to the right of the icon so + // they line up under one another even when an icon is present. + let body = h_flex() + .gap_2() + .items_start() + .when_some( + self.content_icon.zip(self.content_icon_color), + |el, (icon, color)| { + el.child( + h_flex() + .h(line_height) + .justify_center() + .child(Icon::new(icon).size(IconSize::Small).color(color)), + ) + }, + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_2() .child( - h_flex().w_full().justify_end().children( - self.more_info_message - .iter() - .zip(self.more_info_url.iter()) - .map(|(message, url)| { - let url = url.clone(); - Button::new(message.clone(), message.clone()) - .label_size(LabelSize::Small) - .end_icon( - Icon::new(IconName::ArrowUpRight) - .size(IconSize::Indicator) - .color(Color::Muted), - ) - .on_click(cx.listener(move |_, _, _, cx| { - cx.open_url(&url); - })) - }), - ), + v_flex() + .gap_1() + .child( + div() + .child( + div() + .id("message-notification-content") + .max_h(vh(0.6, window)) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle.clone()) + .child(main_content), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx), + ) + .when_some(self.secondary_content.clone(), |el, secondary| { + el.child( + Label::new(secondary) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + .when(has_suffix, |this| this.child(suffix)), + ); + + div() + .id("message-notification-wrapper") + .opacity(opacity) + .child( + v_flex() + .id(("notification-frame", entity)) + .occlude() + .when(has_auto_hide, |this| { + this.on_hover(cx.listener(|this, hovered: &bool, _window, cx| { + this.on_hover_changed(*hovered, cx); + })) + }) + .when(show_close_button, |this| { + this.on_modifiers_changed(move |_, _, cx| cx.notify(entity)) + }) + .p_3() + .gap_2() + .elevation_3(cx) + .child( + h_flex() + .gap_4() + .justify_between() + .items_start() + .child( + v_flex() + .gap_0p5() + .when_some(self.title.clone(), |div, title| { + div.child(Label::new(title)) + }) + .child(div().max_w_96().child(body)), + ) + .child(header_actions), ), ) } } + + impl Component for MessageNotification { + fn scope() -> ComponentScope { + ComponentScope::Notification + } + + fn description() -> &'static str { + "A workspace notification that surfaces a message in a framed container, with an \ + optional title, secondary message, copy button, and primary/secondary action buttons." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + let normal = + cx.new(|cx| MessageNotification::new("A regular informational notification.", cx)); + + let with_title = cx.new(|cx| { + MessageNotification::new("Some informational content for the user.", cx) + .with_title("Notification Title") + }); + + let with_primary_action = cx.new(|cx| { + MessageNotification::new("A new version of Zed is available for download.", cx) + .with_title("Update Available") + .primary_message("Restart Now") + .primary_icon(IconName::ArrowCircle) + }); + + let with_end_icon_action = cx.new(|cx| { + MessageNotification::new("Release notes for this version are available online.", cx) + .with_title("What’s New") + .primary_message("Read Release Notes") + .primary_end_icon(IconName::ArrowUpRight) + }); + + // Mirrors the shape of notifications such as the keymap parse error: a long, + // multi-line message followed by a primary action button. Useful for catching + // regressions where the action row overlaps or is clipped by the content above. + let with_long_content_and_action = cx.new(|cx| { + let long_message = "Errors in user keymap file. In section with context = \ + \"Workspace\":\n\ + • In binding \"ctrl-r\", expected two-element array of [name, input], \ + found [\"editor::Apply\"].\n\ + • In binding \"ctrl-shift-r\", action \"editor::Reload\" is not registered."; + MessageNotification::new(long_message, cx) + .primary_message("Open Keymap File") + .primary_icon(IconName::Settings) + }); + + struct PreviewError; + impl WorkspaceError for PreviewError { + fn primary_message(&self) -> SharedString { + "Something went wrong while loading your project.".into() + } + + fn primary_action(&self) -> ErrorAction { + ErrorAction::dismiss() + } + + fn secondary_message(&self) -> Option { + Some("Check your network connection and try again.".into()) + } + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Error + } + } + let error_state = + cx.new(|cx| MessageNotification::from_workspace_error(PreviewError, cx)); + + let close_only = cx + .new(|cx| MessageNotification::new("Default header with just a close button.", cx)); + + let copy_and_close = cx.new(|cx| { + let msg: SharedString = "This message can be copied to the clipboard.".into(); + MessageNotification::new(msg.clone(), cx).copy_text(msg) + }); + + let no_close = cx.new(|cx| { + MessageNotification::new("This notification can't be closed manually.", cx) + .show_close_button(false) + }); + + // --- Workspace errors --- + // These showcase common shapes of [`WorkspaceError`]. They are intentionally + // [`ErrorSeverity::Critical`] so they never auto-dismiss in the preview, which + // would otherwise make them disappear mid-inspection. + + struct BasicError; + impl WorkspaceError for BasicError { + fn primary_message(&self) -> SharedString { + "Failed to save the file.".into() + } + fn primary_action(&self) -> ErrorAction { + ErrorAction::dismiss() + } + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } + } + + struct LanguageServerError; + impl WorkspaceError for LanguageServerError { + fn primary_message(&self) -> SharedString { + "Couldn't reach the language server.".into() + } + fn secondary_message(&self) -> Option { + Some( + "Make sure the server is installed and your network connection is working." + .into(), + ) + } + fn primary_action(&self) -> ErrorAction { + ErrorAction::dismiss() + } + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } + } + + // Mirrors the shape of [`super::super::PortalError`]: a critical error with a + // documentation link as its primary action. + struct PortalSetupError; + impl WorkspaceError for PortalSetupError { + fn primary_message(&self) -> SharedString { + "Linux desktop portal initialization failed.".into() + } + fn secondary_message(&self) -> Option { + Some("Zed needs an xdg-desktop-portal implementation to open files.".into()) + } + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } + fn primary_action(&self) -> ErrorAction { + ErrorAction::link( + "See Docs", + "https://zed.dev/docs/linux#i-cant-open-any-files", + ) + } + } + + // Has both a primary action (link) and a secondary action (dismiss), so the + // preview exercises the full button row. + struct UpdateRequiredError; + impl WorkspaceError for UpdateRequiredError { + fn primary_message(&self) -> SharedString { + "An update is required to continue using Zed AI.".into() + } + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } + fn primary_action(&self) -> ErrorAction { + ErrorAction::link("Update Zed", "https://zed.dev/releases") + } + fn secondary_action(&self) -> Option { + Some(ErrorAction::dismiss()) + } + } + + let basic_error = + cx.new(|cx| MessageNotification::from_workspace_error(BasicError, cx)); + let detailed_error = + cx.new(|cx| MessageNotification::from_workspace_error(LanguageServerError, cx)); + let docs_error = + cx.new(|cx| MessageNotification::from_workspace_error(PortalSetupError, cx)); + let update_error = + cx.new(|cx| MessageNotification::from_workspace_error(UpdateRequiredError, cx)); + + let container = || div().w(px(440.)); + + v_flex() + .gap_6() + .p_4() + .children(vec![ + example_group_with_title( + "States", + vec![ + single_example("Normal", container().child(normal).into_any_element()), + single_example( + "With Title", + container().child(with_title).into_any_element(), + ), + single_example( + "With Primary Action (start icon)", + container().child(with_primary_action).into_any_element(), + ), + single_example( + "With Primary Action (end icon)", + container().child(with_end_icon_action).into_any_element(), + ), + single_example( + "Long Content + Primary Action", + container() + .child(with_long_content_and_action) + .into_any_element(), + ), + single_example( + "Error", + container().child(error_state).into_any_element(), + ), + ], + ), + example_group_with_title( + "Header Actions (top right)", + vec![ + single_example( + "Close Only", + container().child(close_only).into_any_element(), + ), + single_example( + "Copy + Close", + container().child(copy_and_close).into_any_element(), + ), + single_example( + "No Close", + container().child(no_close).into_any_element(), + ), + ], + ), + example_group_with_title( + "Workspace Errors", + vec![ + single_example( + "Basic", + container().child(basic_error).into_any_element(), + ), + single_example( + "With Secondary Message", + container().child(detailed_error).into_any_element(), + ), + single_example( + "With Documentation Link", + container().child(docs_error).into_any_element(), + ), + single_example( + "With Primary + Secondary Action", + container().child(update_error).into_any_element(), + ), + ], + ), + ]) + .into_any_element() + } + } } static GLOBAL_APP_NOTIFICATIONS: LazyLock> = LazyLock::new(|| { @@ -1144,7 +1550,7 @@ where Ok(value) => Some(value), Err(err) => { log::error!("Showing error notification in workspace: {err:?}"); - workspace.show_error(&err, cx); + workspace.show_error(format!("Error: {err}"), cx); None } } @@ -1159,8 +1565,9 @@ where Ok(value) => Some(value), Err(err) => { log::error!("{err:?}"); + let message = format!("Error: {err}"); workspace - .update(cx, |workspace, cx| workspace.show_error(&err, cx)) + .update(cx, |workspace, cx| workspace.show_error(message, cx)) .ok(); None } @@ -1171,13 +1578,15 @@ where match self { Ok(value) => Some(value), Err(err) => { - let message: SharedString = format!("Error: {err}").into(); + let message = format!("Error: {err}"); log::error!("Showing error notification in app: {message}"); show_app_notification(workspace_error_notification_id(), cx, { move |cx| { cx.new({ let message = message.clone(); - move |cx| ErrorMessagePrompt::new(message, cx) + move |cx| { + simple_message_notification::MessageNotification::from_workspace_error(message, cx) + } }) } }); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 02d0a30135..a3d56abf19 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -4057,10 +4057,7 @@ impl Pane { .workspace .update(cx, |workspace, cx| { if workspace.project().read(cx).is_via_collab() { - workspace.show_error( - &anyhow::anyhow!("Cannot drop files on a remote project"), - cx, - ); + workspace.show_error("Cannot drop files on a remote project", cx); true } else { false @@ -4116,7 +4113,7 @@ impl Pane { _ = workspace.update_in(cx, |workspace, window, cx| { for item in opened_items.into_iter().flatten() { if let Err(e) = item { - workspace.show_error(&e, cx); + workspace.show_error(format!("Error: {e}"), cx); } } if to_pane.read(cx).items_len() == 0 { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index a1b588988b..be73f25e61 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -26,9 +26,9 @@ mod theme_preview; mod toast_layer; mod toolbar; pub mod welcome; +pub mod workspace_error; mod workspace_settings; -pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MoveProjectToNewWindow, @@ -577,16 +577,16 @@ pub enum CloseIntent { #[derive(Clone)] pub struct Toast { id: NotificationId, - msg: Cow<'static, str>, + message: Cow<'static, str>, autohide: bool, on_click: Option<(Cow<'static, str>, Arc)>, } impl Toast { - pub fn new>>(id: NotificationId, msg: I) -> Self { + pub fn new>>(id: NotificationId, message: I) -> Self { Toast { id, - msg: msg.into(), + message: message.into(), on_click: None, autohide: false, } @@ -610,7 +610,7 @@ impl Toast { impl PartialEq for Toast { fn eq(&self, other: &Self) -> bool { self.id == other.id - && self.msg == other.msg + && self.message == other.message && self.on_click.is_some() == other.on_click.is_some() } } @@ -3012,7 +3012,8 @@ impl Workspace { } Err(err) => { let rx = workspace.update_in(cx, |workspace, window, cx| { - workspace.show_portal_error(err.to_string(), cx); + workspace + .show_error(workspace_error::PortalError::new(err.to_string()), cx); let prompt = workspace.on_prompt_for_open_path.take().unwrap(); let rx = prompt(workspace, lister, window, cx); workspace.on_prompt_for_open_path = Some(prompt); @@ -3068,7 +3069,8 @@ impl Workspace { Ok(path) => path, Err(err) => { let rx = workspace.update_in(cx, |workspace, window, cx| { - workspace.show_portal_error(err.to_string(), cx); + workspace + .show_error(workspace_error::PortalError::new(err.to_string()), cx); let prompt = workspace.on_prompt_for_new_path.take().unwrap(); let rx = prompt(workspace, lister, suggested_name, window, cx); @@ -3851,10 +3853,7 @@ impl Workspace { ) { let project = self.project.read(cx); if project.is_via_collab() { - self.show_error( - &anyhow!("You cannot add folders to someone else's project"), - cx, - ); + self.show_error("You cannot add folders to someone else's project", cx); return; } let paths = self.prompt_for_open_path( @@ -10133,7 +10132,7 @@ pub fn open_paths( workspace.update(cx, |workspace, cx| { for item in open_task.iter().flatten() { if let Err(e) = item { - workspace.show_error(&e, cx); + workspace.show_error(format!("Error: {e}"), cx); } } }); @@ -10468,10 +10467,10 @@ async fn open_remote_project_inner( for error in project_path_errors { if error.error_code() == proto::ErrorCode::DevServerProjectPathDoesNotExist { if let Some(path) = error.error_tag("path") { - workspace.show_error(&anyhow!("'{path}' does not exist"), cx) + workspace.show_error(format!("'{path}' does not exist"), cx) } } else { - workspace.show_error(&error, cx) + workspace.show_error(format!("{error}"), cx) } } }); diff --git a/crates/workspace/src/workspace_error.rs b/crates/workspace/src/workspace_error.rs new file mode 100644 index 0000000000..e6293c43a8 --- /dev/null +++ b/crates/workspace/src/workspace_error.rs @@ -0,0 +1,204 @@ +use std::{sync::Arc, time::Duration}; + +use gpui::{Action, SharedString}; +use ui::{IconName, IconPosition}; +use zed_actions::OpenBrowser; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorSeverity { + Critical, + Error, + Warning, +} + +impl ErrorSeverity { + pub fn auto_dismiss_delay(&self) -> Option { + match self { + ErrorSeverity::Critical => None, + ErrorSeverity::Error => Some(Duration::from_secs(20)), + ErrorSeverity::Warning => Some(Duration::from_secs(10)), + } + } +} + +/// The behavior triggered when the user invokes an [`ErrorAction`]. +pub enum ErrorActionHandler { + /// Run the provided callback when the action is invoked. + /// The notification is still dismissed afterwards by the button's click handler. + Action(Box), + /// Dismiss the notification without running any extra logic. + Dismiss, +} + +/// An icon to display on an action button, with its position relative to the label. +/// +/// Bundled together so the position is only carried around when an icon is actually set +/// — there's no meaningful "position" without an icon to position. +#[derive(Debug, Clone, Copy)] +pub struct ActionIcon { + pub name: IconName, + pub position: IconPosition, +} + +impl ActionIcon { + /// Show `name` at the start (left) of the action button label. + pub fn start(name: IconName) -> Self { + Self { + name, + position: IconPosition::Start, + } + } + + /// Show `name` at the end (right) of the action button label. + pub fn end(name: IconName) -> Self { + Self { + name, + position: IconPosition::End, + } + } +} + +pub struct ErrorAction { + pub label: SharedString, + pub icon: Option, + pub tooltip: Option, + pub handler: ErrorActionHandler, +} + +impl ErrorAction { + pub fn new(label: impl Into, handler: A) -> Self { + Self { + label: label.into(), + icon: None, + tooltip: None, + handler: ErrorActionHandler::Action(Box::new(handler)), + } + } + + /// Creates a dismiss-only action labelled "Dismiss". + /// + /// Useful as a sensible default for [`WorkspaceError::primary_action`] when the error has no + /// recovery affordance beyond closing the notification. + pub fn dismiss() -> Self { + Self { + label: "Dismiss".into(), + icon: None, + tooltip: None, + handler: ErrorActionHandler::Dismiss, + } + } + + /// Show `icon` at the start (left) of the action button label. + pub fn with_icon(mut self, icon: IconName) -> Self { + self.icon = Some(ActionIcon::start(icon)); + self + } + + /// Show `icon` at the end (right) of the action button label. + /// + /// Useful for actions that navigate the user elsewhere — for example the trailing + /// `⇗` produced by [`Self::link`]. + pub fn with_end_icon(mut self, icon: IconName) -> Self { + self.icon = Some(ActionIcon::end(icon)); + self + } + + pub fn with_tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(tooltip.into()); + self + } + + pub fn link(label: impl Into, url: impl Into>) -> Self { + Self::new(label, OpenBrowser { url: url.into() }).with_end_icon(IconName::ArrowUpRight) + } +} + +pub trait WorkspaceError { + fn primary_message(&self) -> SharedString; + + fn secondary_message(&self) -> Option { + None + } + + /// The primary action shown in the error notification. + /// + /// If in doubt, use [`ErrorAction::dismiss`]. + fn primary_action(&self) -> ErrorAction; + + fn secondary_action(&self) -> Option { + None + } + + fn severity(&self) -> ErrorSeverity; +} + +impl WorkspaceError for &'static str { + fn primary_message(&self) -> SharedString { + self.to_string().into() + } + + fn primary_action(&self) -> ErrorAction { + ErrorAction::dismiss() + } + + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } +} + +impl WorkspaceError for String { + fn primary_message(&self) -> SharedString { + self.clone().into() + } + + fn primary_action(&self) -> ErrorAction { + ErrorAction::dismiss() + } + + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } +} + +impl WorkspaceError for anyhow::Error { + fn primary_message(&self) -> SharedString { + format!("{self}").into() + } + + fn primary_action(&self) -> ErrorAction { + ErrorAction::dismiss() + } + + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } +} + +pub struct PortalError { + message: String, +} + +impl PortalError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl WorkspaceError for PortalError { + fn primary_message(&self) -> SharedString { + self.message.clone().into() + } + + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } + + fn primary_action(&self) -> ErrorAction { + ErrorAction::link( + "See docs", + "https://zed.dev/docs/linux#i-cant-open-any-files", + ) + } +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 5564498614..d56668b479 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -71,6 +71,8 @@ use settings::{ update_settings_file, }; use sidebar::Sidebar; +#[cfg(debug_assertions)] +use workspace::workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError}; use std::{ borrow::Cow, @@ -156,6 +158,15 @@ actions!( ] ); +#[cfg(debug_assertions)] +actions!( + dev, + [ + /// Show an error on the workspace level. + ShowWorkspaceError + ] +); + pub fn init(cx: &mut App) { #[cfg(target_os = "macos")] cx.on_action(|_: &Hide, cx| cx.hide()); @@ -891,7 +902,7 @@ fn register_actions( }) .register_action(|_, action: &OpenZedUrl, _, cx| { OpenListener::global(cx).open(RawOpenRequest { - urls: vec![action.url.clone()], + urls: vec![String::from(&*action.url)], ..Default::default() }) }) @@ -909,7 +920,7 @@ fn register_actions( } Err(e) => { workspace.show_error( - &anyhow::anyhow!( + format!( "Opening this URL in a browser failed because the URL is invalid: {}\n\nError was: {e}", action.url ), @@ -1293,6 +1304,43 @@ fn register_actions( } workspace.register_action(sidebar::dump_workspace_info); + + #[cfg(debug_assertions)] + workspace.register_action(|workspace, _: &ShowWorkspaceError, _, cx| { + struct DebugError; + struct SecondDebugError; + + impl WorkspaceError for DebugError { + fn primary_message(&self) -> SharedString { + SharedString::new_static("This is an error.") + } + + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Warning + } + + fn primary_action(&self) -> ErrorAction { + ErrorAction::dismiss() + } + } + + impl WorkspaceError for SecondDebugError { + fn primary_message(&self) -> SharedString { + SharedString::new_static("This is some error to ignore.") + } + + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Error + } + + fn primary_action(&self) -> ErrorAction { + ErrorAction::dismiss() + } + } + + workspace.show_error(DebugError, cx); + workspace.show_error(SecondDebugError, cx); + }); } fn initialize_pane( diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 9ac6f36b3a..b20caaa532 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -1,7 +1,7 @@ use gpui::{Action, actions}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; // If the zed binary doesn't use anything in this crate, it will be optimized away // and the actions won't initialize. So we just provide an empty initialization function @@ -17,7 +17,7 @@ pub fn init() {} #[action(namespace = zed)] #[serde(deny_unknown_fields)] pub struct OpenBrowser { - pub url: String, + pub url: Arc, } /// Opens a zed:// URL within the application. @@ -25,7 +25,7 @@ pub struct OpenBrowser { #[action(namespace = zed)] #[serde(deny_unknown_fields)] pub struct OpenZedUrl { - pub url: String, + pub url: Arc, } /// Opens the keymap to either add a keybinding or change an existing one