Adds a way to dismiss workspace notifications (#30015)

Kirill Bulatov created

Closes https://github.com/zed-industries/zed/issues/10140

* On `menu::Cancel` action (`ESC`), close notifications, one by one, if
`Workspace` gets to handle this action.
More specific, focused items contexts (e.g. `Editor`) take priority.

* Allows to temporarily suppress notifications of this kind either by
clicking a corresponding button in the UI, or using
`workspace::SuppressNotification` action.

This might not work well out of the box for all notifications and might
require further improvement.


https://github.com/user-attachments/assets/0ea49ee6-cd21-464f-ba74-fc40f7a8dedf


Release Notes:

- Added a way to dismiss workspace notifications

Change summary

Cargo.lock                                 |  1 
crates/collab_ui/src/notification_panel.rs | 10 +++
crates/workspace/Cargo.toml                |  1 
crates/workspace/src/notifications.rs      | 72 ++++++++++++++++++++---
crates/workspace/src/workspace.rs          | 25 +++++++
5 files changed, 96 insertions(+), 13 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -18158,6 +18158,7 @@ dependencies = [
  "itertools 0.14.0",
  "language",
  "log",
+ "menu",
  "node_runtime",
  "parking_lot",
  "postage",

crates/collab_ui/src/notification_panel.rs 🔗

@@ -22,7 +22,9 @@ use ui::{
     Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip, h_flex, prelude::*, v_flex,
 };
 use util::{ResultExt, TryFutureExt};
-use workspace::notifications::{Notification as WorkspaceNotification, NotificationId};
+use workspace::notifications::{
+    Notification as WorkspaceNotification, NotificationId, SuppressEvent,
+};
 use workspace::{
     Workspace,
     dock::{DockPosition, Panel, PanelEvent},
@@ -823,6 +825,11 @@ impl Render for NotificationToast {
                 IconButton::new("close", IconName::Close)
                     .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
             )
+            .child(
+                IconButton::new("suppress", IconName::XCircle)
+                    .tooltip(Tooltip::text("Do not show until restart"))
+                    .on_click(cx.listener(|_, _, _, cx| cx.emit(SuppressEvent))),
+            )
             .on_click(cx.listener(|this, _, window, cx| {
                 this.focus_notification_panel(window, cx);
                 cx.emit(DismissEvent);
@@ -831,3 +838,4 @@ impl Render for NotificationToast {
 }
 
 impl EventEmitter<DismissEvent> for NotificationToast {}
+impl EventEmitter<SuppressEvent> for NotificationToast {}

crates/workspace/Cargo.toml 🔗

@@ -43,6 +43,7 @@ http_client.workspace = true
 itertools.workspace = true
 language.workspace = true
 log.workspace = true
+menu.workspace = true
 node_runtime.workspace = true
 parking_lot.workspace = true
 postage.workspace = true

crates/workspace/src/notifications.rs 🔗

@@ -29,7 +29,7 @@ impl std::ops::DerefMut for Notifications {
     }
 }
 
-#[derive(Debug, PartialEq, Clone)]
+#[derive(Debug, Eq, PartialEq, Clone, Hash)]
 pub enum NotificationId {
     Unique(TypeId),
     Composite(TypeId, ElementId),
@@ -54,7 +54,12 @@ impl NotificationId {
     }
 }
 
-pub trait Notification: EventEmitter<DismissEvent> + Focusable + Render {}
+pub trait Notification:
+    EventEmitter<DismissEvent> + EventEmitter<SuppressEvent> + Focusable + Render
+{
+}
+
+pub struct SuppressEvent;
 
 impl Workspace {
     #[cfg(any(test, feature = "test-support"))]
@@ -81,6 +86,13 @@ impl Workspace {
                 }
             })
             .detach();
+            cx.subscribe(&notification, {
+                let id = id.clone();
+                move |workspace: &mut Workspace, _, _: &SuppressEvent, cx| {
+                    workspace.suppress_notification(&id, cx);
+                }
+            })
+            .detach();
             notification.into()
         });
     }
@@ -96,6 +108,9 @@ impl Workspace {
         cx: &mut Context<Self>,
         build_notification: impl FnOnce(&mut Context<Self>) -> AnyView,
     ) {
+        if self.suppressed_notifications.contains(id) {
+            return;
+        }
         self.dismiss_notification(id, cx);
         self.notifications
             .push((id.clone(), build_notification(cx)));
@@ -172,6 +187,11 @@ impl Workspace {
         cx.notify();
     }
 
+    pub fn suppress_notification(&mut self, id: &NotificationId, cx: &mut Context<Self>) {
+        self.dismiss_notification(id, cx);
+        self.suppressed_notifications.insert(id.clone());
+    }
+
     pub fn show_initial_notifications(&mut self, cx: &mut Context<Self>) {
         // Allow absence of the global so that tests don't need to initialize it.
         let app_notifications = GLOBAL_APP_NOTIFICATIONS
@@ -268,6 +288,14 @@ impl Render for LanguageServerPrompt {
                             )
                             .child(
                                 h_flex()
+                                    .gap_2()
+                                    .child(
+                                        IconButton::new("suppress", IconName::XCircle)
+                                            .tooltip(Tooltip::text("Do not show until restart"))
+                                            .on_click(
+                                                cx.listener(|_, _, _, cx| cx.emit(SuppressEvent)),
+                                            ),
+                                    )
                                     .child(
                                         IconButton::new("copy", IconName::Copy)
                                             .on_click({
@@ -305,6 +333,7 @@ impl Render for LanguageServerPrompt {
 }
 
 impl EventEmitter<DismissEvent> for LanguageServerPrompt {}
+impl EventEmitter<SuppressEvent> for LanguageServerPrompt {}
 
 fn workspace_error_notification_id() -> NotificationId {
     struct WorkspaceErrorNotification;
@@ -401,6 +430,7 @@ impl Focusable for ErrorMessagePrompt {
 }
 
 impl EventEmitter<DismissEvent> for ErrorMessagePrompt {}
+impl EventEmitter<SuppressEvent> for ErrorMessagePrompt {}
 
 impl Notification for ErrorMessagePrompt {}
 
@@ -411,9 +441,9 @@ pub mod simple_message_notification {
         AnyElement, DismissEvent, EventEmitter, FocusHandle, Focusable, ParentElement, Render,
         SharedString, Styled, div,
     };
-    use ui::prelude::*;
+    use ui::{Tooltip, prelude::*};
 
-    use super::Notification;
+    use super::{Notification, SuppressEvent};
 
     pub struct MessageNotification {
         focus_handle: FocusHandle,
@@ -429,6 +459,7 @@ pub mod simple_message_notification {
         more_info_message: Option<SharedString>,
         more_info_url: Option<Arc<str>>,
         show_close_button: bool,
+        show_suppress_button: bool,
         title: Option<SharedString>,
     }
 
@@ -439,6 +470,7 @@ pub mod simple_message_notification {
     }
 
     impl EventEmitter<DismissEvent> for MessageNotification {}
+    impl EventEmitter<SuppressEvent> for MessageNotification {}
 
     impl Notification for MessageNotification {}
 
@@ -470,6 +502,7 @@ pub mod simple_message_notification {
                 more_info_message: None,
                 more_info_url: None,
                 show_close_button: true,
+                show_suppress_button: true,
                 title: None,
                 focus_handle: cx.focus_handle(),
             }
@@ -568,6 +601,11 @@ pub mod simple_message_notification {
             self
         }
 
+        pub fn show_suppress_button(mut self, show: bool) -> Self {
+            self.show_suppress_button = show;
+            self
+        }
+
         pub fn with_title<S>(mut self, title: S) -> Self
         where
             S: Into<SharedString>,
@@ -597,12 +635,26 @@ pub mod simple_message_notification {
                                 })
                                 .child(div().max_w_96().child((self.build_content)(window, cx))),
                         )
-                        .when(self.show_close_button, |this| {
-                            this.child(
-                                IconButton::new("close", IconName::Close)
-                                    .on_click(cx.listener(|this, _, _, cx| this.dismiss(cx))),
-                            )
-                        }),
+                        .child(
+                            h_flex()
+                                .gap_2()
+                                .when(self.show_suppress_button, |this| {
+                                    this.child(
+                                        IconButton::new("suppress", IconName::XCircle)
+                                            .tooltip(Tooltip::text("Do not show until restart"))
+                                            .on_click(cx.listener(|_, _, _, cx| {
+                                                cx.emit(SuppressEvent);
+                                            })),
+                                    )
+                                })
+                                .when(self.show_close_button, |this| {
+                                    this.child(
+                                        IconButton::new("close", IconName::Close).on_click(
+                                            cx.listener(|this, _, _, cx| this.dismiss(cx)),
+                                        ),
+                                    )
+                                }),
+                        ),
                 )
                 .child(
                     h_flex()

crates/workspace/src/workspace.rs 🔗

@@ -52,7 +52,8 @@ use language::{Buffer, LanguageRegistry, Rope};
 pub use modal_layer::*;
 use node_runtime::NodeRuntime;
 use notifications::{
-    DetachAndPromptErr, Notifications, simple_message_notification::MessageNotification,
+    DetachAndPromptErr, Notifications, dismiss_app_notification,
+    simple_message_notification::MessageNotification,
 };
 pub use pane::*;
 pub use pane_group::*;
@@ -179,6 +180,7 @@ actions!(
         SaveAs,
         SaveWithoutFormat,
         ShutdownDebugAdapters,
+        SuppressNotification,
         ToggleBottomDock,
         ToggleCenteredLayout,
         ToggleLeftDock,
@@ -921,6 +923,7 @@ pub struct Workspace {
     toast_layer: Entity<ToastLayer>,
     titlebar_item: Option<AnyView>,
     notifications: Notifications,
+    suppressed_notifications: HashSet<NotificationId>,
     project: Entity<Project>,
     follower_states: HashMap<CollaboratorId, FollowerState>,
     last_leaders_by_pane: HashMap<WeakEntity<Pane>, CollaboratorId>,
@@ -1245,7 +1248,8 @@ impl Workspace {
             modal_layer,
             toast_layer,
             titlebar_item: None,
-            notifications: Default::default(),
+            notifications: Notifications::default(),
+            suppressed_notifications: HashSet::default(),
             left_dock,
             bottom_dock,
             bottom_dock_layout,
@@ -5301,12 +5305,20 @@ impl Workspace {
                     workspace.clear_all_notifications(cx);
                 },
             ))
+            .on_action(cx.listener(
+                |workspace: &mut Workspace, _: &SuppressNotification, _, cx| {
+                    if let Some((notification_id, _)) = workspace.notifications.pop() {
+                        workspace.suppress_notification(&notification_id, cx);
+                    }
+                },
+            ))
             .on_action(cx.listener(
                 |workspace: &mut Workspace, _: &ReopenClosedItem, window, cx| {
                     workspace.reopen_closed_item(window, cx).detach();
                 },
             ))
             .on_action(cx.listener(Workspace::toggle_centered_layout))
+            .on_action(cx.listener(Workspace::cancel))
     }
 
     #[cfg(any(test, feature = "test-support"))]
@@ -5477,6 +5489,15 @@ impl Workspace {
             .update(cx, |_, window, _| window.activate_window())
             .ok();
     }
+
+    pub fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+        if let Some((notification_id, _)) = self.notifications.pop() {
+            dismiss_app_notification(&notification_id, cx);
+            return;
+        }
+
+        cx.propagate();
+    }
 }
 
 fn leader_border_for_pane(