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                            .items_start()
288                            .child(
289                                h_flex()
290                                    .gap_2()
291                                    .child(Icon::new(icon).color(color))
292                                    .child(Label::new(request.lsp_name.clone())),
293                            )
294                            .child(
295                                h_flex()
296                                    .child(
297                                        IconButton::new("copy", IconName::Copy)
298                                            .on_click({
299                                                let message = request.message.clone();
300                                                move |_, cx| {
301                                                    cx.write_to_clipboard(
302                                                        ClipboardItem::new_string(message.clone()),
303                                                    )
304                                                }
305                                            })
306                                            .tooltip(|cx| Tooltip::text("Copy Description", cx)),
307                                    )
308                                    .child(IconButton::new("close", IconName::Close).on_click(
309                                        cx.listener(|_, _, cx| cx.emit(gpui::DismissEvent)),
310                                    )),
311                            ),
312                    )
313                    .child(Label::new(request.message.to_string()).size(LabelSize::Small))
314                    .children(request.actions.iter().enumerate().map(|(ix, action)| {
315                        let this_handle = cx.view().clone();
316                        Button::new(ix, action.title.clone())
317                            .size(ButtonSize::Large)
318                            .on_click(move |_, cx| {
319                                let this_handle = this_handle.clone();
320                                cx.spawn(|cx| async move {
321                                    LanguageServerPrompt::select_option(this_handle, ix, cx).await
322                                })
323                                .detach()
324                            })
325                    })),
326            )
327    }
328}
329
330impl EventEmitter<DismissEvent> for LanguageServerPrompt {}
331
332fn workspace_error_notification_id() -> NotificationId {
333    struct WorkspaceErrorNotification;
334    NotificationId::unique::<WorkspaceErrorNotification>()
335}
336
337#[derive(Debug, Clone)]
338pub struct ErrorMessagePrompt {
339    message: SharedString,
340    label_and_url_button: Option<(SharedString, SharedString)>,
341}
342
343impl ErrorMessagePrompt {
344    pub fn new<S>(message: S) -> Self
345    where
346        S: Into<SharedString>,
347    {
348        Self {
349            message: message.into(),
350            label_and_url_button: None,
351        }
352    }
353
354    pub fn with_link_button<S>(mut self, label: S, url: S) -> Self
355    where
356        S: Into<SharedString>,
357    {
358        self.label_and_url_button = Some((label.into(), url.into()));
359        self
360    }
361}
362
363impl Render for ErrorMessagePrompt {
364    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
365        h_flex()
366            .id("error_message_prompt_notification")
367            .occlude()
368            .elevation_3(cx)
369            .items_start()
370            .justify_between()
371            .p_2()
372            .gap_2()
373            .w_full()
374            .child(
375                v_flex()
376                    .w_full()
377                    .child(
378                        h_flex()
379                            .w_full()
380                            .justify_between()
381                            .child(
382                                svg()
383                                    .size(cx.text_style().font_size)
384                                    .flex_none()
385                                    .mr_2()
386                                    .mt(px(-2.0))
387                                    .map(|icon| {
388                                        icon.path(IconName::Warning.path())
389                                            .text_color(Color::Error.color(cx))
390                                    }),
391                            )
392                            .child(
393                                ui::IconButton::new("close", ui::IconName::Close)
394                                    .on_click(cx.listener(|_, _, cx| cx.emit(gpui::DismissEvent))),
395                            ),
396                    )
397                    .child(
398                        div()
399                            .id("error_message")
400                            .max_w_96()
401                            .max_h_40()
402                            .overflow_y_scroll()
403                            .child(Label::new(self.message.clone()).size(LabelSize::Small)),
404                    )
405                    .when_some(self.label_and_url_button.clone(), |elm, (label, url)| {
406                        elm.child(
407                            div().mt_2().child(
408                                ui::Button::new("error_message_prompt_notification_button", label)
409                                    .on_click(move |_, cx| cx.open_url(&url)),
410                            ),
411                        )
412                    }),
413            )
414    }
415}
416
417impl EventEmitter<DismissEvent> for ErrorMessagePrompt {}
418
419pub mod simple_message_notification {
420    use std::sync::Arc;
421
422    use gpui::{
423        div, AnyElement, DismissEvent, EventEmitter, ParentElement, Render, SharedString, Styled,
424        ViewContext,
425    };
426    use ui::prelude::*;
427
428    pub struct MessageNotification {
429        content: Box<dyn Fn(&mut ViewContext<Self>) -> AnyElement>,
430        on_click: Option<Arc<dyn Fn(&mut ViewContext<Self>)>>,
431        click_message: Option<SharedString>,
432        secondary_click_message: Option<SharedString>,
433        secondary_on_click: Option<Arc<dyn Fn(&mut ViewContext<Self>)>>,
434    }
435
436    impl EventEmitter<DismissEvent> for MessageNotification {}
437
438    impl MessageNotification {
439        pub fn new<S>(message: S) -> MessageNotification
440        where
441            S: Into<SharedString>,
442        {
443            let message = message.into();
444            Self::new_from_builder(move |_| Label::new(message.clone()).into_any_element())
445        }
446
447        pub fn new_from_builder<F>(content: F) -> MessageNotification
448        where
449            F: 'static + Fn(&mut ViewContext<Self>) -> AnyElement,
450        {
451            Self {
452                content: Box::new(content),
453                on_click: None,
454                click_message: None,
455                secondary_on_click: None,
456                secondary_click_message: None,
457            }
458        }
459
460        pub fn with_click_message<S>(mut self, message: S) -> Self
461        where
462            S: Into<SharedString>,
463        {
464            self.click_message = Some(message.into());
465            self
466        }
467
468        pub fn on_click<F>(mut self, on_click: F) -> Self
469        where
470            F: 'static + Fn(&mut ViewContext<Self>),
471        {
472            self.on_click = Some(Arc::new(on_click));
473            self
474        }
475
476        pub fn with_secondary_click_message<S>(mut self, message: S) -> Self
477        where
478            S: Into<SharedString>,
479        {
480            self.secondary_click_message = Some(message.into());
481            self
482        }
483
484        pub fn on_secondary_click<F>(mut self, on_click: F) -> Self
485        where
486            F: 'static + Fn(&mut ViewContext<Self>),
487        {
488            self.secondary_on_click = Some(Arc::new(on_click));
489            self
490        }
491
492        pub fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
493            cx.emit(DismissEvent);
494        }
495    }
496
497    impl Render for MessageNotification {
498        fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
499            v_flex()
500                .p_3()
501                .gap_2()
502                .elevation_3(cx)
503                .child(
504                    h_flex()
505                        .gap_4()
506                        .justify_between()
507                        .items_start()
508                        .child(div().max_w_96().child((self.content)(cx)))
509                        .child(
510                            IconButton::new("close", IconName::Close)
511                                .on_click(cx.listener(|this, _, cx| this.dismiss(cx))),
512                        ),
513                )
514                .child(
515                    h_flex()
516                        .gap_2()
517                        .children(self.click_message.iter().map(|message| {
518                            Button::new(message.clone(), message.clone())
519                                .label_size(LabelSize::Small)
520                                .icon(IconName::Check)
521                                .icon_position(IconPosition::Start)
522                                .icon_size(IconSize::Small)
523                                .icon_color(Color::Success)
524                                .on_click(cx.listener(|this, _, cx| {
525                                    if let Some(on_click) = this.on_click.as_ref() {
526                                        (on_click)(cx)
527                                    };
528                                    this.dismiss(cx)
529                                }))
530                        }))
531                        .children(self.secondary_click_message.iter().map(|message| {
532                            Button::new(message.clone(), message.clone())
533                                .label_size(LabelSize::Small)
534                                .icon(IconName::Close)
535                                .icon_position(IconPosition::Start)
536                                .icon_size(IconSize::Small)
537                                .icon_color(Color::Error)
538                                .on_click(cx.listener(|this, _, cx| {
539                                    if let Some(on_click) = this.secondary_on_click.as_ref() {
540                                        (on_click)(cx)
541                                    };
542                                    this.dismiss(cx)
543                                }))
544                        })),
545                )
546        }
547    }
548}
549
550/// Shows a notification in the active workspace if there is one, otherwise shows it in all workspaces.
551pub fn show_app_notification<V: Notification>(
552    id: NotificationId,
553    cx: &mut AppContext,
554    build_notification: impl Fn(&mut ViewContext<Workspace>) -> View<V>,
555) -> Result<()> {
556    let workspaces_to_notify = if let Some(active_workspace_window) = cx
557        .active_window()
558        .and_then(|window| window.downcast::<Workspace>())
559    {
560        vec![active_workspace_window]
561    } else {
562        let mut workspaces_to_notify = Vec::new();
563        for window in cx.windows() {
564            if let Some(workspace_window) = window.downcast::<Workspace>() {
565                workspaces_to_notify.push(workspace_window);
566            }
567        }
568        workspaces_to_notify
569    };
570
571    let mut notified = false;
572    let mut notify_errors = Vec::new();
573
574    for workspace_window in workspaces_to_notify {
575        let notify_result = workspace_window.update(cx, |workspace, cx| {
576            workspace.show_notification(id.clone(), cx, &build_notification);
577        });
578        match notify_result {
579            Ok(()) => notified = true,
580            Err(notify_err) => notify_errors.push(notify_err),
581        }
582    }
583
584    if notified {
585        Ok(())
586    } else {
587        if notify_errors.is_empty() {
588            Err(anyhow!("Found no workspaces to show notification."))
589        } else {
590            Err(anyhow!(
591                "No workspaces were able to show notification. Errors:\n\n{}",
592                notify_errors
593                    .iter()
594                    .map(|e| e.to_string())
595                    .collect::<Vec<_>>()
596                    .join("\n\n")
597            ))
598        }
599    }
600}
601
602pub fn dismiss_app_notification(id: &NotificationId, cx: &mut AppContext) {
603    for window in cx.windows() {
604        if let Some(workspace_window) = window.downcast::<Workspace>() {
605            workspace_window
606                .update(cx, |workspace, cx| {
607                    workspace.dismiss_notification(&id, cx);
608                })
609                .ok();
610        }
611    }
612}
613
614pub trait NotifyResultExt {
615    type Ok;
616
617    fn notify_err(
618        self,
619        workspace: &mut Workspace,
620        cx: &mut ViewContext<Workspace>,
621    ) -> Option<Self::Ok>;
622
623    fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<Self::Ok>;
624
625    /// Notifies the active workspace if there is one, otherwise notifies all workspaces.
626    fn notify_app_err(self, cx: &mut AppContext) -> Option<Self::Ok>;
627}
628
629impl<T, E> NotifyResultExt for std::result::Result<T, E>
630where
631    E: std::fmt::Debug + std::fmt::Display,
632{
633    type Ok = T;
634
635    fn notify_err(self, workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Option<T> {
636        match self {
637            Ok(value) => Some(value),
638            Err(err) => {
639                log::error!("TODO {err:?}");
640                workspace.show_error(&err, cx);
641                None
642            }
643        }
644    }
645
646    fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<T> {
647        match self {
648            Ok(value) => Some(value),
649            Err(err) => {
650                log::error!("{err:?}");
651                cx.update_root(|view, cx| {
652                    if let Ok(workspace) = view.downcast::<Workspace>() {
653                        workspace.update(cx, |workspace, cx| workspace.show_error(&err, cx))
654                    }
655                })
656                .ok();
657                None
658            }
659        }
660    }
661
662    fn notify_app_err(self, cx: &mut AppContext) -> Option<T> {
663        match self {
664            Ok(value) => Some(value),
665            Err(err) => {
666                let message: SharedString = format!("Error: {err}").into();
667                show_app_notification(workspace_error_notification_id(), cx, |cx| {
668                    cx.new_view(|_cx| ErrorMessagePrompt::new(message.clone()))
669                })
670                .with_context(|| format!("Showing error notification: {message}"))
671                .log_err();
672                None
673            }
674        }
675    }
676}
677
678pub trait NotifyTaskExt {
679    fn detach_and_notify_err(self, cx: &mut WindowContext);
680}
681
682impl<R, E> NotifyTaskExt for Task<std::result::Result<R, E>>
683where
684    E: std::fmt::Debug + std::fmt::Display + Sized + 'static,
685    R: 'static,
686{
687    fn detach_and_notify_err(self, cx: &mut WindowContext) {
688        cx.spawn(|mut cx| async move { self.await.notify_async_err(&mut cx) })
689            .detach();
690    }
691}
692
693pub trait DetachAndPromptErr<R> {
694    fn prompt_err(
695        self,
696        msg: &str,
697        cx: &mut WindowContext,
698        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
699    ) -> Task<Option<R>>;
700
701    fn detach_and_prompt_err(
702        self,
703        msg: &str,
704        cx: &mut WindowContext,
705        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
706    );
707}
708
709impl<R> DetachAndPromptErr<R> for Task<anyhow::Result<R>>
710where
711    R: 'static,
712{
713    fn prompt_err(
714        self,
715        msg: &str,
716        cx: &mut WindowContext,
717        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
718    ) -> Task<Option<R>> {
719        let msg = msg.to_owned();
720        cx.spawn(|mut cx| async move {
721            let result = self.await;
722            if let Err(err) = result.as_ref() {
723                log::error!("{err:?}");
724                if let Ok(prompt) = cx.update(|cx| {
725                    let detail = f(err, cx).unwrap_or_else(|| format!("{err}. Please try again."));
726                    cx.prompt(PromptLevel::Critical, &msg, Some(&detail), &["Ok"])
727                }) {
728                    prompt.await.ok();
729                }
730                return None;
731            }
732            Some(result.unwrap())
733        })
734    }
735
736    fn detach_and_prompt_err(
737        self,
738        msg: &str,
739        cx: &mut WindowContext,
740        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
741    ) {
742        self.prompt_err(msg, cx, f).detach();
743    }
744}