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