notifications.rs

  1use crate::{Toast, Workspace};
  2use gpui::{
  3    svg, AnyView, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
  4    Entity, EventEmitter, Global, PromptLevel, Render, ScrollHandle, Task,
  5};
  6use std::rc::Rc;
  7use std::{any::TypeId, time::Duration};
  8use ui::{prelude::*, Tooltip};
  9use util::ResultExt;
 10
 11pub fn init(cx: &mut App) {
 12    cx.set_global(GlobalAppNotifications {
 13        app_notifications: Vec::new(),
 14    })
 15}
 16
 17#[derive(Debug, PartialEq, Clone)]
 18pub enum NotificationId {
 19    Unique(TypeId),
 20    Composite(TypeId, ElementId),
 21    Named(SharedString),
 22}
 23
 24impl NotificationId {
 25    /// Returns a unique [`NotificationId`] for the given type.
 26    pub fn unique<T: 'static>() -> Self {
 27        Self::Unique(TypeId::of::<T>())
 28    }
 29
 30    /// Returns a [`NotificationId`] for the given type that is also identified
 31    /// by the provided ID.
 32    pub fn composite<T: 'static>(id: impl Into<ElementId>) -> Self {
 33        Self::Composite(TypeId::of::<T>(), id.into())
 34    }
 35
 36    /// Builds a `NotificationId` out of the given string.
 37    pub fn named(id: SharedString) -> Self {
 38        Self::Named(id)
 39    }
 40}
 41
 42pub trait Notification: EventEmitter<DismissEvent> + Render {}
 43
 44impl<V: EventEmitter<DismissEvent> + Render> Notification for V {}
 45
 46impl Workspace {
 47    #[cfg(any(test, feature = "test-support"))]
 48    pub fn notification_ids(&self) -> Vec<NotificationId> {
 49        self.notifications
 50            .iter()
 51            .map(|(id, _)| id)
 52            .cloned()
 53            .collect()
 54    }
 55
 56    pub fn show_notification<V: Notification>(
 57        &mut self,
 58        id: NotificationId,
 59        cx: &mut Context<Self>,
 60        build_notification: impl FnOnce(&mut Context<Self>) -> Entity<V>,
 61    ) {
 62        self.show_notification_without_handling_dismiss_events(&id, cx, |cx| {
 63            let notification = build_notification(cx);
 64            cx.subscribe(&notification, {
 65                let id = id.clone();
 66                move |this, _, _: &DismissEvent, cx| {
 67                    this.dismiss_notification(&id, cx);
 68                }
 69            })
 70            .detach();
 71            notification.into()
 72        });
 73    }
 74
 75    /// Shows a notification in this workspace's window. Caller must handle dismiss.
 76    ///
 77    /// This exists so that the `build_notification` closures stored for app notifications can
 78    /// return `AnyView`. Subscribing to events from an `AnyView` is not supported, so instead that
 79    /// responsibility is pushed to the caller where the `V` type is known.
 80    pub(crate) fn show_notification_without_handling_dismiss_events(
 81        &mut self,
 82        id: &NotificationId,
 83        cx: &mut Context<Self>,
 84        build_notification: impl FnOnce(&mut Context<Self>) -> AnyView,
 85    ) {
 86        self.dismiss_notification(id, cx);
 87        self.notifications
 88            .push((id.clone(), build_notification(cx)));
 89        cx.notify();
 90    }
 91
 92    pub fn show_error<E>(&mut self, err: &E, cx: &mut Context<Self>)
 93    where
 94        E: std::fmt::Debug + std::fmt::Display,
 95    {
 96        self.show_notification(workspace_error_notification_id(), cx, |cx| {
 97            cx.new(|_| ErrorMessagePrompt::new(format!("Error: {err}")))
 98        });
 99    }
100
101    pub fn show_portal_error(&mut self, err: String, cx: &mut Context<Self>) {
102        struct PortalError;
103
104        self.show_notification(NotificationId::unique::<PortalError>(), cx, |cx| {
105            cx.new(|_| {
106                ErrorMessagePrompt::new(err.to_string()).with_link_button(
107                    "See docs",
108                    "https://zed.dev/docs/linux#i-cant-open-any-files",
109                )
110            })
111        });
112    }
113
114    pub fn dismiss_notification(&mut self, id: &NotificationId, cx: &mut Context<Self>) {
115        self.notifications.retain(|(existing_id, _)| {
116            if existing_id == id {
117                cx.notify();
118                false
119            } else {
120                true
121            }
122        });
123    }
124
125    pub fn show_toast(&mut self, toast: Toast, cx: &mut Context<Self>) {
126        self.dismiss_notification(&toast.id, cx);
127        self.show_notification(toast.id.clone(), cx, |cx| {
128            cx.new(|_| match toast.on_click.as_ref() {
129                Some((click_msg, on_click)) => {
130                    let on_click = on_click.clone();
131                    simple_message_notification::MessageNotification::new(toast.msg.clone())
132                        .with_click_message(click_msg.clone())
133                        .on_click(move |window, cx| on_click(window, cx))
134                }
135                None => simple_message_notification::MessageNotification::new(toast.msg.clone()),
136            })
137        });
138        if toast.autohide {
139            cx.spawn(|workspace, mut cx| async move {
140                cx.background_executor()
141                    .timer(Duration::from_millis(5000))
142                    .await;
143                workspace
144                    .update(&mut cx, |workspace, cx| {
145                        workspace.dismiss_toast(&toast.id, cx)
146                    })
147                    .ok();
148            })
149            .detach();
150        }
151    }
152
153    pub fn dismiss_toast(&mut self, id: &NotificationId, cx: &mut Context<Self>) {
154        self.dismiss_notification(id, cx);
155    }
156
157    pub fn clear_all_notifications(&mut self, cx: &mut Context<Self>) {
158        self.notifications.clear();
159        cx.notify();
160    }
161
162    pub fn show_initial_notifications(&mut self, cx: &mut Context<Self>) {
163        // Allow absence of the global so that tests don't need to initialize it.
164        let app_notifications = cx
165            .try_global::<GlobalAppNotifications>()
166            .iter()
167            .flat_map(|global| global.app_notifications.iter().cloned())
168            .collect::<Vec<_>>();
169        for (id, build_notification) in app_notifications {
170            self.show_notification_without_handling_dismiss_events(&id, cx, |cx| {
171                build_notification(cx)
172            });
173        }
174    }
175}
176
177pub struct LanguageServerPrompt {
178    request: Option<project::LanguageServerPromptRequest>,
179    scroll_handle: ScrollHandle,
180}
181
182impl LanguageServerPrompt {
183    pub fn new(request: project::LanguageServerPromptRequest) -> Self {
184        Self {
185            request: Some(request),
186            scroll_handle: ScrollHandle::new(),
187        }
188    }
189
190    async fn select_option(this: Entity<Self>, ix: usize, mut cx: AsyncWindowContext) {
191        util::maybe!(async move {
192            let potential_future = this.update(&mut cx, |this, _| {
193                this.request.take().map(|request| request.respond(ix))
194            });
195
196            potential_future? // App Closed
197                .ok_or_else(|| anyhow::anyhow!("Response already sent"))?
198                .await
199                .ok_or_else(|| anyhow::anyhow!("Stream already closed"))?;
200
201            this.update(&mut cx, |_, cx| cx.emit(DismissEvent))?;
202
203            anyhow::Ok(())
204        })
205        .await
206        .log_err();
207    }
208}
209
210impl Render for LanguageServerPrompt {
211    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
212        let Some(request) = &self.request else {
213            return div().id("language_server_prompt_notification");
214        };
215
216        let (icon, color) = match request.level {
217            PromptLevel::Info => (IconName::Info, Color::Accent),
218            PromptLevel::Warning => (IconName::Warning, Color::Warning),
219            PromptLevel::Critical => (IconName::XCircle, Color::Error),
220        };
221
222        div()
223            .id("language_server_prompt_notification")
224            .group("language_server_prompt_notification")
225            .occlude()
226            .w_full()
227            .max_h(vh(0.8, window))
228            .elevation_3(cx)
229            .overflow_y_scroll()
230            .track_scroll(&self.scroll_handle)
231            .child(
232                v_flex()
233                    .p_3()
234                    .overflow_hidden()
235                    .child(
236                        h_flex()
237                            .justify_between()
238                            .items_start()
239                            .child(
240                                h_flex()
241                                    .gap_2()
242                                    .child(Icon::new(icon).color(color))
243                                    .child(Label::new(request.lsp_name.clone())),
244                            )
245                            .child(
246                                h_flex()
247                                    .child(
248                                        IconButton::new("copy", IconName::Copy)
249                                            .on_click({
250                                                let message = request.message.clone();
251                                                move |_, _, cx| {
252                                                    cx.write_to_clipboard(
253                                                        ClipboardItem::new_string(message.clone()),
254                                                    )
255                                                }
256                                            })
257                                            .tooltip(Tooltip::text("Copy Description")),
258                                    )
259                                    .child(IconButton::new("close", IconName::Close).on_click(
260                                        cx.listener(|_, _, _, cx| cx.emit(gpui::DismissEvent)),
261                                    )),
262                            ),
263                    )
264                    .child(Label::new(request.message.to_string()).size(LabelSize::Small))
265                    .children(request.actions.iter().enumerate().map(|(ix, action)| {
266                        let this_handle = cx.entity().clone();
267                        Button::new(ix, action.title.clone())
268                            .size(ButtonSize::Large)
269                            .on_click(move |_, window, cx| {
270                                let this_handle = this_handle.clone();
271                                window
272                                    .spawn(cx, |cx| async move {
273                                        LanguageServerPrompt::select_option(this_handle, ix, cx)
274                                            .await
275                                    })
276                                    .detach()
277                            })
278                    })),
279            )
280    }
281}
282
283impl EventEmitter<DismissEvent> for LanguageServerPrompt {}
284
285fn workspace_error_notification_id() -> NotificationId {
286    struct WorkspaceErrorNotification;
287    NotificationId::unique::<WorkspaceErrorNotification>()
288}
289
290#[derive(Debug, Clone)]
291pub struct ErrorMessagePrompt {
292    message: SharedString,
293    label_and_url_button: Option<(SharedString, SharedString)>,
294}
295
296impl ErrorMessagePrompt {
297    pub fn new<S>(message: S) -> Self
298    where
299        S: Into<SharedString>,
300    {
301        Self {
302            message: message.into(),
303            label_and_url_button: None,
304        }
305    }
306
307    pub fn with_link_button<S>(mut self, label: S, url: S) -> Self
308    where
309        S: Into<SharedString>,
310    {
311        self.label_and_url_button = Some((label.into(), url.into()));
312        self
313    }
314}
315
316impl Render for ErrorMessagePrompt {
317    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
318        h_flex()
319            .id("error_message_prompt_notification")
320            .occlude()
321            .elevation_3(cx)
322            .items_start()
323            .justify_between()
324            .p_2()
325            .gap_2()
326            .w_full()
327            .child(
328                v_flex()
329                    .w_full()
330                    .child(
331                        h_flex()
332                            .w_full()
333                            .justify_between()
334                            .child(
335                                svg()
336                                    .size(window.text_style().font_size)
337                                    .flex_none()
338                                    .mr_2()
339                                    .mt(px(-2.0))
340                                    .map(|icon| {
341                                        icon.path(IconName::Warning.path())
342                                            .text_color(Color::Error.color(cx))
343                                    }),
344                            )
345                            .child(
346                                ui::IconButton::new("close", ui::IconName::Close).on_click(
347                                    cx.listener(|_, _, _, cx| cx.emit(gpui::DismissEvent)),
348                                ),
349                            ),
350                    )
351                    .child(
352                        div()
353                            .id("error_message")
354                            .max_w_96()
355                            .max_h_40()
356                            .overflow_y_scroll()
357                            .child(Label::new(self.message.clone()).size(LabelSize::Small)),
358                    )
359                    .when_some(self.label_and_url_button.clone(), |elm, (label, url)| {
360                        elm.child(
361                            div().mt_2().child(
362                                ui::Button::new("error_message_prompt_notification_button", label)
363                                    .on_click(move |_, _, cx| cx.open_url(&url)),
364                            ),
365                        )
366                    }),
367            )
368    }
369}
370
371impl EventEmitter<DismissEvent> for ErrorMessagePrompt {}
372
373pub mod simple_message_notification {
374    use std::sync::Arc;
375
376    use gpui::{
377        div, AnyElement, DismissEvent, EventEmitter, ParentElement, Render, SharedString, Styled,
378    };
379    use ui::prelude::*;
380
381    pub struct MessageNotification {
382        build_content: Box<dyn Fn(&mut Window, &mut Context<Self>) -> AnyElement>,
383        on_click: Option<Arc<dyn Fn(&mut Window, &mut Context<Self>)>>,
384        click_message: Option<SharedString>,
385        secondary_click_message: Option<SharedString>,
386        secondary_on_click: Option<Arc<dyn Fn(&mut Window, &mut Context<Self>)>>,
387    }
388
389    impl EventEmitter<DismissEvent> for MessageNotification {}
390
391    impl MessageNotification {
392        pub fn new<S>(message: S) -> MessageNotification
393        where
394            S: Into<SharedString>,
395        {
396            let message = message.into();
397            Self::new_from_builder(move |_, _| Label::new(message.clone()).into_any_element())
398        }
399
400        pub fn new_from_builder<F>(content: F) -> MessageNotification
401        where
402            F: 'static + Fn(&mut Window, &mut Context<Self>) -> AnyElement,
403        {
404            Self {
405                build_content: Box::new(content),
406                on_click: None,
407                click_message: None,
408                secondary_on_click: None,
409                secondary_click_message: None,
410            }
411        }
412
413        pub fn with_click_message<S>(mut self, message: S) -> Self
414        where
415            S: Into<SharedString>,
416        {
417            self.click_message = Some(message.into());
418            self
419        }
420
421        pub fn on_click<F>(mut self, on_click: F) -> Self
422        where
423            F: 'static + Fn(&mut Window, &mut Context<Self>),
424        {
425            self.on_click = Some(Arc::new(on_click));
426            self
427        }
428
429        pub fn with_secondary_click_message<S>(mut self, message: S) -> Self
430        where
431            S: Into<SharedString>,
432        {
433            self.secondary_click_message = Some(message.into());
434            self
435        }
436
437        pub fn on_secondary_click<F>(mut self, on_click: F) -> Self
438        where
439            F: 'static + Fn(&mut Window, &mut Context<Self>),
440        {
441            self.secondary_on_click = Some(Arc::new(on_click));
442            self
443        }
444
445        pub fn dismiss(&mut self, cx: &mut Context<Self>) {
446            cx.emit(DismissEvent);
447        }
448    }
449
450    impl Render for MessageNotification {
451        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
452            v_flex()
453                .p_3()
454                .gap_2()
455                .elevation_3(cx)
456                .child(
457                    h_flex()
458                        .gap_4()
459                        .justify_between()
460                        .items_start()
461                        .child(div().max_w_96().child((self.build_content)(window, cx)))
462                        .child(
463                            IconButton::new("close", IconName::Close)
464                                .on_click(cx.listener(|this, _, _, cx| this.dismiss(cx))),
465                        ),
466                )
467                .child(
468                    h_flex()
469                        .gap_2()
470                        .children(self.click_message.iter().map(|message| {
471                            Button::new(message.clone(), message.clone())
472                                .label_size(LabelSize::Small)
473                                .icon(IconName::Check)
474                                .icon_position(IconPosition::Start)
475                                .icon_size(IconSize::Small)
476                                .icon_color(Color::Success)
477                                .on_click(cx.listener(|this, _, window, cx| {
478                                    if let Some(on_click) = this.on_click.as_ref() {
479                                        (on_click)(window, cx)
480                                    };
481                                    this.dismiss(cx)
482                                }))
483                        }))
484                        .children(self.secondary_click_message.iter().map(|message| {
485                            Button::new(message.clone(), message.clone())
486                                .label_size(LabelSize::Small)
487                                .icon(IconName::Close)
488                                .icon_position(IconPosition::Start)
489                                .icon_size(IconSize::Small)
490                                .icon_color(Color::Error)
491                                .on_click(cx.listener(|this, _, window, cx| {
492                                    if let Some(on_click) = this.secondary_on_click.as_ref() {
493                                        (on_click)(window, cx)
494                                    };
495                                    this.dismiss(cx)
496                                }))
497                        })),
498                )
499        }
500    }
501}
502
503/// Stores app notifications so that they can be shown in new workspaces.
504struct GlobalAppNotifications {
505    app_notifications: Vec<(
506        NotificationId,
507        Rc<dyn Fn(&mut Context<Workspace>) -> AnyView>,
508    )>,
509}
510
511impl Global for GlobalAppNotifications {}
512
513impl GlobalAppNotifications {
514    pub fn insert(
515        &mut self,
516        id: NotificationId,
517        build_notification: Rc<dyn Fn(&mut Context<Workspace>) -> AnyView>,
518    ) {
519        self.remove(&id);
520        self.app_notifications.push((id, build_notification))
521    }
522
523    pub fn remove(&mut self, id: &NotificationId) {
524        self.app_notifications
525            .retain(|(existing_id, _)| existing_id != id);
526    }
527}
528
529/// Shows a notification in all workspaces. New workspaces will also receive the notification - this
530/// is particularly to handle notifications that occur on initialization before any workspaces
531/// exist. If the notification is dismissed within any workspace, it will be removed from all.
532pub fn show_app_notification<V: Notification + 'static>(
533    id: NotificationId,
534    cx: &mut App,
535    build_notification: impl Fn(&mut Context<Workspace>) -> Entity<V> + 'static,
536) {
537    // Defer notification creation so that windows on the stack can be returned to GPUI
538    cx.defer(move |cx| {
539        // Handle dismiss events by removing the notification from all workspaces.
540        let build_notification: Rc<dyn Fn(&mut Context<Workspace>) -> AnyView> = Rc::new({
541            let id = id.clone();
542            move |cx| {
543                let notification = build_notification(cx);
544                cx.subscribe(&notification, {
545                    let id = id.clone();
546                    move |_, _, _: &DismissEvent, cx| {
547                        dismiss_app_notification(&id, cx);
548                    }
549                })
550                .detach();
551                notification.into()
552            }
553        });
554
555        // Store the notification so that new workspaces also receive it.
556        cx.global_mut::<GlobalAppNotifications>()
557            .insert(id.clone(), build_notification.clone());
558
559        for window in cx.windows() {
560            if let Some(workspace_window) = window.downcast::<Workspace>() {
561                workspace_window
562                    .update(cx, |workspace, _window, cx| {
563                        workspace.show_notification_without_handling_dismiss_events(
564                            &id,
565                            cx,
566                            |cx| build_notification(cx),
567                        );
568                    })
569                    .ok(); // Doesn't matter if the windows are dropped
570            }
571        }
572    });
573}
574
575pub fn dismiss_app_notification(id: &NotificationId, cx: &mut App) {
576    let id = id.clone();
577    // Defer notification dismissal so that windows on the stack can be returned to GPUI
578    cx.defer(move |cx| {
579        cx.global_mut::<GlobalAppNotifications>().remove(&id);
580        for window in cx.windows() {
581            if let Some(workspace_window) = window.downcast::<Workspace>() {
582                let id = id.clone();
583                workspace_window
584                    .update(cx, |workspace, _window, cx| {
585                        workspace.dismiss_notification(&id, cx)
586                    })
587                    .ok();
588            }
589        }
590    });
591}
592
593pub trait NotifyResultExt {
594    type Ok;
595
596    fn notify_err(self, workspace: &mut Workspace, cx: &mut Context<Workspace>)
597        -> Option<Self::Ok>;
598
599    fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<Self::Ok>;
600
601    /// Notifies the active workspace if there is one, otherwise notifies all workspaces.
602    fn notify_app_err(self, cx: &mut App) -> Option<Self::Ok>;
603}
604
605impl<T, E> NotifyResultExt for std::result::Result<T, E>
606where
607    E: std::fmt::Debug + std::fmt::Display,
608{
609    type Ok = T;
610
611    fn notify_err(self, workspace: &mut Workspace, cx: &mut Context<Workspace>) -> Option<T> {
612        match self {
613            Ok(value) => Some(value),
614            Err(err) => {
615                log::error!("Showing error notification in workspace: {err:?}");
616                workspace.show_error(&err, cx);
617                None
618            }
619        }
620    }
621
622    fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<T> {
623        match self {
624            Ok(value) => Some(value),
625            Err(err) => {
626                log::error!("{err:?}");
627                cx.update_root(|view, _, cx| {
628                    if let Ok(workspace) = view.downcast::<Workspace>() {
629                        workspace.update(cx, |workspace, cx| workspace.show_error(&err, cx))
630                    }
631                })
632                .ok();
633                None
634            }
635        }
636    }
637
638    fn notify_app_err(self, cx: &mut App) -> Option<T> {
639        match self {
640            Ok(value) => Some(value),
641            Err(err) => {
642                let message: SharedString = format!("Error: {err}").into();
643                log::error!("Showing error notification in app: {message}");
644                show_app_notification(workspace_error_notification_id(), cx, {
645                    let message = message.clone();
646                    move |cx| {
647                        cx.new({
648                            let message = message.clone();
649                            move |_cx| ErrorMessagePrompt::new(message)
650                        })
651                    }
652                });
653
654                None
655            }
656        }
657    }
658}
659
660pub trait NotifyTaskExt {
661    fn detach_and_notify_err(self, window: &mut Window, cx: &mut App);
662}
663
664impl<R, E> NotifyTaskExt for Task<std::result::Result<R, E>>
665where
666    E: std::fmt::Debug + std::fmt::Display + Sized + 'static,
667    R: 'static,
668{
669    fn detach_and_notify_err(self, window: &mut Window, cx: &mut App) {
670        window
671            .spawn(
672                cx,
673                |mut cx| async move { self.await.notify_async_err(&mut cx) },
674            )
675            .detach();
676    }
677}
678
679pub trait DetachAndPromptErr<R> {
680    fn prompt_err(
681        self,
682        msg: &str,
683        window: &Window,
684        cx: &App,
685        f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
686    ) -> Task<Option<R>>;
687
688    fn detach_and_prompt_err(
689        self,
690        msg: &str,
691        window: &Window,
692        cx: &App,
693        f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
694    );
695}
696
697impl<R> DetachAndPromptErr<R> for Task<anyhow::Result<R>>
698where
699    R: 'static,
700{
701    fn prompt_err(
702        self,
703        msg: &str,
704        window: &Window,
705        cx: &App,
706        f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
707    ) -> Task<Option<R>> {
708        let msg = msg.to_owned();
709        window.spawn(cx, |mut cx| async move {
710            let result = self.await;
711            if let Err(err) = result.as_ref() {
712                log::error!("{err:?}");
713                if let Ok(prompt) = cx.update(|window, cx| {
714                    let detail =
715                        f(err, window, cx).unwrap_or_else(|| format!("{err}. Please try again."));
716                    window.prompt(PromptLevel::Critical, &msg, Some(&detail), &["Ok"], cx)
717                }) {
718                    prompt.await.ok();
719                }
720                return None;
721            }
722            Some(result.unwrap())
723        })
724    }
725
726    fn detach_and_prompt_err(
727        self,
728        msg: &str,
729        window: &Window,
730        cx: &App,
731        f: impl FnOnce(&anyhow::Error, &mut Window, &mut App) -> Option<String> + 'static,
732    ) {
733        self.prompt_err(msg, window, cx, f).detach();
734    }
735}