Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:03:57.119Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

notifications.rs

1904 lines · 73.8 KB · rust
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(&notification, {
92                let id = id.clone();
93                move |this, _, _: &DismissEvent, cx| {
94                    this.dismiss_notification(&id, cx);
95                }
96            })
97            .detach();
98            cx.subscribe(&notification, {
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(&notification_id)
226    }
227
228    pub fn unsuppress(&mut self, notification_id: NotificationId) {
229        self.suppressed_notifications.remove(&notification_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(&notification, {
1470                        let id = id.clone();
1471                        move |_, _, _: &DismissEvent, cx| {
1472                            dismiss_app_notification(&id, cx);
1473                        }
1474                    })
1475                    .detach();
1476                    cx.subscribe(&notification, {
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
Served at tenant.openagents/omega Member data and write actions are omitted.