Skip to repository content1904 lines · 73.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T06:05:42.165Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
notifications.rs
1use crate::{
2 MultiWorkspace, SuppressNotification, Toast, Workspace, workspace_error::WorkspaceError,
3};
4use anyhow::Context as _;
5use gpui::{
6 AnyEntity, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, ClickEvent, Context,
7 DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle,
8 Task, TextStyleRefinement, UnderlineStyle, WeakEntity,
9};
10use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
11use parking_lot::Mutex;
12use project::project_settings::ProjectSettings;
13use settings::Settings;
14use theme_settings::ThemeSettings;
15
16use std::ops::Deref;
17use std::sync::{Arc, LazyLock};
18use std::{any::TypeId, time::Duration};
19use ui::{CopyButton, Tooltip, prelude::*};
20use util::ResultExt;
21
22#[derive(Default)]
23pub struct Notifications {
24 notifications: Vec<(NotificationId, AnyView)>,
25}
26
27impl Deref for Notifications {
28 type Target = Vec<(NotificationId, AnyView)>;
29
30 fn deref(&self) -> &Self::Target {
31 &self.notifications
32 }
33}
34
35impl std::ops::DerefMut for Notifications {
36 fn deref_mut(&mut self) -> &mut Self::Target {
37 &mut self.notifications
38 }
39}
40
41#[derive(Debug, Eq, PartialEq, Clone, Hash)]
42pub enum NotificationId {
43 Unique(TypeId),
44 Composite(TypeId, ElementId),
45 Named(SharedString),
46}
47
48impl NotificationId {
49 /// Returns a unique [`NotificationId`] for the given type.
50 pub const fn unique<T: 'static>() -> Self {
51 Self::Unique(TypeId::of::<T>())
52 }
53
54 /// Returns a [`NotificationId`] for the given type that is also identified
55 /// by the provided ID.
56 pub fn composite<T: 'static>(id: impl Into<ElementId>) -> Self {
57 Self::Composite(TypeId::of::<T>(), id.into())
58 }
59
60 /// Builds a `NotificationId` out of the given string.
61 pub fn named(id: SharedString) -> Self {
62 Self::Named(id)
63 }
64}
65
66pub trait Notification:
67 EventEmitter<DismissEvent> + EventEmitter<SuppressEvent> + Focusable + Render
68{
69}
70
71pub struct SuppressEvent;
72
73impl Workspace {
74 #[cfg(any(test, feature = "test-support"))]
75 pub fn notification_ids(&self) -> Vec<NotificationId> {
76 self.notifications
77 .iter()
78 .map(|(id, _)| id)
79 .cloned()
80 .collect()
81 }
82
83 pub fn show_notification<V: Notification>(
84 &mut self,
85 id: NotificationId,
86 cx: &mut Context<Self>,
87 build_notification: impl FnOnce(&mut Context<Self>) -> Entity<V>,
88 ) {
89 self.show_notification_without_handling_dismiss_events(&id, cx, |cx| {
90 let notification = build_notification(cx);
91 cx.subscribe(¬ification, {
92 let id = id.clone();
93 move |this, _, _: &DismissEvent, cx| {
94 this.dismiss_notification(&id, cx);
95 }
96 })
97 .detach();
98 cx.subscribe(¬ification, {
99 let id = id.clone();
100 move |workspace: &mut Workspace, _, _: &SuppressEvent, cx| {
101 workspace.suppress_notification(&id, cx);
102 }
103 })
104 .detach();
105
106 if let Ok(prompt) =
107 AnyEntity::from(notification.clone()).downcast::<LanguageServerPrompt>()
108 {
109 let is_prompt_without_actions = prompt
110 .read(cx)
111 .request
112 .as_ref()
113 .is_some_and(|request| request.actions.is_empty());
114
115 let dismiss_timeout_ms = ProjectSettings::get_global(cx)
116 .global_lsp_settings
117 .notifications
118 .dismiss_timeout_ms;
119
120 if is_prompt_without_actions {
121 if let Some(dismiss_duration_ms) = dismiss_timeout_ms.filter(|&ms| ms > 0) {
122 let task = cx.spawn({
123 let id = id.clone();
124 async move |this, cx| {
125 cx.background_executor()
126 .timer(Duration::from_millis(dismiss_duration_ms))
127 .await;
128 let _ = this.update(cx, |workspace, cx| {
129 workspace.dismiss_notification(&id, cx);
130 });
131 }
132 });
133 prompt.update(cx, |prompt, _| {
134 prompt.dismiss_task = Some(task);
135 });
136 }
137 }
138 }
139 notification.into()
140 });
141 }
142
143 /// Shows a notification in this workspace's window. Caller must handle dismiss.
144 ///
145 /// This exists so that the `build_notification` closures stored for app notifications can
146 /// return `AnyView`. Subscribing to events from an `AnyView` is not supported, so instead that
147 /// responsibility is pushed to the caller where the `V` type is known.
148 pub(crate) fn show_notification_without_handling_dismiss_events(
149 &mut self,
150 id: &NotificationId,
151 cx: &mut Context<Self>,
152 build_notification: impl FnOnce(&mut Context<Self>) -> AnyView,
153 ) {
154 if self.suppressed_notifications.contains(id) {
155 return;
156 }
157 self.dismiss_notification(id, cx);
158 self.notifications
159 .push((id.clone(), build_notification(cx)));
160 cx.notify();
161 }
162
163 pub fn show_error<E: WorkspaceError + 'static>(&mut self, err: E, cx: &mut Context<Self>) {
164 self.show_notification(NotificationId::unique::<E>(), cx, |cx| {
165 cx.new(|cx| {
166 simple_message_notification::MessageNotification::from_workspace_error(err, cx)
167 })
168 });
169 }
170
171 pub fn dismiss_notification(&mut self, id: &NotificationId, cx: &mut Context<Self>) {
172 self.notifications.retain(|(existing_id, _)| {
173 if existing_id == id {
174 cx.notify();
175 false
176 } else {
177 true
178 }
179 });
180 }
181
182 pub fn show_toast(&mut self, toast: Toast, cx: &mut Context<Self>) {
183 self.dismiss_notification(&toast.id, cx);
184 self.show_notification(toast.id.clone(), cx, |cx| {
185 cx.new(|cx| {
186 simple_message_notification::MessageNotification::new(toast.message, cx).when_some(
187 toast.on_click,
188 |this, (click_msg, on_click)| {
189 this.primary_message(click_msg)
190 .primary_on_click(move |window, cx| on_click(window, cx))
191 },
192 )
193 })
194 });
195
196 if toast.autohide {
197 cx.spawn(async move |workspace, cx| {
198 cx.background_executor()
199 .timer(Duration::from_millis(5000))
200 .await;
201 workspace
202 .update(cx, |workspace, cx| workspace.dismiss_toast(&toast.id, cx))
203 .ok();
204 })
205 .detach();
206 }
207 }
208
209 pub fn dismiss_toast(&mut self, id: &NotificationId, cx: &mut Context<Self>) {
210 self.dismiss_notification(id, cx);
211 }
212
213 pub fn clear_all_notifications(&mut self, cx: &mut Context<Self>) {
214 self.notifications.clear();
215 cx.notify();
216 }
217
218 /// Hide all notifications matching the given ID
219 pub fn suppress_notification(&mut self, id: &NotificationId, cx: &mut Context<Self>) {
220 self.dismiss_notification(id, cx);
221 self.suppressed_notifications.insert(id.clone());
222 }
223
224 pub fn is_notification_suppressed(&self, notification_id: NotificationId) -> bool {
225 self.suppressed_notifications.contains(¬ification_id)
226 }
227
228 pub fn unsuppress(&mut self, notification_id: NotificationId) {
229 self.suppressed_notifications.remove(¬ification_id);
230 }
231
232 pub fn show_initial_notifications(&mut self, cx: &mut Context<Self>) {
233 // Allow absence of the global so that tests don't need to initialize it.
234 let app_notifications = GLOBAL_APP_NOTIFICATIONS
235 .lock()
236 .app_notifications
237 .iter()
238 .cloned()
239 .collect::<Vec<_>>();
240 for (id, build_notification) in app_notifications {
241 self.show_notification_without_handling_dismiss_events(&id, cx, |cx| {
242 build_notification(cx)
243 });
244 }
245 }
246}
247
248pub struct LanguageServerPrompt {
249 focus_handle: FocusHandle,
250 request: Option<project::LanguageServerPromptRequest>,
251 scroll_handle: ScrollHandle,
252 markdown: Entity<Markdown>,
253 dismiss_task: Option<Task<()>>,
254}
255
256impl Focusable for LanguageServerPrompt {
257 fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
258 self.focus_handle.clone()
259 }
260}
261
262impl Notification for LanguageServerPrompt {}
263
264impl LanguageServerPrompt {
265 pub fn new(request: project::LanguageServerPromptRequest, cx: &mut App) -> Self {
266 let markdown = cx.new(|cx| Markdown::new(request.message.clone().into(), None, None, cx));
267
268 Self {
269 focus_handle: cx.focus_handle(),
270 request: Some(request),
271 scroll_handle: ScrollHandle::new(),
272 markdown,
273 dismiss_task: None,
274 }
275 }
276
277 async fn select_option(this: Entity<Self>, ix: usize, cx: &mut AsyncWindowContext) {
278 util::maybe!(async move {
279 let potential_future = this.update(cx, |this, _| {
280 this.request.take().map(|request| request.respond(ix))
281 });
282
283 potential_future
284 .context("Response already sent")?
285 .await
286 .context("Stream already closed")?;
287
288 this.update(cx, |this, cx| {
289 this.dismiss_notification(cx);
290 });
291
292 anyhow::Ok(())
293 })
294 .await
295 .log_err();
296 }
297
298 fn dismiss_notification(&mut self, cx: &mut Context<Self>) {
299 self.dismiss_task = None;
300 cx.emit(DismissEvent);
301 }
302}
303
304impl Render for LanguageServerPrompt {
305 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
306 let Some(request) = &self.request else {
307 return div().id("language_server_prompt_notification");
308 };
309
310 let (icon, color) = match request.level {
311 PromptLevel::Info => (IconName::Info, Color::Muted),
312 PromptLevel::Warning => (IconName::Warning, Color::Warning),
313 PromptLevel::Critical => (IconName::XCircle, Color::Error),
314 };
315
316 let suppress = window.modifiers().shift;
317 let (close_id, close_icon) = if suppress {
318 ("suppress", IconName::Minimize)
319 } else {
320 ("close", IconName::Close)
321 };
322
323 div()
324 .id("language_server_prompt_notification")
325 .group("language_server_prompt_notification")
326 .occlude()
327 .w_full()
328 .max_h(vh(0.8, window))
329 .elevation_3(cx)
330 .overflow_y_scroll()
331 .track_scroll(&self.scroll_handle)
332 .on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify()))
333 .child(
334 v_flex()
335 .p_3()
336 .overflow_hidden()
337 .child(
338 h_flex()
339 .justify_between()
340 .child(
341 h_flex()
342 .gap_2()
343 .child(Icon::new(icon).color(color).size(IconSize::Small))
344 .child(Label::new(request.lsp_name.clone())),
345 )
346 .child(
347 h_flex()
348 .gap_1()
349 .child(
350 CopyButton::new(
351 "copy-description",
352 request.message.clone(),
353 )
354 .tooltip_label("Copy Description"),
355 )
356 .child(
357 IconButton::new(close_id, close_icon)
358 .tooltip(move |_window, cx| {
359 if suppress {
360 Tooltip::with_meta(
361 "Suppress",
362 Some(&SuppressNotification),
363 "Click to close",
364 cx,
365 )
366 } else {
367 Tooltip::with_meta(
368 "Close",
369 Some(&menu::Cancel),
370 "Suppress with shift-click",
371 cx,
372 )
373 }
374 })
375 .on_click(cx.listener(
376 move |this, _: &ClickEvent, _, cx| {
377 if suppress {
378 cx.emit(SuppressEvent);
379 } else {
380 this.dismiss_notification(cx);
381 }
382 },
383 )),
384 ),
385 ),
386 )
387 .child(
388 MarkdownElement::new(self.markdown.clone(), markdown_style(window, cx))
389 .text_size(TextSize::Small.rems(cx))
390 .code_block_renderer(markdown::CodeBlockRenderer::Default {
391 copy_button_visibility: CopyButtonVisibility::Hidden,
392 wrap_button_visibility: markdown::WrapButtonVisibility::Hidden,
393 border: false,
394 })
395 .on_url_click(|link, window, cx| {
396 if let Some(workspace) = Workspace::for_window(window, cx) {
397 workspace.update(cx, |workspace, cx| {
398 workspace.open_url_or_file(&link, None, window, cx);
399 });
400 } else {
401 cx.open_url(&link);
402 }
403 }),
404 )
405 .children(request.actions.iter().enumerate().map(|(ix, action)| {
406 let this_handle = cx.entity();
407 Button::new(ix, action.title.clone())
408 .size(ButtonSize::Large)
409 .on_click(move |_, window, cx| {
410 let this_handle = this_handle.clone();
411 window
412 .spawn(cx, async move |cx| {
413 LanguageServerPrompt::select_option(this_handle, ix, cx)
414 .await
415 })
416 .detach()
417 })
418 })),
419 )
420 }
421}
422
423impl EventEmitter<DismissEvent> for LanguageServerPrompt {}
424impl EventEmitter<SuppressEvent> for LanguageServerPrompt {}
425
426fn workspace_error_notification_id() -> NotificationId {
427 struct WorkspaceErrorNotification;
428 NotificationId::unique::<WorkspaceErrorNotification>()
429}
430
431fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
432 let settings = ThemeSettings::get_global(cx);
433 let ui_font_family = settings.ui_font.family.clone();
434 let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
435 let buffer_font_family = settings.buffer_font.family.clone();
436 let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
437
438 let mut base_text_style = window.text_style();
439 base_text_style.refine(&TextStyleRefinement {
440 font_family: Some(ui_font_family),
441 font_fallbacks: ui_font_fallbacks,
442 color: Some(cx.theme().colors().text),
443 ..Default::default()
444 });
445
446 MarkdownStyle {
447 base_text_style,
448 selection_background_color: cx.theme().colors().element_selection_background,
449 inline_code: TextStyleRefinement {
450 background_color: Some(cx.theme().colors().editor_background.opacity(0.5)),
451 font_family: Some(buffer_font_family),
452 font_fallbacks: buffer_font_fallbacks,
453 ..Default::default()
454 },
455 link: TextStyleRefinement {
456 underline: Some(UnderlineStyle {
457 thickness: px(1.),
458 color: Some(cx.theme().colors().text_accent),
459 wavy: false,
460 }),
461 ..Default::default()
462 },
463 ..Default::default()
464 }
465}
466
467pub mod simple_message_notification {
468 use std::sync::Arc;
469 use std::time::{Duration, Instant};
470
471 use gpui::{
472 AnyElement, DismissEvent, EventEmitter, FocusHandle, Focusable, ParentElement, Render,
473 ScrollHandle, SharedString, Styled, Task,
474 };
475 use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*};
476
477 use crate::SuppressNotification;
478 use crate::workspace_error::{
479 ActionIcon, ErrorAction, ErrorActionHandler, ErrorSeverity, WorkspaceError,
480 };
481
482 use super::{Notification, SuppressEvent};
483
484 const FADE_OUT_DURATION: Duration = Duration::from_secs(2);
485 const FADE_TO_FULL_OPACITY_DURATION: Duration = Duration::from_millis(200);
486
487 pub(super) struct AutoHideState {
488 remaining_dismiss_duration: Duration,
489 timer_started: Option<Instant>,
490 hovered: bool,
491 fade: Option<AutoHideFade>,
492 task: Option<Task<()>>,
493 }
494
495 enum AutoHideFade {
496 FadingOut {
497 started_at: Instant,
498 },
499 FadingIn {
500 started_at: Instant,
501 start_opacity: f32,
502 },
503 }
504
505 impl AutoHideState {
506 fn new(duration: Duration, cx: &mut Context<MessageNotification>) -> Self {
507 let mut this = Self {
508 remaining_dismiss_duration: duration,
509 timer_started: None,
510 hovered: false,
511 fade: None,
512 task: None,
513 };
514 this.schedule(cx);
515 this
516 }
517
518 fn schedule(&mut self, cx: &mut Context<MessageNotification>) {
519 if self.task.is_some() || self.hovered {
520 return;
521 }
522
523 let duration = self.remaining_dismiss_duration;
524 self.timer_started = Some(Instant::now());
525 self.task = Some(cx.spawn(async move |this, cx| {
526 cx.background_executor().timer(duration).await;
527 if let Err(error) = this.update(cx, |this, cx| {
528 if let Some(auto_hide) = this.auto_hide.as_mut() {
529 auto_hide.finish_timer();
530 if !auto_hide.hovered {
531 if cx.reduce_motion() {
532 cx.emit(DismissEvent);
533 } else {
534 auto_hide.start_fading_out();
535 cx.notify();
536 }
537 }
538 }
539 }) {
540 log::error!("failed to update auto-hiding notification: {error:?}");
541 }
542 }));
543 }
544
545 fn set_hovered(&mut self, hovered: bool, cx: &mut Context<MessageNotification>) {
546 if self.hovered == hovered {
547 return;
548 }
549
550 self.hovered = hovered;
551 if hovered {
552 self.remaining_dismiss_duration = self.remaining_dismiss_duration();
553 self.timer_started = None;
554 self.task.take();
555
556 if matches!(self.fade, Some(AutoHideFade::FadingOut { .. })) {
557 let start_opacity = self.opacity();
558 self.fade = Some(AutoHideFade::FadingIn {
559 started_at: Instant::now(),
560 start_opacity,
561 });
562 }
563 } else {
564 if matches!(self.fade, Some(AutoHideFade::FadingIn { .. })) {
565 self.fade = None;
566 }
567 self.schedule(cx);
568 }
569 cx.notify();
570 }
571
572 fn refresh_animation(&mut self) -> bool {
573 match self.fade {
574 Some(AutoHideFade::FadingOut { started_at })
575 if started_at.elapsed() >= FADE_OUT_DURATION =>
576 {
577 true
578 }
579 Some(AutoHideFade::FadingIn { started_at, .. })
580 if started_at.elapsed() >= FADE_TO_FULL_OPACITY_DURATION =>
581 {
582 self.fade = None;
583 false
584 }
585 _ => false,
586 }
587 }
588
589 fn needs_animation_frame(&self) -> bool {
590 self.fade.is_some()
591 }
592
593 fn opacity(&self) -> f32 {
594 match self.fade {
595 Some(AutoHideFade::FadingOut { started_at }) => {
596 1.0 - duration_progress(started_at.elapsed(), FADE_OUT_DURATION)
597 }
598 Some(AutoHideFade::FadingIn {
599 started_at,
600 start_opacity,
601 }) => {
602 let progress =
603 duration_progress(started_at.elapsed(), FADE_TO_FULL_OPACITY_DURATION);
604 start_opacity + (1.0 - start_opacity) * progress
605 }
606 None => 1.0,
607 }
608 }
609
610 fn finish_timer(&mut self) {
611 self.task.take();
612 self.timer_started = None;
613 self.remaining_dismiss_duration = Duration::ZERO;
614 }
615
616 fn start_fading_out(&mut self) {
617 self.fade = Some(AutoHideFade::FadingOut {
618 started_at: Instant::now(),
619 });
620 }
621
622 fn remaining_dismiss_duration(&self) -> Duration {
623 self.timer_started
624 .map_or(self.remaining_dismiss_duration, |timer_started| {
625 self.remaining_dismiss_duration
626 .saturating_sub(timer_started.elapsed())
627 })
628 }
629 }
630
631 fn duration_progress(elapsed: Duration, duration: Duration) -> f32 {
632 if duration.is_zero() {
633 1.0
634 } else {
635 (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0)
636 }
637 }
638
639 #[derive(RegisterComponent)]
640 pub struct MessageNotification {
641 focus_handle: FocusHandle,
642 build_content: Box<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>,
643 button_style: Option<ButtonStyle>,
644 content_icon: Option<IconName>,
645 content_icon_color: Option<Color>,
646 secondary_content: Option<SharedString>,
647 copy_text: Option<SharedString>,
648 primary_message: Option<SharedString>,
649 primary_icon: Option<ActionIcon>,
650 primary_icon_color: Option<Color>,
651 primary_on_click: Option<Arc<dyn Fn(&mut Window, &mut Context<Self>)>>,
652 secondary_message: Option<SharedString>,
653 secondary_icon: Option<ActionIcon>,
654 secondary_icon_color: Option<Color>,
655 secondary_on_click: Option<Arc<dyn Fn(&mut Window, &mut Context<Self>)>>,
656 more_info_message: Option<SharedString>,
657 more_info_url: Option<Arc<str>>,
658 show_close_button: bool,
659 show_suppress_button: bool,
660 title: Option<SharedString>,
661 scroll_handle: ScrollHandle,
662 auto_hide: Option<AutoHideState>,
663 }
664
665 impl Focusable for MessageNotification {
666 fn focus_handle(&self, _: &App) -> FocusHandle {
667 self.focus_handle.clone()
668 }
669 }
670
671 impl EventEmitter<DismissEvent> for MessageNotification {}
672 impl EventEmitter<SuppressEvent> for MessageNotification {}
673
674 impl Notification for MessageNotification {}
675
676 impl FluentBuilder for MessageNotification {}
677
678 impl MessageNotification {
679 pub fn new<S>(message: S, cx: &mut App) -> MessageNotification
680 where
681 S: Into<SharedString>,
682 {
683 let message = message.into();
684 Self::new_from_builder(cx, move |_, _| {
685 Label::new(message.clone()).into_any_element()
686 })
687 }
688
689 pub fn new_from_builder<F>(cx: &mut App, content: F) -> MessageNotification
690 where
691 F: 'static + Fn(&mut Window, &mut Context<Self>) -> AnyElement,
692 {
693 Self {
694 build_content: Box::new(content),
695 button_style: None,
696 content_icon: None,
697 content_icon_color: None,
698 secondary_content: None,
699 copy_text: None,
700 primary_message: None,
701 primary_icon: None,
702 primary_icon_color: None,
703 primary_on_click: None,
704 secondary_message: None,
705 secondary_icon: None,
706 secondary_icon_color: None,
707 secondary_on_click: None,
708 more_info_message: None,
709 more_info_url: None,
710 show_close_button: true,
711 show_suppress_button: true,
712 title: None,
713 focus_handle: cx.focus_handle(),
714 scroll_handle: ScrollHandle::new(),
715 auto_hide: None,
716 }
717 }
718
719 pub fn button_style(mut self, style: ButtonStyle) -> Self {
720 self.button_style = Some(style);
721 self
722 }
723
724 pub fn primary_message<S>(mut self, message: S) -> Self
725 where
726 S: Into<SharedString>,
727 {
728 self.primary_message = Some(message.into());
729 self
730 }
731
732 /// Show `icon` at the start (left) of the primary action button label.
733 pub fn primary_icon(mut self, icon: IconName) -> Self {
734 self.primary_icon = Some(ActionIcon::start(icon));
735 self
736 }
737
738 /// Show `icon` at the end (right) of the primary action button label.
739 pub fn primary_end_icon(mut self, icon: IconName) -> Self {
740 self.primary_icon = Some(ActionIcon::end(icon));
741 self
742 }
743
744 pub fn primary_icon_color(mut self, color: Color) -> Self {
745 self.primary_icon_color = Some(color);
746 self
747 }
748
749 pub fn primary_on_click<F>(mut self, on_click: F) -> Self
750 where
751 F: 'static + Fn(&mut Window, &mut Context<Self>),
752 {
753 self.primary_on_click = Some(Arc::new(on_click));
754 self
755 }
756
757 pub fn primary_on_click_arc<F>(mut self, on_click: Arc<F>) -> Self
758 where
759 F: 'static + Fn(&mut Window, &mut Context<Self>),
760 {
761 self.primary_on_click = Some(on_click);
762 self
763 }
764
765 pub fn secondary_message<S>(mut self, message: S) -> Self
766 where
767 S: Into<SharedString>,
768 {
769 self.secondary_message = Some(message.into());
770 self
771 }
772
773 /// Show `icon` at the start (left) of the secondary action button label.
774 pub fn secondary_icon(mut self, icon: IconName) -> Self {
775 self.secondary_icon = Some(ActionIcon::start(icon));
776 self
777 }
778
779 /// Show `icon` at the end (right) of the secondary action button label.
780 pub fn secondary_end_icon(mut self, icon: IconName) -> Self {
781 self.secondary_icon = Some(ActionIcon::end(icon));
782 self
783 }
784
785 pub fn secondary_icon_color(mut self, color: Color) -> Self {
786 self.secondary_icon_color = Some(color);
787 self
788 }
789
790 pub fn secondary_on_click<F>(mut self, on_click: F) -> Self
791 where
792 F: 'static + Fn(&mut Window, &mut Context<Self>),
793 {
794 self.secondary_on_click = Some(Arc::new(on_click));
795 self
796 }
797
798 pub fn secondary_on_click_arc<F>(mut self, on_click: Arc<F>) -> Self
799 where
800 F: 'static + Fn(&mut Window, &mut Context<Self>),
801 {
802 self.secondary_on_click = Some(on_click);
803 self
804 }
805
806 pub fn more_info_message<S>(mut self, message: S) -> Self
807 where
808 S: Into<SharedString>,
809 {
810 self.more_info_message = Some(message.into());
811 self
812 }
813
814 pub fn more_info_url<S>(mut self, url: S) -> Self
815 where
816 S: Into<Arc<str>>,
817 {
818 self.more_info_url = Some(url.into());
819 self
820 }
821
822 pub fn dismiss(&mut self, cx: &mut Context<Self>) {
823 cx.emit(DismissEvent);
824 }
825
826 pub fn show_close_button(mut self, show: bool) -> Self {
827 self.show_close_button = show;
828 self
829 }
830
831 /// Determines whether the given notification ID should be suppressible
832 /// Suppressed notifications will not be shown anymor
833 pub fn show_suppress_button(mut self, show: bool) -> Self {
834 self.show_suppress_button = show;
835 self
836 }
837
838 pub fn with_title<S>(mut self, title: S) -> Self
839 where
840 S: Into<SharedString>,
841 {
842 self.title = Some(title.into());
843 self
844 }
845
846 pub fn content_icon(mut self, icon: IconName, color: Color) -> Self {
847 self.content_icon = Some(icon);
848 self.content_icon_color = Some(color);
849 self
850 }
851
852 pub fn secondary_content<S: Into<SharedString>>(mut self, text: S) -> Self {
853 self.secondary_content = Some(text.into());
854 self
855 }
856
857 pub fn copy_text<S: Into<SharedString>>(mut self, text: S) -> Self {
858 self.copy_text = Some(text.into());
859 self
860 }
861
862 fn auto_dismiss(mut self, severity: ErrorSeverity, cx: &mut Context<Self>) -> Self {
863 if let Some(delay) = severity.auto_dismiss_delay() {
864 self.auto_hide = Some(AutoHideState::new(delay, cx));
865 }
866 self
867 }
868
869 pub fn from_workspace_error<E: WorkspaceError>(error: E, cx: &mut Context<Self>) -> Self {
870 let primary_message = error.primary_message();
871 let severity = error.severity();
872 let primary_action = error.primary_action();
873 let secondary_action = error.secondary_action();
874
875 Self::new(primary_message.clone(), cx)
876 .content_icon(IconName::Warning, Color::Error)
877 .button_style(ButtonStyle::Outlined)
878 .copy_text(primary_message)
879 .show_suppress_button(false)
880 .when_some(error.secondary_message(), |this, text| {
881 this.secondary_content(text)
882 })
883 .map(|this| {
884 let ErrorAction {
885 label,
886 icon,
887 tooltip: _,
888 handler,
889 } = primary_action;
890
891 this.primary_message(label)
892 .when_some(icon, |this, icon| match icon.position {
893 IconPosition::Start => this.primary_icon(icon.name),
894 IconPosition::End => this.primary_end_icon(icon.name),
895 })
896 .map(|this| match handler {
897 ErrorActionHandler::Action(action) => {
898 this.primary_on_click(move |window, cx| {
899 window.dispatch_action(action.boxed_clone(), cx);
900 })
901 }
902 ErrorActionHandler::Dismiss => {
903 this.primary_on_click(move |_, cx| cx.emit(DismissEvent))
904 }
905 })
906 })
907 .when_some(secondary_action, |this, action| {
908 let ErrorAction {
909 label,
910 icon,
911 tooltip: _,
912 handler,
913 } = action;
914
915 this.secondary_message(label)
916 .when_some(icon, |this, icon| match icon.position {
917 IconPosition::Start => this.secondary_icon(icon.name),
918 IconPosition::End => this.secondary_end_icon(icon.name),
919 })
920 .map(|this| match handler {
921 ErrorActionHandler::Action(handler) => {
922 this.secondary_on_click(move |window, cx| {
923 window.dispatch_action(handler.boxed_clone(), cx);
924 })
925 }
926 ErrorActionHandler::Dismiss => {
927 this.secondary_on_click(move |_, cx| cx.emit(DismissEvent))
928 }
929 })
930 })
931 .auto_dismiss(severity, cx)
932 }
933
934 fn on_hover_changed(&mut self, hovering: bool, cx: &mut Context<Self>) {
935 if let Some(auto_hide) = self.auto_hide.as_mut() {
936 auto_hide.set_hovered(hovering, cx);
937 }
938 }
939
940 fn opacity(&self) -> f32 {
941 self.auto_hide
942 .as_ref()
943 .map_or(1.0, |auto_hide| auto_hide.opacity())
944 }
945
946 fn needs_animation_frame(&self) -> bool {
947 self.auto_hide
948 .as_ref()
949 .is_some_and(|auto_hide| auto_hide.needs_animation_frame())
950 }
951 }
952
953 impl Render for MessageNotification {
954 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
955 if self
956 .auto_hide
957 .as_mut()
958 .is_some_and(|auto_hide| auto_hide.refresh_animation())
959 {
960 cx.emit(DismissEvent);
961 }
962
963 if self.needs_animation_frame() && !cx.reduce_motion() {
964 window.request_animation_frame();
965 }
966
967 let opacity = self.opacity();
968 let has_auto_hide = self.auto_hide.is_some();
969 let entity = window.current_view();
970 let show_suppress_button = self.show_suppress_button;
971 let show_close_button = self.show_close_button;
972 let suppress = show_suppress_button && window.modifiers().shift;
973 let (close_id, close_icon) = if suppress {
974 ("suppress", IconName::Minimize)
975 } else {
976 ("close", IconName::Close)
977 };
978
979 let main_content = (self.build_content)(window, cx);
980 let line_height = window.line_height();
981
982 let copy_text = self.copy_text.clone();
983 let header_actions = h_flex()
984 .flex_shrink_0()
985 .gap_1()
986 .when_some(copy_text, |el, text| {
987 el.child(
988 CopyButton::new("copy-notification-message", text)
989 .tooltip_label("Copy Message"),
990 )
991 })
992 .when(show_close_button, |el| {
993 el.child(
994 IconButton::new(close_id, close_icon)
995 .tooltip(move |_window, cx| {
996 if suppress {
997 Tooltip::with_meta(
998 "Suppress",
999 Some(&SuppressNotification),
1000 "Click to Close",
1001 cx,
1002 )
1003 } else if show_suppress_button {
1004 Tooltip::with_meta(
1005 "Close",
1006 Some(&menu::Cancel),
1007 "Shift-click to Suppress",
1008 cx,
1009 )
1010 } else {
1011 Tooltip::for_action("Close", &menu::Cancel, cx)
1012 }
1013 })
1014 .on_click(cx.listener(move |_, _, _, cx| {
1015 if suppress {
1016 cx.emit(SuppressEvent);
1017 } else {
1018 cx.emit(DismissEvent);
1019 }
1020 })),
1021 )
1022 });
1023
1024 let has_suffix = self.primary_message.is_some()
1025 || self.secondary_message.is_some()
1026 || self.more_info_message.is_some();
1027
1028 let suffix = h_flex()
1029 .gap_1()
1030 .children(self.primary_message.iter().map(|message| {
1031 Button::new(("notification-primary", cx.entity_id()), message.clone())
1032 .when_some(self.button_style, |button, style| button.style(style))
1033 .label_size(LabelSize::Small)
1034 .on_click(cx.listener(|this, _, window, cx| {
1035 if let Some(on_click) = this.primary_on_click.as_ref() {
1036 (on_click)(window, cx)
1037 };
1038 this.dismiss(cx)
1039 }))
1040 .when_some(self.primary_icon, |button, icon| {
1041 let element = Icon::new(icon.name)
1042 .size(IconSize::Small)
1043 .color(self.primary_icon_color.unwrap_or(Color::Muted));
1044 match icon.position {
1045 IconPosition::Start => button.start_icon(element),
1046 IconPosition::End => button.end_icon(element),
1047 }
1048 })
1049 }))
1050 .children(self.secondary_message.iter().map(|message| {
1051 Button::new(("notification-secondary", cx.entity_id()), message.clone())
1052 .when_some(self.button_style, |button, style| button.style(style))
1053 .label_size(LabelSize::Small)
1054 .on_click(cx.listener(|this, _, window, cx| {
1055 if let Some(on_click) = this.secondary_on_click.as_ref() {
1056 (on_click)(window, cx)
1057 };
1058 this.dismiss(cx)
1059 }))
1060 .when_some(self.secondary_icon, |button, icon| {
1061 let element = Icon::new(icon.name)
1062 .size(IconSize::Small)
1063 .color(self.secondary_icon_color.unwrap_or(Color::Muted));
1064 match icon.position {
1065 IconPosition::Start => button.start_icon(element),
1066 IconPosition::End => button.end_icon(element),
1067 }
1068 })
1069 }))
1070 .child(
1071 h_flex().w_full().justify_end().children(
1072 self.more_info_message
1073 .iter()
1074 .zip(self.more_info_url.iter())
1075 .map(|(message, url)| {
1076 let url = url.clone();
1077 Button::new(message.clone(), message.clone())
1078 .label_size(LabelSize::Small)
1079 .end_icon(
1080 Icon::new(IconName::ArrowUpRight)
1081 .size(IconSize::Indicator)
1082 .color(Color::Muted),
1083 )
1084 .on_click(cx.listener(move |_, _, _, cx| {
1085 cx.open_url(&url);
1086 }))
1087 }),
1088 ),
1089 );
1090
1091 // Wrap the icon to vertically align with the first line of the primary
1092 // message (mirrors `ui::Callout`'s alignment pattern). The body, secondary
1093 // text and suffix all share a single column to the right of the icon so
1094 // they line up under one another even when an icon is present.
1095 let body = h_flex()
1096 .gap_2()
1097 .items_start()
1098 .when_some(
1099 self.content_icon.zip(self.content_icon_color),
1100 |el, (icon, color)| {
1101 el.child(
1102 h_flex()
1103 .h(line_height)
1104 .justify_center()
1105 .child(Icon::new(icon).size(IconSize::Small).color(color)),
1106 )
1107 },
1108 )
1109 .child(
1110 v_flex()
1111 .flex_1()
1112 .min_w_0()
1113 .gap_2()
1114 .child(
1115 v_flex()
1116 .gap_1()
1117 .child(
1118 div()
1119 .child(
1120 div()
1121 .id("message-notification-content")
1122 .max_h(vh(0.6, window))
1123 .overflow_y_scroll()
1124 .track_scroll(&self.scroll_handle.clone())
1125 .child(main_content),
1126 )
1127 .vertical_scrollbar_for(&self.scroll_handle, window, cx),
1128 )
1129 .when_some(self.secondary_content.clone(), |el, secondary| {
1130 el.child(
1131 Label::new(secondary)
1132 .size(LabelSize::Small)
1133 .color(Color::Muted),
1134 )
1135 }),
1136 )
1137 .when(has_suffix, |this| this.child(suffix)),
1138 );
1139
1140 div()
1141 .id("message-notification-wrapper")
1142 .opacity(opacity)
1143 .child(
1144 v_flex()
1145 .id(("notification-frame", entity))
1146 .occlude()
1147 .when(has_auto_hide, |this| {
1148 this.on_hover(cx.listener(|this, hovered: &bool, _window, cx| {
1149 this.on_hover_changed(*hovered, cx);
1150 }))
1151 })
1152 .when(show_close_button, |this| {
1153 this.on_modifiers_changed(move |_, _, cx| cx.notify(entity))
1154 })
1155 .p_3()
1156 .gap_2()
1157 .elevation_3(cx)
1158 .child(
1159 h_flex()
1160 .gap_4()
1161 .justify_between()
1162 .items_start()
1163 .child(
1164 v_flex()
1165 .flex_1()
1166 .min_w_0()
1167 .gap_0p5()
1168 .when_some(self.title.clone(), |div, title| {
1169 div.child(Label::new(title))
1170 })
1171 .child(div().max_w_96().child(body)),
1172 )
1173 .child(header_actions),
1174 ),
1175 )
1176 }
1177 }
1178
1179 impl Component for MessageNotification {
1180 fn scope() -> ComponentScope {
1181 ComponentScope::Notification
1182 }
1183
1184 fn description() -> &'static str {
1185 "A workspace notification that surfaces a message in a framed container, with an \
1186 optional title, secondary message, copy button, and primary/secondary action buttons."
1187 }
1188
1189 fn preview(_window: &mut Window, cx: &mut App) -> AnyElement {
1190 let normal =
1191 cx.new(|cx| MessageNotification::new("A regular informational notification.", cx));
1192
1193 let with_title = cx.new(|cx| {
1194 MessageNotification::new("Some informational content for the user.", cx)
1195 .with_title("Notification Title")
1196 });
1197
1198 let with_primary_action = cx.new(|cx| {
1199 MessageNotification::new("A new version of Omega is available for download.", cx)
1200 .with_title("Update Available")
1201 .primary_message("Restart Now")
1202 .primary_icon(IconName::ArrowCircle)
1203 });
1204
1205 let with_end_icon_action = cx.new(|cx| {
1206 MessageNotification::new("Release notes for this version are available online.", cx)
1207 .with_title("What’s New")
1208 .primary_message("Read Release Notes")
1209 .primary_end_icon(IconName::ArrowUpRight)
1210 });
1211
1212 // Mirrors the shape of notifications such as the keymap parse error: a long,
1213 // multi-line message followed by a primary action button. Useful for catching
1214 // regressions where the action row overlaps or is clipped by the content above.
1215 let with_long_content_and_action = cx.new(|cx| {
1216 let long_message = "Errors in user keymap file. In section with context = \
1217 \"Workspace\":\n\
1218 • In binding \"ctrl-r\", expected two-element array of [name, input], \
1219 found [\"editor::Apply\"].\n\
1220 • In binding \"ctrl-shift-r\", action \"editor::Reload\" is not registered.";
1221 MessageNotification::new(long_message, cx)
1222 .primary_message("Open Keymap File")
1223 .primary_icon(IconName::Settings)
1224 });
1225
1226 struct PreviewError;
1227 impl WorkspaceError for PreviewError {
1228 fn primary_message(&self) -> SharedString {
1229 "Something went wrong while loading your project.".into()
1230 }
1231
1232 fn primary_action(&self) -> ErrorAction {
1233 ErrorAction::dismiss()
1234 }
1235
1236 fn secondary_message(&self) -> Option<SharedString> {
1237 Some("Check your network connection and try again.".into())
1238 }
1239 fn severity(&self) -> ErrorSeverity {
1240 ErrorSeverity::Error
1241 }
1242 }
1243 let error_state =
1244 cx.new(|cx| MessageNotification::from_workspace_error(PreviewError, cx));
1245
1246 let close_only = cx
1247 .new(|cx| MessageNotification::new("Default header with just a close button.", cx));
1248
1249 let copy_and_close = cx.new(|cx| {
1250 let msg: SharedString = "This message can be copied to the clipboard.".into();
1251 MessageNotification::new(msg.clone(), cx).copy_text(msg)
1252 });
1253
1254 let no_close = cx.new(|cx| {
1255 MessageNotification::new("This notification can't be closed manually.", cx)
1256 .show_close_button(false)
1257 });
1258
1259 // --- Workspace errors ---
1260 // These showcase common shapes of [`WorkspaceError`]. They are intentionally
1261 // [`ErrorSeverity::Critical`] so they never auto-dismiss in the preview, which
1262 // would otherwise make them disappear mid-inspection.
1263
1264 struct BasicError;
1265 impl WorkspaceError for BasicError {
1266 fn primary_message(&self) -> SharedString {
1267 "Failed to save the file.".into()
1268 }
1269 fn primary_action(&self) -> ErrorAction {
1270 ErrorAction::dismiss()
1271 }
1272 fn severity(&self) -> ErrorSeverity {
1273 ErrorSeverity::Critical
1274 }
1275 }
1276
1277 struct LanguageServerError;
1278 impl WorkspaceError for LanguageServerError {
1279 fn primary_message(&self) -> SharedString {
1280 "Error: Prepare rename via rust-analyzer failed: No references found at position"
1281 .into()
1282 }
1283 fn secondary_message(&self) -> Option<SharedString> {
1284 None
1285 }
1286 fn primary_action(&self) -> ErrorAction {
1287 ErrorAction::dismiss()
1288 }
1289 fn severity(&self) -> ErrorSeverity {
1290 ErrorSeverity::Critical
1291 }
1292 }
1293
1294 // Mirrors the shape of [`super::super::PortalError`]: a critical error with a
1295 // documentation link as its primary action.
1296 struct PortalSetupError;
1297 impl WorkspaceError for PortalSetupError {
1298 fn primary_message(&self) -> SharedString {
1299 "Linux desktop portal initialization failed.".into()
1300 }
1301 fn secondary_message(&self) -> Option<SharedString> {
1302 Some("Omega needs an xdg-desktop-portal implementation to open files.".into())
1303 }
1304 fn severity(&self) -> ErrorSeverity {
1305 ErrorSeverity::Critical
1306 }
1307 fn primary_action(&self) -> ErrorAction {
1308 ErrorAction::link(
1309 "See Docs",
1310 "https://github.com/OpenAgentsInc/omega#readme",
1311 )
1312 }
1313 }
1314
1315 // Has both a primary action (link) and a secondary action (dismiss), so the
1316 // preview exercises the full button row.
1317 struct UpdateRequiredError;
1318 impl WorkspaceError for UpdateRequiredError {
1319 fn primary_message(&self) -> SharedString {
1320 "An update is required to continue using hosted AI features.".into()
1321 }
1322 fn severity(&self) -> ErrorSeverity {
1323 ErrorSeverity::Critical
1324 }
1325 fn primary_action(&self) -> ErrorAction {
1326 ErrorAction::link(
1327 "Update Omega",
1328 "https://github.com/OpenAgentsInc/omega/releases",
1329 )
1330 }
1331 fn secondary_action(&self) -> Option<ErrorAction> {
1332 Some(ErrorAction::dismiss())
1333 }
1334 }
1335
1336 let basic_error =
1337 cx.new(|cx| MessageNotification::from_workspace_error(BasicError, cx));
1338 let detailed_error =
1339 cx.new(|cx| MessageNotification::from_workspace_error(LanguageServerError, cx));
1340 let docs_error =
1341 cx.new(|cx| MessageNotification::from_workspace_error(PortalSetupError, cx));
1342 let update_error =
1343 cx.new(|cx| MessageNotification::from_workspace_error(UpdateRequiredError, cx));
1344
1345 let container = || div().w(px(440.));
1346
1347 v_flex()
1348 .gap_6()
1349 .p_4()
1350 .children(vec![
1351 example_group_with_title(
1352 "States",
1353 vec![
1354 single_example("Normal", container().child(normal).into_any_element()),
1355 single_example(
1356 "With Title",
1357 container().child(with_title).into_any_element(),
1358 ),
1359 single_example(
1360 "With Primary Action (start icon)",
1361 container().child(with_primary_action).into_any_element(),
1362 ),
1363 single_example(
1364 "With Primary Action (end icon)",
1365 container().child(with_end_icon_action).into_any_element(),
1366 ),
1367 single_example(
1368 "Long Content + Primary Action",
1369 container()
1370 .child(with_long_content_and_action)
1371 .into_any_element(),
1372 ),
1373 single_example(
1374 "Error",
1375 container().child(error_state).into_any_element(),
1376 ),
1377 ],
1378 ),
1379 example_group_with_title(
1380 "Header Actions (top right)",
1381 vec![
1382 single_example(
1383 "Close Only",
1384 container().child(close_only).into_any_element(),
1385 ),
1386 single_example(
1387 "Copy + Close",
1388 container().child(copy_and_close).into_any_element(),
1389 ),
1390 single_example(
1391 "No Close",
1392 container().child(no_close).into_any_element(),
1393 ),
1394 ],
1395 ),
1396 example_group_with_title(
1397 "Workspace Errors",
1398 vec![
1399 single_example(
1400 "Basic",
1401 container().child(basic_error).into_any_element(),
1402 ),
1403 single_example(
1404 "With Secondary Message",
1405 container().child(detailed_error).into_any_element(),
1406 ),
1407 single_example(
1408 "With Documentation Link",
1409 container().child(docs_error).into_any_element(),
1410 ),
1411 single_example(
1412 "With Primary + Secondary Action",
1413 container().child(update_error).into_any_element(),
1414 ),
1415 ],
1416 ),
1417 ])
1418 .into_any_element()
1419 }
1420 }
1421}
1422
1423static GLOBAL_APP_NOTIFICATIONS: LazyLock<Mutex<AppNotifications>> = LazyLock::new(|| {
1424 Mutex::new(AppNotifications {
1425 app_notifications: Vec::new(),
1426 })
1427});
1428
1429/// Stores app notifications so that they can be shown in new workspaces.
1430struct AppNotifications {
1431 app_notifications: Vec<(
1432 NotificationId,
1433 Arc<dyn Fn(&mut Context<Workspace>) -> AnyView + Send + Sync>,
1434 )>,
1435}
1436
1437impl AppNotifications {
1438 pub fn insert(
1439 &mut self,
1440 id: NotificationId,
1441 build_notification: Arc<dyn Fn(&mut Context<Workspace>) -> AnyView + Send + Sync>,
1442 ) {
1443 self.remove(&id);
1444 self.app_notifications.push((id, build_notification))
1445 }
1446
1447 pub fn remove(&mut self, id: &NotificationId) {
1448 self.app_notifications
1449 .retain(|(existing_id, _)| existing_id != id);
1450 }
1451}
1452
1453/// Shows a notification in all workspaces. New workspaces will also receive the notification - this
1454/// is particularly to handle notifications that occur on initialization before any workspaces
1455/// exist. If the notification is dismissed within any workspace, it will be removed from all.
1456pub fn show_app_notification<V: Notification + 'static>(
1457 id: NotificationId,
1458 cx: &mut App,
1459 build_notification: impl Fn(&mut Context<Workspace>) -> Entity<V> + 'static + Send + Sync,
1460) {
1461 // Defer notification creation so that windows on the stack can be returned to GPUI
1462 cx.defer(move |cx| {
1463 // Handle dismiss events by removing the notification from all workspaces.
1464 let build_notification: Arc<dyn Fn(&mut Context<Workspace>) -> AnyView + Send + Sync> =
1465 Arc::new({
1466 let id = id.clone();
1467 move |cx| {
1468 let notification = build_notification(cx);
1469 cx.subscribe(¬ification, {
1470 let id = id.clone();
1471 move |_, _, _: &DismissEvent, cx| {
1472 dismiss_app_notification(&id, cx);
1473 }
1474 })
1475 .detach();
1476 cx.subscribe(¬ification, {
1477 let id = id.clone();
1478 move |workspace: &mut Workspace, _, _: &SuppressEvent, cx| {
1479 workspace.suppress_notification(&id, cx);
1480 }
1481 })
1482 .detach();
1483 notification.into()
1484 }
1485 });
1486
1487 // Store the notification so that new workspaces also receive it.
1488 GLOBAL_APP_NOTIFICATIONS
1489 .lock()
1490 .insert(id.clone(), build_notification.clone());
1491
1492 for window in cx.windows() {
1493 if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
1494 multi_workspace
1495 .update(cx, |multi_workspace, _window, cx| {
1496 for workspace in multi_workspace.workspaces() {
1497 workspace.update(cx, |workspace, cx| {
1498 workspace.show_notification_without_handling_dismiss_events(
1499 &id,
1500 cx,
1501 |cx| build_notification(cx),
1502 );
1503 });
1504 }
1505 })
1506 .ok(); // Doesn't matter if the windows are dropped
1507 }
1508 }
1509 });
1510}
1511
1512pub fn dismiss_app_notification(id: &NotificationId, cx: &mut App) {
1513 let id = id.clone();
1514 // Defer notification dismissal so that windows on the stack can be returned to GPUI
1515 cx.defer(move |cx| {
1516 GLOBAL_APP_NOTIFICATIONS.lock().remove(&id);
1517 for window in cx.windows() {
1518 if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
1519 let id = id.clone();
1520 multi_workspace
1521 .update(cx, |multi_workspace, _window, cx| {
1522 for workspace in multi_workspace.workspaces() {
1523 workspace.update(cx, |workspace, cx| {
1524 workspace.dismiss_notification(&id, cx)
1525 });
1526 }
1527 })
1528 .ok();
1529 }
1530 }
1531 });
1532}
1533
1534pub trait NotifyResultExt {
1535 type Ok;
1536
1537 fn notify_err(self, workspace: &mut Workspace, cx: &mut Context<Workspace>)
1538 -> Option<Self::Ok>;
1539
1540 fn notify_workspace_async_err(
1541 self,
1542 workspace: WeakEntity<Workspace>,
1543 cx: &mut AsyncApp,
1544 ) -> Option<Self::Ok>;
1545
1546 /// Notifies the active workspace if there is one, otherwise notifies all workspaces.
1547 fn notify_app_err(self, cx: &mut App) -> Option<Self::Ok>;
1548}
1549
1550impl<T, E> NotifyResultExt for std::result::Result<T, E>
1551where
1552 E: std::fmt::Debug + std::fmt::Display,
1553{
1554 type Ok = T;
1555
1556 fn notify_err(self, workspace: &mut Workspace, cx: &mut Context<Workspace>) -> Option<T> {
1557 match self {
1558 Ok(value) => Some(value),
1559 Err(err) => {
1560 log::error!("Showing error notification in workspace: {err:?}");
1561 workspace.show_error(format!("Error: {err}"), cx);
1562 None
1563 }
1564 }
1565 }
1566
1567 fn notify_workspace_async_err(
1568 self,
1569 workspace: WeakEntity<Workspace>,
1570 cx: &mut AsyncApp,
1571 ) -> Option<T> {
1572 match self {
1573 Ok(value) => Some(value),
1574 Err(err) => {
1575 log::error!("{err:?}");
1576 let message = format!("Error: {err}");
1577 workspace
1578 .update(cx, |workspace, cx| workspace.show_error(message, cx))
1579 .ok();
1580 None
1581 }
1582 }
1583 }
1584
1585 fn notify_app_err(self, cx: &mut App) -> Option<T> {
1586 match self {
1587 Ok(value) => Some(value),
1588 Err(err) => {
1589 let message = format!("Error: {err}");
1590 log::error!("Showing error notification in app: {message}");
1591 show_app_notification(workspace_error_notification_id(), cx, {
1592 move |cx| {
1593 cx.new({
1594 let message = message.clone();
1595 move |cx| {
1596 simple_message_notification::MessageNotification::from_workspace_error(message, cx)
1597 }
1598 })
1599 }
1600 });
1601
1602 None
1603 }
1604 }
1605 }
1606}
1607
1608pub trait NotifyTaskExt {
1609 fn detach_and_notify_err(
1610 self,
1611 workspace: WeakEntity<Workspace>,
1612 window: &mut Window,
1613 cx: &mut App,
1614 );
1615}
1616
1617impl<R, E> NotifyTaskExt for Task<std::result::Result<R, E>>
1618where
1619 E: std::fmt::Debug + std::fmt::Display + Sized + 'static,
1620 R: 'static,
1621{
1622 fn detach_and_notify_err(
1623 self,
1624 workspace: WeakEntity<Workspace>,
1625 window: &mut Window,
1626 cx: &mut App,
1627 ) {
1628 window
1629 .spawn(cx, async move |mut cx| {
1630 self.await.notify_workspace_async_err(workspace, &mut cx)
1631 })
1632 .detach();
1633 }
1634}
1635
1636pub trait DetachAndPromptErr<R> {
1637 fn prompt_err(
1638 self,
1639 msg: &str,
1640 window: &Window,
1641 cx: &App,
1642 f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
1643 ) -> Task<Option<R>>;
1644
1645 fn detach_and_prompt_err(
1646 self,
1647 msg: &str,
1648 window: &Window,
1649 cx: &App,
1650 f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
1651 );
1652}
1653
1654impl<R> DetachAndPromptErr<R> for Task<anyhow::Result<R>>
1655where
1656 R: 'static,
1657{
1658 fn prompt_err(
1659 self,
1660 msg: &str,
1661 window: &Window,
1662 cx: &App,
1663 f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
1664 ) -> Task<Option<R>> {
1665 let msg = msg.to_owned();
1666 window.spawn(cx, async move |cx| {
1667 let result = self.await;
1668 if let Err(err) = result.as_ref() {
1669 log::error!("{err:#}");
1670 if let Ok(prompt) = cx.update(|window, cx| {
1671 let mut display = format!("{err:#}");
1672 if !display.ends_with('\n') {
1673 display.push('.');
1674 }
1675 let detail = f(err, window, cx).unwrap_or(display);
1676 window.prompt(PromptLevel::Critical, &msg, Some(&detail), &["OK"], cx)
1677 }) {
1678 prompt.await.ok();
1679 }
1680 return None;
1681 }
1682 Some(result.unwrap())
1683 })
1684 }
1685
1686 fn detach_and_prompt_err(
1687 self,
1688 msg: &str,
1689 window: &Window,
1690 cx: &App,
1691 f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
1692 ) {
1693 self.prompt_err(msg, window, cx, f).detach();
1694 }
1695}
1696
1697#[cfg(test)]
1698mod tests {
1699 use fs::FakeFs;
1700 use gpui::TestAppContext;
1701 use project::{LanguageServerPromptRequest, Project};
1702
1703 use crate::tests::init_test;
1704
1705 use super::*;
1706
1707 #[gpui::test]
1708 async fn test_notification_auto_dismiss_with_notifications_from_multiple_language_servers(
1709 cx: &mut TestAppContext,
1710 ) {
1711 init_test(cx);
1712
1713 let fs = FakeFs::new(cx.executor());
1714 let project = Project::test(fs, [], cx).await;
1715
1716 let (workspace, cx) =
1717 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1718
1719 let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
1720 workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
1721 };
1722
1723 let show_notification = |workspace: &Entity<Workspace>,
1724 cx: &mut TestAppContext,
1725 lsp_name: &str| {
1726 workspace.update(cx, |workspace, cx| {
1727 let request = LanguageServerPromptRequest::test(
1728 gpui::PromptLevel::Warning,
1729 "Test notification".to_string(),
1730 vec![], // Empty actions triggers auto-dismiss
1731 lsp_name.to_string(),
1732 );
1733 let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
1734 workspace.show_notification(notification_id, cx, |cx| {
1735 cx.new(|cx| LanguageServerPrompt::new(request, cx))
1736 });
1737 })
1738 };
1739
1740 show_notification(&workspace, cx, "Lsp1");
1741 assert_eq!(count_notifications(&workspace, cx), 1);
1742
1743 cx.executor().advance_clock(Duration::from_millis(1000));
1744
1745 show_notification(&workspace, cx, "Lsp2");
1746 assert_eq!(count_notifications(&workspace, cx), 2);
1747
1748 cx.executor().advance_clock(Duration::from_millis(1000));
1749
1750 show_notification(&workspace, cx, "Lsp3");
1751 assert_eq!(count_notifications(&workspace, cx), 3);
1752
1753 cx.executor().advance_clock(Duration::from_millis(3000));
1754 assert_eq!(count_notifications(&workspace, cx), 2);
1755
1756 cx.executor().advance_clock(Duration::from_millis(1000));
1757 assert_eq!(count_notifications(&workspace, cx), 1);
1758
1759 cx.executor().advance_clock(Duration::from_millis(1000));
1760 assert_eq!(count_notifications(&workspace, cx), 0);
1761 }
1762
1763 #[gpui::test]
1764 async fn test_notification_auto_dismiss_with_multiple_notifications_from_single_language_server(
1765 cx: &mut TestAppContext,
1766 ) {
1767 init_test(cx);
1768
1769 let lsp_name = "server1";
1770
1771 let fs = FakeFs::new(cx.executor());
1772 let project = Project::test(fs, [], cx).await;
1773 let (workspace, cx) =
1774 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1775
1776 let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
1777 workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
1778 };
1779
1780 let show_notification = |lsp_name: &str,
1781 workspace: &Entity<Workspace>,
1782 cx: &mut TestAppContext| {
1783 workspace.update(cx, |workspace, cx| {
1784 let lsp_name = lsp_name.to_string();
1785 let request = LanguageServerPromptRequest::test(
1786 gpui::PromptLevel::Warning,
1787 "Test notification".to_string(),
1788 vec![], // Empty actions triggers auto-dismiss
1789 lsp_name,
1790 );
1791 let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
1792
1793 workspace.show_notification(notification_id, cx, |cx| {
1794 cx.new(|cx| LanguageServerPrompt::new(request, cx))
1795 });
1796 })
1797 };
1798
1799 show_notification(lsp_name, &workspace, cx);
1800 assert_eq!(count_notifications(&workspace, cx), 1);
1801
1802 cx.executor().advance_clock(Duration::from_millis(1000));
1803
1804 show_notification(lsp_name, &workspace, cx);
1805 assert_eq!(count_notifications(&workspace, cx), 2);
1806
1807 cx.executor().advance_clock(Duration::from_millis(4000));
1808 assert_eq!(count_notifications(&workspace, cx), 1);
1809
1810 cx.executor().advance_clock(Duration::from_millis(1000));
1811 assert_eq!(count_notifications(&workspace, cx), 0);
1812 }
1813
1814 #[gpui::test]
1815 async fn test_notification_auto_dismiss_turned_off(cx: &mut TestAppContext) {
1816 init_test(cx);
1817
1818 cx.update(|cx| {
1819 let mut settings = ProjectSettings::get_global(cx).clone();
1820 settings
1821 .global_lsp_settings
1822 .notifications
1823 .dismiss_timeout_ms = Some(0);
1824 ProjectSettings::override_global(settings, cx);
1825 });
1826
1827 let fs = FakeFs::new(cx.executor());
1828 let project = Project::test(fs, [], cx).await;
1829 let (workspace, cx) =
1830 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1831
1832 let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
1833 workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
1834 };
1835
1836 workspace.update(cx, |workspace, cx| {
1837 let request = LanguageServerPromptRequest::test(
1838 gpui::PromptLevel::Warning,
1839 "Test notification".to_string(),
1840 vec![], // Empty actions would trigger auto-dismiss if enabled
1841 "test_server".to_string(),
1842 );
1843 let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
1844 workspace.show_notification(notification_id, cx, |cx| {
1845 cx.new(|cx| LanguageServerPrompt::new(request, cx))
1846 });
1847 });
1848
1849 assert_eq!(count_notifications(&workspace, cx), 1);
1850
1851 // Advance time beyond the default auto-dismiss duration
1852 cx.executor().advance_clock(Duration::from_millis(10000));
1853 assert_eq!(count_notifications(&workspace, cx), 1);
1854 }
1855
1856 #[gpui::test]
1857 async fn test_notification_auto_dismiss_with_custom_duration(cx: &mut TestAppContext) {
1858 init_test(cx);
1859
1860 let custom_duration_ms: u64 = 2000;
1861 cx.update(|cx| {
1862 let mut settings = ProjectSettings::get_global(cx).clone();
1863 settings
1864 .global_lsp_settings
1865 .notifications
1866 .dismiss_timeout_ms = Some(custom_duration_ms);
1867 ProjectSettings::override_global(settings, cx);
1868 });
1869
1870 let fs = FakeFs::new(cx.executor());
1871 let project = Project::test(fs, [], cx).await;
1872 let (workspace, cx) =
1873 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1874
1875 let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
1876 workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
1877 };
1878
1879 workspace.update(cx, |workspace, cx| {
1880 let request = LanguageServerPromptRequest::test(
1881 gpui::PromptLevel::Warning,
1882 "Test notification".to_string(),
1883 vec![], // Empty actions triggers auto-dismiss
1884 "test_server".to_string(),
1885 );
1886 let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
1887 workspace.show_notification(notification_id, cx, |cx| {
1888 cx.new(|cx| LanguageServerPrompt::new(request, cx))
1889 });
1890 });
1891
1892 assert_eq!(count_notifications(&workspace, cx), 1);
1893
1894 // Advance time less than custom duration
1895 cx.executor()
1896 .advance_clock(Duration::from_millis(custom_duration_ms - 500));
1897 assert_eq!(count_notifications(&workspace, cx), 1);
1898
1899 // Advance time past the custom duration
1900 cx.executor().advance_clock(Duration::from_millis(1000));
1901 assert_eq!(count_notifications(&workspace, cx), 0);
1902 }
1903}
1904