notifications.rs

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