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