notifications.rs

   1use crate::{MultiWorkspace, SuppressNotification, Toast, Workspace};
   2use anyhow::Context as _;
   3use gpui::{
   4    AnyEntity, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, ClickEvent, Context,
   5    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle,
   6    Task, TextStyleRefinement, UnderlineStyle, WeakEntity, svg,
   7};
   8use markdown::{Markdown, MarkdownElement, MarkdownStyle};
   9use parking_lot::Mutex;
  10use project::project_settings::ProjectSettings;
  11use settings::Settings;
  12use theme::ThemeSettings;
  13
  14use std::ops::Deref;
  15use std::sync::{Arc, LazyLock};
  16use std::{any::TypeId, time::Duration};
  17use ui::{CopyButton, Tooltip, prelude::*};
  18use util::ResultExt;
  19
  20#[derive(Default)]
  21pub struct Notifications {
  22    notifications: Vec<(NotificationId, AnyView)>,
  23}
  24
  25impl Deref for Notifications {
  26    type Target = Vec<(NotificationId, AnyView)>;
  27
  28    fn deref(&self) -> &Self::Target {
  29        &self.notifications
  30    }
  31}
  32
  33impl std::ops::DerefMut for Notifications {
  34    fn deref_mut(&mut self) -> &mut Self::Target {
  35        &mut self.notifications
  36    }
  37}
  38
  39#[derive(Debug, Eq, PartialEq, Clone, Hash)]
  40pub enum NotificationId {
  41    Unique(TypeId),
  42    Composite(TypeId, ElementId),
  43    Named(SharedString),
  44}
  45
  46impl NotificationId {
  47    /// Returns a unique [`NotificationId`] for the given type.
  48    pub const fn unique<T: 'static>() -> Self {
  49        Self::Unique(TypeId::of::<T>())
  50    }
  51
  52    /// Returns a [`NotificationId`] for the given type that is also identified
  53    /// by the provided ID.
  54    pub fn composite<T: 'static>(id: impl Into<ElementId>) -> Self {
  55        Self::Composite(TypeId::of::<T>(), id.into())
  56    }
  57
  58    /// Builds a `NotificationId` out of the given string.
  59    pub fn named(id: SharedString) -> Self {
  60        Self::Named(id)
  61    }
  62}
  63
  64pub trait Notification:
  65    EventEmitter<DismissEvent> + EventEmitter<SuppressEvent> + Focusable + Render
  66{
  67}
  68
  69pub struct SuppressEvent;
  70
  71impl Workspace {
  72    #[cfg(any(test, feature = "test-support"))]
  73    pub fn notification_ids(&self) -> Vec<NotificationId> {
  74        self.notifications
  75            .iter()
  76            .map(|(id, _)| id)
  77            .cloned()
  78            .collect()
  79    }
  80
  81    pub fn show_notification<V: Notification>(
  82        &mut self,
  83        id: NotificationId,
  84        cx: &mut Context<Self>,
  85        build_notification: impl FnOnce(&mut Context<Self>) -> Entity<V>,
  86    ) {
  87        self.show_notification_without_handling_dismiss_events(&id, cx, |cx| {
  88            let notification = build_notification(cx);
  89            cx.subscribe(&notification, {
  90                let id = id.clone();
  91                move |this, _, _: &DismissEvent, cx| {
  92                    this.dismiss_notification(&id, cx);
  93                }
  94            })
  95            .detach();
  96            cx.subscribe(&notification, {
  97                let id = id.clone();
  98                move |workspace: &mut Workspace, _, _: &SuppressEvent, cx| {
  99                    workspace.suppress_notification(&id, cx);
 100                }
 101            })
 102            .detach();
 103
 104            if let Ok(prompt) =
 105                AnyEntity::from(notification.clone()).downcast::<LanguageServerPrompt>()
 106            {
 107                let is_prompt_without_actions = prompt
 108                    .read(cx)
 109                    .request
 110                    .as_ref()
 111                    .is_some_and(|request| request.actions.is_empty());
 112
 113                let dismiss_timeout_ms = ProjectSettings::get_global(cx)
 114                    .global_lsp_settings
 115                    .notifications
 116                    .dismiss_timeout_ms;
 117
 118                if is_prompt_without_actions {
 119                    if let Some(dismiss_duration_ms) = dismiss_timeout_ms.filter(|&ms| ms > 0) {
 120                        let task = cx.spawn({
 121                            let id = id.clone();
 122                            async move |this, cx| {
 123                                cx.background_executor()
 124                                    .timer(Duration::from_millis(dismiss_duration_ms))
 125                                    .await;
 126                                let _ = this.update(cx, |workspace, cx| {
 127                                    workspace.dismiss_notification(&id, cx);
 128                                });
 129                            }
 130                        });
 131                        prompt.update(cx, |prompt, _| {
 132                            prompt.dismiss_task = Some(task);
 133                        });
 134                    }
 135                }
 136            }
 137            notification.into()
 138        });
 139    }
 140
 141    /// Shows a notification in this workspace's window. Caller must handle dismiss.
 142    ///
 143    /// This exists so that the `build_notification` closures stored for app notifications can
 144    /// return `AnyView`. Subscribing to events from an `AnyView` is not supported, so instead that
 145    /// responsibility is pushed to the caller where the `V` type is known.
 146    pub(crate) fn show_notification_without_handling_dismiss_events(
 147        &mut self,
 148        id: &NotificationId,
 149        cx: &mut Context<Self>,
 150        build_notification: impl FnOnce(&mut Context<Self>) -> AnyView,
 151    ) {
 152        if self.suppressed_notifications.contains(id) {
 153            return;
 154        }
 155        self.dismiss_notification(id, cx);
 156        self.notifications
 157            .push((id.clone(), build_notification(cx)));
 158        cx.notify();
 159    }
 160
 161    pub fn show_error<E>(&mut self, err: &E, cx: &mut Context<Self>)
 162    where
 163        E: std::fmt::Debug + std::fmt::Display,
 164    {
 165        self.show_notification(workspace_error_notification_id(), cx, |cx| {
 166            cx.new(|cx| ErrorMessagePrompt::new(format!("Error: {err}"), cx))
 167        });
 168    }
 169
 170    pub fn show_portal_error(&mut self, err: String, cx: &mut Context<Self>) {
 171        struct PortalError;
 172
 173        self.show_notification(NotificationId::unique::<PortalError>(), cx, |cx| {
 174            cx.new(|cx| {
 175                ErrorMessagePrompt::new(err.to_string(), cx).with_link_button(
 176                    "See docs",
 177                    "https://zed.dev/docs/linux#i-cant-open-any-files",
 178                )
 179            })
 180        });
 181    }
 182
 183    pub fn dismiss_notification(&mut self, id: &NotificationId, cx: &mut Context<Self>) {
 184        self.notifications.retain(|(existing_id, _)| {
 185            if existing_id == id {
 186                cx.notify();
 187                false
 188            } else {
 189                true
 190            }
 191        });
 192    }
 193
 194    pub fn show_toast(&mut self, toast: Toast, cx: &mut Context<Self>) {
 195        self.dismiss_notification(&toast.id, cx);
 196        self.show_notification(toast.id.clone(), cx, |cx| {
 197            cx.new(|cx| match toast.on_click.as_ref() {
 198                Some((click_msg, on_click)) => {
 199                    let on_click = on_click.clone();
 200                    simple_message_notification::MessageNotification::new(toast.msg.clone(), cx)
 201                        .primary_message(click_msg.clone())
 202                        .primary_on_click(move |window, cx| on_click(window, cx))
 203                }
 204                None => {
 205                    simple_message_notification::MessageNotification::new(toast.msg.clone(), cx)
 206                }
 207            })
 208        });
 209        if toast.autohide {
 210            cx.spawn(async move |workspace, cx| {
 211                cx.background_executor()
 212                    .timer(Duration::from_millis(5000))
 213                    .await;
 214                workspace
 215                    .update(cx, |workspace, cx| workspace.dismiss_toast(&toast.id, cx))
 216                    .ok();
 217            })
 218            .detach();
 219        }
 220    }
 221
 222    pub fn dismiss_toast(&mut self, id: &NotificationId, cx: &mut Context<Self>) {
 223        self.dismiss_notification(id, cx);
 224    }
 225
 226    pub fn clear_all_notifications(&mut self, cx: &mut Context<Self>) {
 227        self.notifications.clear();
 228        cx.notify();
 229    }
 230
 231    /// Hide all notifications matching the given ID
 232    pub fn suppress_notification(&mut self, id: &NotificationId, cx: &mut Context<Self>) {
 233        self.dismiss_notification(id, cx);
 234        self.suppressed_notifications.insert(id.clone());
 235    }
 236
 237    pub fn show_initial_notifications(&mut self, cx: &mut Context<Self>) {
 238        // Allow absence of the global so that tests don't need to initialize it.
 239        let app_notifications = GLOBAL_APP_NOTIFICATIONS
 240            .lock()
 241            .app_notifications
 242            .iter()
 243            .cloned()
 244            .collect::<Vec<_>>();
 245        for (id, build_notification) in app_notifications {
 246            self.show_notification_without_handling_dismiss_events(&id, cx, |cx| {
 247                build_notification(cx)
 248            });
 249        }
 250    }
 251}
 252
 253pub struct LanguageServerPrompt {
 254    focus_handle: FocusHandle,
 255    request: Option<project::LanguageServerPromptRequest>,
 256    scroll_handle: ScrollHandle,
 257    markdown: Entity<Markdown>,
 258    dismiss_task: Option<Task<()>>,
 259}
 260
 261impl Focusable for LanguageServerPrompt {
 262    fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
 263        self.focus_handle.clone()
 264    }
 265}
 266
 267impl Notification for LanguageServerPrompt {}
 268
 269impl LanguageServerPrompt {
 270    pub fn new(request: project::LanguageServerPromptRequest, cx: &mut App) -> Self {
 271        let markdown = cx.new(|cx| Markdown::new(request.message.clone().into(), None, None, cx));
 272
 273        Self {
 274            focus_handle: cx.focus_handle(),
 275            request: Some(request),
 276            scroll_handle: ScrollHandle::new(),
 277            markdown,
 278            dismiss_task: None,
 279        }
 280    }
 281
 282    async fn select_option(this: Entity<Self>, ix: usize, cx: &mut AsyncWindowContext) {
 283        util::maybe!(async move {
 284            let potential_future = this.update(cx, |this, _| {
 285                this.request.take().map(|request| request.respond(ix))
 286            });
 287
 288            potential_future
 289                .context("Response already sent")?
 290                .await
 291                .context("Stream already closed")?;
 292
 293            this.update(cx, |this, cx| {
 294                this.dismiss_notification(cx);
 295            });
 296
 297            anyhow::Ok(())
 298        })
 299        .await
 300        .log_err();
 301    }
 302
 303    fn dismiss_notification(&mut self, cx: &mut Context<Self>) {
 304        self.dismiss_task = None;
 305        cx.emit(DismissEvent);
 306    }
 307}
 308
 309impl Render for LanguageServerPrompt {
 310    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 311        let Some(request) = &self.request else {
 312            return div().id("language_server_prompt_notification");
 313        };
 314
 315        let (icon, color) = match request.level {
 316            PromptLevel::Info => (IconName::Info, Color::Muted),
 317            PromptLevel::Warning => (IconName::Warning, Color::Warning),
 318            PromptLevel::Critical => (IconName::XCircle, Color::Error),
 319        };
 320
 321        let suppress = window.modifiers().shift;
 322        let (close_id, close_icon) = if suppress {
 323            ("suppress", IconName::Minimize)
 324        } else {
 325            ("close", IconName::Close)
 326        };
 327
 328        div()
 329            .id("language_server_prompt_notification")
 330            .group("language_server_prompt_notification")
 331            .occlude()
 332            .w_full()
 333            .max_h(vh(0.8, window))
 334            .elevation_3(cx)
 335            .overflow_y_scroll()
 336            .track_scroll(&self.scroll_handle)
 337            .on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify()))
 338            .child(
 339                v_flex()
 340                    .p_3()
 341                    .overflow_hidden()
 342                    .child(
 343                        h_flex()
 344                            .justify_between()
 345                            .child(
 346                                h_flex()
 347                                    .gap_2()
 348                                    .child(Icon::new(icon).color(color).size(IconSize::Small))
 349                                    .child(Label::new(request.lsp_name.clone())),
 350                            )
 351                            .child(
 352                                h_flex()
 353                                    .gap_1()
 354                                    .child(
 355                                        CopyButton::new(
 356                                            "copy-description",
 357                                            request.message.clone(),
 358                                        )
 359                                        .tooltip_label("Copy Description"),
 360                                    )
 361                                    .child(
 362                                        IconButton::new(close_id, close_icon)
 363                                            .tooltip(move |_window, cx| {
 364                                                if suppress {
 365                                                    Tooltip::with_meta(
 366                                                        "Suppress",
 367                                                        Some(&SuppressNotification),
 368                                                        "Click to close",
 369                                                        cx,
 370                                                    )
 371                                                } else {
 372                                                    Tooltip::with_meta(
 373                                                        "Close",
 374                                                        Some(&menu::Cancel),
 375                                                        "Suppress with shift-click",
 376                                                        cx,
 377                                                    )
 378                                                }
 379                                            })
 380                                            .on_click(cx.listener(
 381                                                move |this, _: &ClickEvent, _, cx| {
 382                                                    if suppress {
 383                                                        cx.emit(SuppressEvent);
 384                                                    } else {
 385                                                        this.dismiss_notification(cx);
 386                                                    }
 387                                                },
 388                                            )),
 389                                    ),
 390                            ),
 391                    )
 392                    .child(
 393                        MarkdownElement::new(self.markdown.clone(), markdown_style(window, cx))
 394                            .text_size(TextSize::Small.rems(cx))
 395                            .code_block_renderer(markdown::CodeBlockRenderer::Default {
 396                                copy_button: false,
 397                                copy_button_on_hover: false,
 398                                border: false,
 399                            })
 400                            .on_url_click(|link, _, cx| cx.open_url(&link)),
 401                    )
 402                    .children(request.actions.iter().enumerate().map(|(ix, action)| {
 403                        let this_handle = cx.entity();
 404                        Button::new(ix, action.title.clone())
 405                            .size(ButtonSize::Large)
 406                            .on_click(move |_, window, cx| {
 407                                let this_handle = this_handle.clone();
 408                                window
 409                                    .spawn(cx, async move |cx| {
 410                                        LanguageServerPrompt::select_option(this_handle, ix, cx)
 411                                            .await
 412                                    })
 413                                    .detach()
 414                            })
 415                    })),
 416            )
 417    }
 418}
 419
 420impl EventEmitter<DismissEvent> for LanguageServerPrompt {}
 421impl EventEmitter<SuppressEvent> for LanguageServerPrompt {}
 422
 423fn workspace_error_notification_id() -> NotificationId {
 424    struct WorkspaceErrorNotification;
 425    NotificationId::unique::<WorkspaceErrorNotification>()
 426}
 427
 428fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
 429    let settings = ThemeSettings::get_global(cx);
 430    let ui_font_family = settings.ui_font.family.clone();
 431    let ui_font_fallbacks = settings.ui_font.fallbacks.clone();
 432    let buffer_font_family = settings.buffer_font.family.clone();
 433    let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone();
 434
 435    let mut base_text_style = window.text_style();
 436    base_text_style.refine(&TextStyleRefinement {
 437        font_family: Some(ui_font_family),
 438        font_fallbacks: ui_font_fallbacks,
 439        color: Some(cx.theme().colors().text),
 440        ..Default::default()
 441    });
 442
 443    MarkdownStyle {
 444        base_text_style,
 445        selection_background_color: cx.theme().colors().element_selection_background,
 446        inline_code: TextStyleRefinement {
 447            background_color: Some(cx.theme().colors().editor_background.opacity(0.5)),
 448            font_family: Some(buffer_font_family),
 449            font_fallbacks: buffer_font_fallbacks,
 450            ..Default::default()
 451        },
 452        link: TextStyleRefinement {
 453            underline: Some(UnderlineStyle {
 454                thickness: px(1.),
 455                color: Some(cx.theme().colors().text_accent),
 456                wavy: false,
 457            }),
 458            ..Default::default()
 459        },
 460        ..Default::default()
 461    }
 462}
 463
 464#[derive(Debug, Clone)]
 465pub struct ErrorMessagePrompt {
 466    message: SharedString,
 467    focus_handle: gpui::FocusHandle,
 468    label_and_url_button: Option<(SharedString, SharedString)>,
 469}
 470
 471impl ErrorMessagePrompt {
 472    pub fn new<S>(message: S, cx: &mut App) -> Self
 473    where
 474        S: Into<SharedString>,
 475    {
 476        Self {
 477            message: message.into(),
 478            focus_handle: cx.focus_handle(),
 479            label_and_url_button: None,
 480        }
 481    }
 482
 483    pub fn with_link_button<S>(mut self, label: S, url: S) -> Self
 484    where
 485        S: Into<SharedString>,
 486    {
 487        self.label_and_url_button = Some((label.into(), url.into()));
 488        self
 489    }
 490}
 491
 492impl Render for ErrorMessagePrompt {
 493    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 494        h_flex()
 495            .id("error_message_prompt_notification")
 496            .occlude()
 497            .elevation_3(cx)
 498            .items_start()
 499            .justify_between()
 500            .p_2()
 501            .gap_2()
 502            .w_full()
 503            .child(
 504                v_flex()
 505                    .w_full()
 506                    .child(
 507                        h_flex()
 508                            .w_full()
 509                            .justify_between()
 510                            .child(
 511                                svg()
 512                                    .size(window.text_style().font_size)
 513                                    .flex_none()
 514                                    .mr_2()
 515                                    .mt(px(-2.0))
 516                                    .map(|icon| {
 517                                        icon.path(IconName::Warning.path())
 518                                            .text_color(Color::Error.color(cx))
 519                                    }),
 520                            )
 521                            .child(
 522                                h_flex()
 523                                    .gap_1()
 524                                    .child(
 525                                        CopyButton::new("copy-error-message", self.message.clone())
 526                                            .tooltip_label("Copy Error Message"),
 527                                    )
 528                                    .child(
 529                                        ui::IconButton::new("close", ui::IconName::Close).on_click(
 530                                            cx.listener(|_, _, _, cx| cx.emit(DismissEvent)),
 531                                        ),
 532                                    ),
 533                            ),
 534                    )
 535                    .child(
 536                        div()
 537                            .id("error_message")
 538                            .max_w_96()
 539                            .max_h_40()
 540                            .overflow_y_scroll()
 541                            .child(Label::new(self.message.clone()).size(LabelSize::Small)),
 542                    )
 543                    .when_some(self.label_and_url_button.clone(), |elm, (label, url)| {
 544                        elm.child(
 545                            div().mt_2().child(
 546                                ui::Button::new("error_message_prompt_notification_button", label)
 547                                    .on_click(move |_, _, cx| cx.open_url(&url)),
 548                            ),
 549                        )
 550                    }),
 551            )
 552    }
 553}
 554
 555impl Focusable for ErrorMessagePrompt {
 556    fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
 557        self.focus_handle.clone()
 558    }
 559}
 560
 561impl EventEmitter<DismissEvent> for ErrorMessagePrompt {}
 562impl EventEmitter<SuppressEvent> for ErrorMessagePrompt {}
 563
 564impl Notification for ErrorMessagePrompt {}
 565
 566#[derive(IntoElement, RegisterComponent)]
 567pub struct NotificationFrame {
 568    title: Option<SharedString>,
 569    show_suppress_button: bool,
 570    show_close_button: bool,
 571    close: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
 572    contents: Option<AnyElement>,
 573    suffix: Option<AnyElement>,
 574}
 575
 576impl NotificationFrame {
 577    pub fn new() -> Self {
 578        Self {
 579            title: None,
 580            contents: None,
 581            suffix: None,
 582            show_suppress_button: true,
 583            show_close_button: true,
 584            close: None,
 585        }
 586    }
 587
 588    pub fn with_title(mut self, title: Option<impl Into<SharedString>>) -> Self {
 589        self.title = title.map(Into::into);
 590        self
 591    }
 592
 593    pub fn with_content(self, content: impl IntoElement) -> Self {
 594        Self {
 595            contents: Some(content.into_any_element()),
 596            ..self
 597        }
 598    }
 599
 600    /// Determines whether the given notification ID should be suppressible
 601    /// Suppressed notifications will not be shown anymore
 602    pub fn show_suppress_button(mut self, show: bool) -> Self {
 603        self.show_suppress_button = show;
 604        self
 605    }
 606
 607    pub fn show_close_button(mut self, show: bool) -> Self {
 608        self.show_close_button = show;
 609        self
 610    }
 611
 612    pub fn on_close(self, on_close: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
 613        Self {
 614            close: Some(Box::new(on_close)),
 615            ..self
 616        }
 617    }
 618
 619    pub fn with_suffix(mut self, suffix: impl IntoElement) -> Self {
 620        self.suffix = Some(suffix.into_any_element());
 621        self
 622    }
 623}
 624
 625impl RenderOnce for NotificationFrame {
 626    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
 627        let entity = window.current_view();
 628        let show_suppress_button = self.show_suppress_button;
 629        let suppress = show_suppress_button && window.modifiers().shift;
 630        let (close_id, close_icon) = if suppress {
 631            ("suppress", IconName::Minimize)
 632        } else {
 633            ("close", IconName::Close)
 634        };
 635
 636        v_flex()
 637            .occlude()
 638            .p_3()
 639            .gap_2()
 640            .elevation_3(cx)
 641            .child(
 642                h_flex()
 643                    .gap_4()
 644                    .justify_between()
 645                    .items_start()
 646                    .child(
 647                        v_flex()
 648                            .gap_0p5()
 649                            .when_some(self.title.clone(), |div, title| {
 650                                div.child(Label::new(title))
 651                            })
 652                            .child(div().max_w_96().children(self.contents)),
 653                    )
 654                    .when(self.show_close_button, |this| {
 655                        this.on_modifiers_changed(move |_, _, cx| cx.notify(entity))
 656                            .child(
 657                                IconButton::new(close_id, close_icon)
 658                                    .tooltip(move |_window, cx| {
 659                                        if suppress {
 660                                            Tooltip::with_meta(
 661                                                "Suppress",
 662                                                Some(&SuppressNotification),
 663                                                "Click to Close",
 664                                                cx,
 665                                            )
 666                                        } else if show_suppress_button {
 667                                            Tooltip::with_meta(
 668                                                "Close",
 669                                                Some(&menu::Cancel),
 670                                                "Shift-click to Suppress",
 671                                                cx,
 672                                            )
 673                                        } else {
 674                                            Tooltip::for_action("Close", &menu::Cancel, cx)
 675                                        }
 676                                    })
 677                                    .on_click({
 678                                        let close = self.close.take();
 679                                        move |_, window, cx| {
 680                                            if let Some(close) = &close {
 681                                                close(&suppress, window, cx)
 682                                            }
 683                                        }
 684                                    }),
 685                            )
 686                    }),
 687            )
 688            .children(self.suffix)
 689    }
 690}
 691
 692impl Component for NotificationFrame {}
 693
 694pub mod simple_message_notification {
 695    use std::sync::Arc;
 696
 697    use gpui::{
 698        AnyElement, DismissEvent, EventEmitter, FocusHandle, Focusable, ParentElement, Render,
 699        ScrollHandle, SharedString, Styled,
 700    };
 701    use ui::{WithScrollbar, prelude::*};
 702
 703    use crate::notifications::NotificationFrame;
 704
 705    use super::{Notification, SuppressEvent};
 706
 707    pub struct MessageNotification {
 708        focus_handle: FocusHandle,
 709        build_content: Box<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>,
 710        primary_message: Option<SharedString>,
 711        primary_icon: Option<IconName>,
 712        primary_icon_color: Option<Color>,
 713        primary_on_click: Option<Arc<dyn Fn(&mut Window, &mut Context<Self>)>>,
 714        secondary_message: Option<SharedString>,
 715        secondary_icon: Option<IconName>,
 716        secondary_icon_color: Option<Color>,
 717        secondary_on_click: Option<Arc<dyn Fn(&mut Window, &mut Context<Self>)>>,
 718        more_info_message: Option<SharedString>,
 719        more_info_url: Option<Arc<str>>,
 720        show_close_button: bool,
 721        show_suppress_button: bool,
 722        title: Option<SharedString>,
 723        scroll_handle: ScrollHandle,
 724    }
 725
 726    impl Focusable for MessageNotification {
 727        fn focus_handle(&self, _: &App) -> FocusHandle {
 728            self.focus_handle.clone()
 729        }
 730    }
 731
 732    impl EventEmitter<DismissEvent> for MessageNotification {}
 733    impl EventEmitter<SuppressEvent> for MessageNotification {}
 734
 735    impl Notification for MessageNotification {}
 736
 737    impl MessageNotification {
 738        pub fn new<S>(message: S, cx: &mut App) -> MessageNotification
 739        where
 740            S: Into<SharedString>,
 741        {
 742            let message = message.into();
 743            Self::new_from_builder(cx, move |_, _| {
 744                Label::new(message.clone()).into_any_element()
 745            })
 746        }
 747
 748        pub fn new_from_builder<F>(cx: &mut App, content: F) -> MessageNotification
 749        where
 750            F: 'static + Fn(&mut Window, &mut Context<Self>) -> AnyElement,
 751        {
 752            Self {
 753                build_content: Box::new(content),
 754                primary_message: None,
 755                primary_icon: None,
 756                primary_icon_color: None,
 757                primary_on_click: None,
 758                secondary_message: None,
 759                secondary_icon: None,
 760                secondary_icon_color: None,
 761                secondary_on_click: None,
 762                more_info_message: None,
 763                more_info_url: None,
 764                show_close_button: true,
 765                show_suppress_button: true,
 766                title: None,
 767                focus_handle: cx.focus_handle(),
 768                scroll_handle: ScrollHandle::new(),
 769            }
 770        }
 771
 772        pub fn primary_message<S>(mut self, message: S) -> Self
 773        where
 774            S: Into<SharedString>,
 775        {
 776            self.primary_message = Some(message.into());
 777            self
 778        }
 779
 780        pub fn primary_icon(mut self, icon: IconName) -> Self {
 781            self.primary_icon = Some(icon);
 782            self
 783        }
 784
 785        pub fn primary_icon_color(mut self, color: Color) -> Self {
 786            self.primary_icon_color = Some(color);
 787            self
 788        }
 789
 790        pub fn primary_on_click<F>(mut self, on_click: F) -> Self
 791        where
 792            F: 'static + Fn(&mut Window, &mut Context<Self>),
 793        {
 794            self.primary_on_click = Some(Arc::new(on_click));
 795            self
 796        }
 797
 798        pub fn primary_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.primary_on_click = Some(on_click);
 803            self
 804        }
 805
 806        pub fn secondary_message<S>(mut self, message: S) -> Self
 807        where
 808            S: Into<SharedString>,
 809        {
 810            self.secondary_message = Some(message.into());
 811            self
 812        }
 813
 814        pub fn secondary_icon(mut self, icon: IconName) -> Self {
 815            self.secondary_icon = Some(icon);
 816            self
 817        }
 818
 819        pub fn secondary_icon_color(mut self, color: Color) -> Self {
 820            self.secondary_icon_color = Some(color);
 821            self
 822        }
 823
 824        pub fn secondary_on_click<F>(mut self, on_click: F) -> Self
 825        where
 826            F: 'static + Fn(&mut Window, &mut Context<Self>),
 827        {
 828            self.secondary_on_click = Some(Arc::new(on_click));
 829            self
 830        }
 831
 832        pub fn secondary_on_click_arc<F>(mut self, on_click: Arc<F>) -> Self
 833        where
 834            F: 'static + Fn(&mut Window, &mut Context<Self>),
 835        {
 836            self.secondary_on_click = Some(on_click);
 837            self
 838        }
 839
 840        pub fn more_info_message<S>(mut self, message: S) -> Self
 841        where
 842            S: Into<SharedString>,
 843        {
 844            self.more_info_message = Some(message.into());
 845            self
 846        }
 847
 848        pub fn more_info_url<S>(mut self, url: S) -> Self
 849        where
 850            S: Into<Arc<str>>,
 851        {
 852            self.more_info_url = Some(url.into());
 853            self
 854        }
 855
 856        pub fn dismiss(&mut self, cx: &mut Context<Self>) {
 857            cx.emit(DismissEvent);
 858        }
 859
 860        pub fn show_close_button(mut self, show: bool) -> Self {
 861            self.show_close_button = show;
 862            self
 863        }
 864
 865        /// Determines whether the given notification ID should be suppressible
 866        /// Suppressed notifications will not be shown anymor
 867        pub fn show_suppress_button(mut self, show: bool) -> Self {
 868            self.show_suppress_button = show;
 869            self
 870        }
 871
 872        pub fn with_title<S>(mut self, title: S) -> Self
 873        where
 874            S: Into<SharedString>,
 875        {
 876            self.title = Some(title.into());
 877            self
 878        }
 879    }
 880
 881    impl Render for MessageNotification {
 882        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 883            NotificationFrame::new()
 884                .with_title(self.title.clone())
 885                .with_content(
 886                    div()
 887                        .child(
 888                            div()
 889                                .id("message-notification-content")
 890                                .max_h(vh(0.6, window))
 891                                .overflow_y_scroll()
 892                                .track_scroll(&self.scroll_handle.clone())
 893                                .child((self.build_content)(window, cx)),
 894                        )
 895                        .vertical_scrollbar_for(&self.scroll_handle, window, cx),
 896                )
 897                .show_close_button(self.show_close_button)
 898                .show_suppress_button(self.show_suppress_button)
 899                .on_close(cx.listener(|_, suppress, _, cx| {
 900                    if *suppress {
 901                        cx.emit(SuppressEvent);
 902                    } else {
 903                        cx.emit(DismissEvent);
 904                    }
 905                }))
 906                .with_suffix(
 907                    h_flex()
 908                        .gap_1()
 909                        .children(self.primary_message.iter().map(|message| {
 910                            let mut button = Button::new(message.clone(), message.clone())
 911                                .label_size(LabelSize::Small)
 912                                .on_click(cx.listener(|this, _, window, cx| {
 913                                    if let Some(on_click) = this.primary_on_click.as_ref() {
 914                                        (on_click)(window, cx)
 915                                    };
 916                                    this.dismiss(cx)
 917                                }));
 918
 919                            if let Some(icon) = self.primary_icon {
 920                                button = button.start_icon(
 921                                    Icon::new(icon)
 922                                        .size(IconSize::Small)
 923                                        .color(self.primary_icon_color.unwrap_or(Color::Muted)),
 924                                );
 925                            }
 926
 927                            button
 928                        }))
 929                        .children(self.secondary_message.iter().map(|message| {
 930                            let mut button = Button::new(message.clone(), message.clone())
 931                                .label_size(LabelSize::Small)
 932                                .on_click(cx.listener(|this, _, window, cx| {
 933                                    if let Some(on_click) = this.secondary_on_click.as_ref() {
 934                                        (on_click)(window, cx)
 935                                    };
 936                                    this.dismiss(cx)
 937                                }));
 938
 939                            if let Some(icon) = self.secondary_icon {
 940                                button = button.start_icon(
 941                                    Icon::new(icon)
 942                                        .size(IconSize::Small)
 943                                        .color(self.secondary_icon_color.unwrap_or(Color::Muted)),
 944                                );
 945                            }
 946
 947                            button
 948                        }))
 949                        .child(
 950                            h_flex().w_full().justify_end().children(
 951                                self.more_info_message
 952                                    .iter()
 953                                    .zip(self.more_info_url.iter())
 954                                    .map(|(message, url)| {
 955                                        let url = url.clone();
 956                                        Button::new(message.clone(), message.clone())
 957                                            .label_size(LabelSize::Small)
 958                                            .end_icon(
 959                                                Icon::new(IconName::ArrowUpRight)
 960                                                    .size(IconSize::Indicator)
 961                                                    .color(Color::Muted),
 962                                            )
 963                                            .on_click(cx.listener(move |_, _, _, cx| {
 964                                                cx.open_url(&url);
 965                                            }))
 966                                    }),
 967                            ),
 968                        ),
 969                )
 970        }
 971    }
 972}
 973
 974static GLOBAL_APP_NOTIFICATIONS: LazyLock<Mutex<AppNotifications>> = LazyLock::new(|| {
 975    Mutex::new(AppNotifications {
 976        app_notifications: Vec::new(),
 977    })
 978});
 979
 980/// Stores app notifications so that they can be shown in new workspaces.
 981struct AppNotifications {
 982    app_notifications: Vec<(
 983        NotificationId,
 984        Arc<dyn Fn(&mut Context<Workspace>) -> AnyView + Send + Sync>,
 985    )>,
 986}
 987
 988impl AppNotifications {
 989    pub fn insert(
 990        &mut self,
 991        id: NotificationId,
 992        build_notification: Arc<dyn Fn(&mut Context<Workspace>) -> AnyView + Send + Sync>,
 993    ) {
 994        self.remove(&id);
 995        self.app_notifications.push((id, build_notification))
 996    }
 997
 998    pub fn remove(&mut self, id: &NotificationId) {
 999        self.app_notifications
1000            .retain(|(existing_id, _)| existing_id != id);
1001    }
1002}
1003
1004/// Shows a notification in all workspaces. New workspaces will also receive the notification - this
1005/// is particularly to handle notifications that occur on initialization before any workspaces
1006/// exist. If the notification is dismissed within any workspace, it will be removed from all.
1007pub fn show_app_notification<V: Notification + 'static>(
1008    id: NotificationId,
1009    cx: &mut App,
1010    build_notification: impl Fn(&mut Context<Workspace>) -> Entity<V> + 'static + Send + Sync,
1011) {
1012    // Defer notification creation so that windows on the stack can be returned to GPUI
1013    cx.defer(move |cx| {
1014        // Handle dismiss events by removing the notification from all workspaces.
1015        let build_notification: Arc<dyn Fn(&mut Context<Workspace>) -> AnyView + Send + Sync> =
1016            Arc::new({
1017                let id = id.clone();
1018                move |cx| {
1019                    let notification = build_notification(cx);
1020                    cx.subscribe(&notification, {
1021                        let id = id.clone();
1022                        move |_, _, _: &DismissEvent, cx| {
1023                            dismiss_app_notification(&id, cx);
1024                        }
1025                    })
1026                    .detach();
1027                    cx.subscribe(&notification, {
1028                        let id = id.clone();
1029                        move |workspace: &mut Workspace, _, _: &SuppressEvent, cx| {
1030                            workspace.suppress_notification(&id, cx);
1031                        }
1032                    })
1033                    .detach();
1034                    notification.into()
1035                }
1036            });
1037
1038        // Store the notification so that new workspaces also receive it.
1039        GLOBAL_APP_NOTIFICATIONS
1040            .lock()
1041            .insert(id.clone(), build_notification.clone());
1042
1043        for window in cx.windows() {
1044            if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
1045                multi_workspace
1046                    .update(cx, |multi_workspace, _window, cx| {
1047                        for workspace in multi_workspace.workspaces() {
1048                            workspace.update(cx, |workspace, cx| {
1049                                workspace.show_notification_without_handling_dismiss_events(
1050                                    &id,
1051                                    cx,
1052                                    |cx| build_notification(cx),
1053                                );
1054                            });
1055                        }
1056                    })
1057                    .ok(); // Doesn't matter if the windows are dropped
1058            }
1059        }
1060    });
1061}
1062
1063pub fn dismiss_app_notification(id: &NotificationId, cx: &mut App) {
1064    let id = id.clone();
1065    // Defer notification dismissal so that windows on the stack can be returned to GPUI
1066    cx.defer(move |cx| {
1067        GLOBAL_APP_NOTIFICATIONS.lock().remove(&id);
1068        for window in cx.windows() {
1069            if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
1070                let id = id.clone();
1071                multi_workspace
1072                    .update(cx, |multi_workspace, _window, cx| {
1073                        for workspace in multi_workspace.workspaces() {
1074                            workspace.update(cx, |workspace, cx| {
1075                                workspace.dismiss_notification(&id, cx)
1076                            });
1077                        }
1078                    })
1079                    .ok();
1080            }
1081        }
1082    });
1083}
1084
1085pub trait NotifyResultExt {
1086    type Ok;
1087
1088    fn notify_err(self, workspace: &mut Workspace, cx: &mut Context<Workspace>)
1089    -> Option<Self::Ok>;
1090
1091    fn notify_workspace_async_err(
1092        self,
1093        workspace: WeakEntity<Workspace>,
1094        cx: &mut AsyncApp,
1095    ) -> Option<Self::Ok>;
1096
1097    /// Notifies the active workspace if there is one, otherwise notifies all workspaces.
1098    fn notify_app_err(self, cx: &mut App) -> Option<Self::Ok>;
1099}
1100
1101impl<T, E> NotifyResultExt for std::result::Result<T, E>
1102where
1103    E: std::fmt::Debug + std::fmt::Display,
1104{
1105    type Ok = T;
1106
1107    fn notify_err(self, workspace: &mut Workspace, cx: &mut Context<Workspace>) -> Option<T> {
1108        match self {
1109            Ok(value) => Some(value),
1110            Err(err) => {
1111                log::error!("Showing error notification in workspace: {err:?}");
1112                workspace.show_error(&err, cx);
1113                None
1114            }
1115        }
1116    }
1117
1118    fn notify_workspace_async_err(
1119        self,
1120        workspace: WeakEntity<Workspace>,
1121        cx: &mut AsyncApp,
1122    ) -> Option<T> {
1123        match self {
1124            Ok(value) => Some(value),
1125            Err(err) => {
1126                log::error!("{err:?}");
1127                workspace
1128                    .update(cx, |workspace, cx| workspace.show_error(&err, cx))
1129                    .ok();
1130                None
1131            }
1132        }
1133    }
1134
1135    fn notify_app_err(self, cx: &mut App) -> Option<T> {
1136        match self {
1137            Ok(value) => Some(value),
1138            Err(err) => {
1139                let message: SharedString = format!("Error: {err}").into();
1140                log::error!("Showing error notification in app: {message}");
1141                show_app_notification(workspace_error_notification_id(), cx, {
1142                    move |cx| {
1143                        cx.new({
1144                            let message = message.clone();
1145                            move |cx| ErrorMessagePrompt::new(message, cx)
1146                        })
1147                    }
1148                });
1149
1150                None
1151            }
1152        }
1153    }
1154}
1155
1156pub trait NotifyTaskExt {
1157    fn detach_and_notify_err(
1158        self,
1159        workspace: WeakEntity<Workspace>,
1160        window: &mut Window,
1161        cx: &mut App,
1162    );
1163}
1164
1165impl<R, E> NotifyTaskExt for Task<std::result::Result<R, E>>
1166where
1167    E: std::fmt::Debug + std::fmt::Display + Sized + 'static,
1168    R: 'static,
1169{
1170    fn detach_and_notify_err(
1171        self,
1172        workspace: WeakEntity<Workspace>,
1173        window: &mut Window,
1174        cx: &mut App,
1175    ) {
1176        window
1177            .spawn(cx, async move |mut cx| {
1178                self.await.notify_workspace_async_err(workspace, &mut cx)
1179            })
1180            .detach();
1181    }
1182}
1183
1184pub trait DetachAndPromptErr<R> {
1185    fn prompt_err(
1186        self,
1187        msg: &str,
1188        window: &Window,
1189        cx: &App,
1190        f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
1191    ) -> Task<Option<R>>;
1192
1193    fn detach_and_prompt_err(
1194        self,
1195        msg: &str,
1196        window: &Window,
1197        cx: &App,
1198        f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
1199    );
1200}
1201
1202impl<R> DetachAndPromptErr<R> for Task<anyhow::Result<R>>
1203where
1204    R: 'static,
1205{
1206    fn prompt_err(
1207        self,
1208        msg: &str,
1209        window: &Window,
1210        cx: &App,
1211        f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
1212    ) -> Task<Option<R>> {
1213        let msg = msg.to_owned();
1214        window.spawn(cx, async move |cx| {
1215            let result = self.await;
1216            if let Err(err) = result.as_ref() {
1217                log::error!("{err:#}");
1218                if let Ok(prompt) = cx.update(|window, cx| {
1219                    let mut display = format!("{err:#}");
1220                    if !display.ends_with('\n') {
1221                        display.push('.');
1222                        display.push(' ')
1223                    }
1224                    let detail =
1225                        f(err, window, cx).unwrap_or_else(|| format!("{display}Please try again."));
1226                    window.prompt(PromptLevel::Critical, &msg, Some(&detail), &["Ok"], cx)
1227                }) {
1228                    prompt.await.ok();
1229                }
1230                return None;
1231            }
1232            Some(result.unwrap())
1233        })
1234    }
1235
1236    fn detach_and_prompt_err(
1237        self,
1238        msg: &str,
1239        window: &Window,
1240        cx: &App,
1241        f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
1242    ) {
1243        self.prompt_err(msg, window, cx, f).detach();
1244    }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use fs::FakeFs;
1250    use gpui::TestAppContext;
1251    use project::{LanguageServerPromptRequest, Project};
1252
1253    use crate::tests::init_test;
1254
1255    use super::*;
1256
1257    #[gpui::test]
1258    async fn test_notification_auto_dismiss_with_notifications_from_multiple_language_servers(
1259        cx: &mut TestAppContext,
1260    ) {
1261        init_test(cx);
1262
1263        let fs = FakeFs::new(cx.executor());
1264        let project = Project::test(fs, [], cx).await;
1265
1266        let (workspace, cx) =
1267            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1268
1269        let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
1270            workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
1271        };
1272
1273        let show_notification = |workspace: &Entity<Workspace>,
1274                                 cx: &mut TestAppContext,
1275                                 lsp_name: &str| {
1276            workspace.update(cx, |workspace, cx| {
1277                let request = LanguageServerPromptRequest::test(
1278                    gpui::PromptLevel::Warning,
1279                    "Test notification".to_string(),
1280                    vec![], // Empty actions triggers auto-dismiss
1281                    lsp_name.to_string(),
1282                );
1283                let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
1284                workspace.show_notification(notification_id, cx, |cx| {
1285                    cx.new(|cx| LanguageServerPrompt::new(request, cx))
1286                });
1287            })
1288        };
1289
1290        show_notification(&workspace, cx, "Lsp1");
1291        assert_eq!(count_notifications(&workspace, cx), 1);
1292
1293        cx.executor().advance_clock(Duration::from_millis(1000));
1294
1295        show_notification(&workspace, cx, "Lsp2");
1296        assert_eq!(count_notifications(&workspace, cx), 2);
1297
1298        cx.executor().advance_clock(Duration::from_millis(1000));
1299
1300        show_notification(&workspace, cx, "Lsp3");
1301        assert_eq!(count_notifications(&workspace, cx), 3);
1302
1303        cx.executor().advance_clock(Duration::from_millis(3000));
1304        assert_eq!(count_notifications(&workspace, cx), 2);
1305
1306        cx.executor().advance_clock(Duration::from_millis(1000));
1307        assert_eq!(count_notifications(&workspace, cx), 1);
1308
1309        cx.executor().advance_clock(Duration::from_millis(1000));
1310        assert_eq!(count_notifications(&workspace, cx), 0);
1311    }
1312
1313    #[gpui::test]
1314    async fn test_notification_auto_dismiss_with_multiple_notifications_from_single_language_server(
1315        cx: &mut TestAppContext,
1316    ) {
1317        init_test(cx);
1318
1319        let lsp_name = "server1";
1320
1321        let fs = FakeFs::new(cx.executor());
1322        let project = Project::test(fs, [], cx).await;
1323        let (workspace, cx) =
1324            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1325
1326        let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
1327            workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
1328        };
1329
1330        let show_notification = |lsp_name: &str,
1331                                 workspace: &Entity<Workspace>,
1332                                 cx: &mut TestAppContext| {
1333            workspace.update(cx, |workspace, cx| {
1334                let lsp_name = lsp_name.to_string();
1335                let request = LanguageServerPromptRequest::test(
1336                    gpui::PromptLevel::Warning,
1337                    "Test notification".to_string(),
1338                    vec![], // Empty actions triggers auto-dismiss
1339                    lsp_name,
1340                );
1341                let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
1342
1343                workspace.show_notification(notification_id, cx, |cx| {
1344                    cx.new(|cx| LanguageServerPrompt::new(request, cx))
1345                });
1346            })
1347        };
1348
1349        show_notification(lsp_name, &workspace, cx);
1350        assert_eq!(count_notifications(&workspace, cx), 1);
1351
1352        cx.executor().advance_clock(Duration::from_millis(1000));
1353
1354        show_notification(lsp_name, &workspace, cx);
1355        assert_eq!(count_notifications(&workspace, cx), 2);
1356
1357        cx.executor().advance_clock(Duration::from_millis(4000));
1358        assert_eq!(count_notifications(&workspace, cx), 1);
1359
1360        cx.executor().advance_clock(Duration::from_millis(1000));
1361        assert_eq!(count_notifications(&workspace, cx), 0);
1362    }
1363
1364    #[gpui::test]
1365    async fn test_notification_auto_dismiss_turned_off(cx: &mut TestAppContext) {
1366        init_test(cx);
1367
1368        cx.update(|cx| {
1369            let mut settings = ProjectSettings::get_global(cx).clone();
1370            settings
1371                .global_lsp_settings
1372                .notifications
1373                .dismiss_timeout_ms = Some(0);
1374            ProjectSettings::override_global(settings, cx);
1375        });
1376
1377        let fs = FakeFs::new(cx.executor());
1378        let project = Project::test(fs, [], cx).await;
1379        let (workspace, cx) =
1380            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1381
1382        let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
1383            workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
1384        };
1385
1386        workspace.update(cx, |workspace, cx| {
1387            let request = LanguageServerPromptRequest::test(
1388                gpui::PromptLevel::Warning,
1389                "Test notification".to_string(),
1390                vec![], // Empty actions would trigger auto-dismiss if enabled
1391                "test_server".to_string(),
1392            );
1393            let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
1394            workspace.show_notification(notification_id, cx, |cx| {
1395                cx.new(|cx| LanguageServerPrompt::new(request, cx))
1396            });
1397        });
1398
1399        assert_eq!(count_notifications(&workspace, cx), 1);
1400
1401        // Advance time beyond the default auto-dismiss duration
1402        cx.executor().advance_clock(Duration::from_millis(10000));
1403        assert_eq!(count_notifications(&workspace, cx), 1);
1404    }
1405
1406    #[gpui::test]
1407    async fn test_notification_auto_dismiss_with_custom_duration(cx: &mut TestAppContext) {
1408        init_test(cx);
1409
1410        let custom_duration_ms: u64 = 2000;
1411        cx.update(|cx| {
1412            let mut settings = ProjectSettings::get_global(cx).clone();
1413            settings
1414                .global_lsp_settings
1415                .notifications
1416                .dismiss_timeout_ms = Some(custom_duration_ms);
1417            ProjectSettings::override_global(settings, cx);
1418        });
1419
1420        let fs = FakeFs::new(cx.executor());
1421        let project = Project::test(fs, [], cx).await;
1422        let (workspace, cx) =
1423            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1424
1425        let count_notifications = |workspace: &Entity<Workspace>, cx: &mut TestAppContext| {
1426            workspace.read_with(cx, |workspace, _| workspace.notification_ids().len())
1427        };
1428
1429        workspace.update(cx, |workspace, cx| {
1430            let request = LanguageServerPromptRequest::test(
1431                gpui::PromptLevel::Warning,
1432                "Test notification".to_string(),
1433                vec![], // Empty actions triggers auto-dismiss
1434                "test_server".to_string(),
1435            );
1436            let notification_id = NotificationId::composite::<LanguageServerPrompt>(request.id);
1437            workspace.show_notification(notification_id, cx, |cx| {
1438                cx.new(|cx| LanguageServerPrompt::new(request, cx))
1439            });
1440        });
1441
1442        assert_eq!(count_notifications(&workspace, cx), 1);
1443
1444        // Advance time less than custom duration
1445        cx.executor()
1446            .advance_clock(Duration::from_millis(custom_duration_ms - 500));
1447        assert_eq!(count_notifications(&workspace, cx), 1);
1448
1449        // Advance time past the custom duration
1450        cx.executor().advance_clock(Duration::from_millis(1000));
1451        assert_eq!(count_notifications(&workspace, cx), 0);
1452    }
1453}