notifications.rs

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