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};
  8use language::DiagnosticSeverity;
  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        struct WorkspaceErrorNotification;
156
157        self.show_notification(
158            NotificationId::unique::<WorkspaceErrorNotification>(),
159            cx,
160            |cx| cx.new_view(|_cx| ErrorMessagePrompt::new(format!("Error: {err:#}"))),
161        );
162    }
163
164    pub fn show_portal_error(&mut self, err: String, cx: &mut ViewContext<Self>) {
165        struct PortalError;
166
167        self.show_notification(NotificationId::unique::<PortalError>(), cx, |cx| {
168            cx.new_view(|_cx| {
169                ErrorMessagePrompt::new(err.to_string()).with_link_button(
170                    "See docs",
171                    "https://zed.dev/docs/linux#i-cant-open-any-files",
172                )
173            })
174        });
175    }
176
177    pub fn dismiss_notification(&mut self, id: &NotificationId, cx: &mut ViewContext<Self>) {
178        self.dismiss_notification_internal(id, cx)
179    }
180
181    pub fn show_toast(&mut self, toast: Toast, cx: &mut ViewContext<Self>) {
182        self.dismiss_notification(&toast.id, cx);
183        self.show_notification(toast.id.clone(), cx, |cx| {
184            cx.new_view(|_cx| match toast.on_click.as_ref() {
185                Some((click_msg, on_click)) => {
186                    let on_click = on_click.clone();
187                    simple_message_notification::MessageNotification::new(toast.msg.clone())
188                        .with_click_message(click_msg.clone())
189                        .on_click(move |cx| on_click(cx))
190                }
191                None => simple_message_notification::MessageNotification::new(toast.msg.clone()),
192            })
193        });
194        if toast.autohide {
195            cx.spawn(|workspace, mut cx| async move {
196                cx.background_executor()
197                    .timer(Duration::from_millis(5000))
198                    .await;
199                workspace
200                    .update(&mut cx, |workspace, cx| {
201                        workspace.dismiss_toast(&toast.id, cx)
202                    })
203                    .ok();
204            })
205            .detach();
206        }
207    }
208
209    pub fn dismiss_toast(&mut self, id: &NotificationId, cx: &mut ViewContext<Self>) {
210        self.dismiss_notification(id, cx);
211    }
212
213    pub fn clear_all_notifications(&mut self, cx: &mut ViewContext<Self>) {
214        self.notifications.clear();
215        cx.notify();
216    }
217
218    fn dismiss_notification_internal(&mut self, id: &NotificationId, cx: &mut ViewContext<Self>) {
219        self.notifications.retain(|(existing_id, _)| {
220            if existing_id == id {
221                cx.notify();
222                false
223            } else {
224                true
225            }
226        });
227    }
228}
229
230pub struct LanguageServerPrompt {
231    request: Option<project::LanguageServerPromptRequest>,
232    scroll_handle: ScrollHandle,
233}
234
235impl LanguageServerPrompt {
236    pub fn new(request: project::LanguageServerPromptRequest) -> Self {
237        Self {
238            request: Some(request),
239            scroll_handle: ScrollHandle::new(),
240        }
241    }
242
243    async fn select_option(this: View<Self>, ix: usize, mut cx: AsyncWindowContext) {
244        util::maybe!(async move {
245            let potential_future = this.update(&mut cx, |this, _| {
246                this.request.take().map(|request| request.respond(ix))
247            });
248
249            potential_future? // App Closed
250                .ok_or_else(|| anyhow::anyhow!("Response already sent"))?
251                .await
252                .ok_or_else(|| anyhow::anyhow!("Stream already closed"))?;
253
254            this.update(&mut cx, |_, cx| cx.emit(DismissEvent))?;
255
256            anyhow::Ok(())
257        })
258        .await
259        .log_err();
260    }
261}
262
263impl Render for LanguageServerPrompt {
264    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
265        let Some(request) = &self.request else {
266            return div().id("language_server_prompt_notification");
267        };
268
269        h_flex()
270            .id("language_server_prompt_notification")
271            .occlude()
272            .elevation_3(cx)
273            .items_start()
274            .justify_between()
275            .p_2()
276            .gap_2()
277            .w_full()
278            .max_h(vh(0.8, cx))
279            .overflow_y_scroll()
280            .track_scroll(&self.scroll_handle)
281            .group("")
282            .child(
283                v_flex()
284                    .w_full()
285                    .overflow_hidden()
286                    .child(
287                        h_flex()
288                            .w_full()
289                            .justify_between()
290                            .child(
291                                h_flex()
292                                    .flex_grow()
293                                    .children(
294                                        match request.level {
295                                            PromptLevel::Info => None,
296                                            PromptLevel::Warning => {
297                                                Some(DiagnosticSeverity::WARNING)
298                                            }
299                                            PromptLevel::Critical => {
300                                                Some(DiagnosticSeverity::ERROR)
301                                            }
302                                        }
303                                        .map(|severity| {
304                                            svg()
305                                                .size(cx.text_style().font_size)
306                                                .flex_none()
307                                                .mr_1()
308                                                .mt(px(-2.0))
309                                                .map(|icon| {
310                                                    if severity == DiagnosticSeverity::ERROR {
311                                                        icon.path(IconName::Warning.path())
312                                                            .text_color(Color::Error.color(cx))
313                                                    } else {
314                                                        icon.path(IconName::Warning.path())
315                                                            .text_color(Color::Warning.color(cx))
316                                                    }
317                                                })
318                                        }),
319                                    )
320                                    .child(
321                                        Label::new(request.lsp_name.clone())
322                                            .size(LabelSize::Default),
323                                    ),
324                            )
325                            .child(
326                                ui::IconButton::new("close", ui::IconName::Close)
327                                    .on_click(cx.listener(|_, _, cx| cx.emit(gpui::DismissEvent))),
328                            ),
329                    )
330                    .child(
331                        v_flex()
332                            .child(
333                                h_flex().absolute().right_0().rounded_md().child(
334                                    ui::IconButton::new("copy", ui::IconName::Copy)
335                                        .on_click({
336                                            let message = request.message.clone();
337                                            move |_, cx| {
338                                                cx.write_to_clipboard(ClipboardItem::new_string(
339                                                    message.clone(),
340                                                ))
341                                            }
342                                        })
343                                        .tooltip(|cx| Tooltip::text("Copy", cx))
344                                        .visible_on_hover(""),
345                                ),
346                            )
347                            .child(Label::new(request.message.to_string()).size(LabelSize::Small)),
348                    )
349                    .children(request.actions.iter().enumerate().map(|(ix, action)| {
350                        let this_handle = cx.view().clone();
351                        ui::Button::new(ix, action.title.clone())
352                            .size(ButtonSize::Large)
353                            .on_click(move |_, cx| {
354                                let this_handle = this_handle.clone();
355                                cx.spawn(|cx| async move {
356                                    LanguageServerPrompt::select_option(this_handle, ix, cx).await
357                                })
358                                .detach()
359                            })
360                    })),
361            )
362    }
363}
364
365impl EventEmitter<DismissEvent> for LanguageServerPrompt {}
366
367pub struct ErrorMessagePrompt {
368    message: SharedString,
369    label_and_url_button: Option<(SharedString, SharedString)>,
370}
371
372impl ErrorMessagePrompt {
373    pub fn new<S>(message: S) -> Self
374    where
375        S: Into<SharedString>,
376    {
377        Self {
378            message: message.into(),
379            label_and_url_button: None,
380        }
381    }
382
383    pub fn with_link_button<S>(mut self, label: S, url: S) -> Self
384    where
385        S: Into<SharedString>,
386    {
387        self.label_and_url_button = Some((label.into(), url.into()));
388        self
389    }
390}
391
392impl Render for ErrorMessagePrompt {
393    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
394        h_flex()
395            .id("error_message_prompt_notification")
396            .occlude()
397            .elevation_3(cx)
398            .items_start()
399            .justify_between()
400            .p_2()
401            .gap_2()
402            .w_full()
403            .child(
404                v_flex()
405                    .w_full()
406                    .child(
407                        h_flex()
408                            .w_full()
409                            .justify_between()
410                            .child(
411                                svg()
412                                    .size(cx.text_style().font_size)
413                                    .flex_none()
414                                    .mr_2()
415                                    .mt(px(-2.0))
416                                    .map(|icon| {
417                                        icon.path(IconName::Warning.path())
418                                            .text_color(Color::Error.color(cx))
419                                    }),
420                            )
421                            .child(
422                                ui::IconButton::new("close", ui::IconName::Close)
423                                    .on_click(cx.listener(|_, _, cx| cx.emit(gpui::DismissEvent))),
424                            ),
425                    )
426                    .child(
427                        div()
428                            .max_w_80()
429                            .child(Label::new(self.message.clone()).size(LabelSize::Small)),
430                    )
431                    .when_some(self.label_and_url_button.clone(), |elm, (label, url)| {
432                        elm.child(
433                            div().mt_2().child(
434                                ui::Button::new("error_message_prompt_notification_button", label)
435                                    .on_click(move |_, cx| cx.open_url(&url)),
436                            ),
437                        )
438                    }),
439            )
440    }
441}
442
443impl EventEmitter<DismissEvent> for ErrorMessagePrompt {}
444
445pub mod simple_message_notification {
446    use gpui::{
447        div, DismissEvent, EventEmitter, InteractiveElement, ParentElement, Render, SharedString,
448        StatefulInteractiveElement, Styled, ViewContext,
449    };
450    use std::sync::Arc;
451    use ui::prelude::*;
452    use ui::{h_flex, v_flex, Button, Icon, IconName, Label, StyledExt};
453
454    pub struct MessageNotification {
455        message: SharedString,
456        on_click: Option<Arc<dyn Fn(&mut ViewContext<Self>)>>,
457        click_message: Option<SharedString>,
458        secondary_click_message: Option<SharedString>,
459        secondary_on_click: Option<Arc<dyn Fn(&mut ViewContext<Self>)>>,
460    }
461
462    impl EventEmitter<DismissEvent> for MessageNotification {}
463
464    impl MessageNotification {
465        pub fn new<S>(message: S) -> MessageNotification
466        where
467            S: Into<SharedString>,
468        {
469            Self {
470                message: message.into(),
471                on_click: None,
472                click_message: None,
473                secondary_on_click: None,
474                secondary_click_message: None,
475            }
476        }
477
478        pub fn with_click_message<S>(mut self, message: S) -> Self
479        where
480            S: Into<SharedString>,
481        {
482            self.click_message = Some(message.into());
483            self
484        }
485
486        pub fn on_click<F>(mut self, on_click: F) -> Self
487        where
488            F: 'static + Fn(&mut ViewContext<Self>),
489        {
490            self.on_click = Some(Arc::new(on_click));
491            self
492        }
493
494        pub fn with_secondary_click_message<S>(mut self, message: S) -> Self
495        where
496            S: Into<SharedString>,
497        {
498            self.secondary_click_message = Some(message.into());
499            self
500        }
501
502        pub fn on_secondary_click<F>(mut self, on_click: F) -> Self
503        where
504            F: 'static + Fn(&mut ViewContext<Self>),
505        {
506            self.secondary_on_click = Some(Arc::new(on_click));
507            self
508        }
509
510        pub fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
511            cx.emit(DismissEvent);
512        }
513    }
514
515    impl Render for MessageNotification {
516        fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
517            v_flex()
518                .elevation_3(cx)
519                .p_4()
520                .child(
521                    h_flex()
522                        .justify_between()
523                        .child(div().max_w_80().child(Label::new(self.message.clone())))
524                        .child(
525                            div()
526                                .id("cancel")
527                                .child(Icon::new(IconName::Close))
528                                .cursor_pointer()
529                                .on_click(cx.listener(|this, _, cx| this.dismiss(cx))),
530                        ),
531                )
532                .child(
533                    h_flex()
534                        .gap_3()
535                        .children(self.click_message.iter().map(|message| {
536                            Button::new(message.clone(), message.clone()).on_click(cx.listener(
537                                |this, _, cx| {
538                                    if let Some(on_click) = this.on_click.as_ref() {
539                                        (on_click)(cx)
540                                    };
541                                    this.dismiss(cx)
542                                },
543                            ))
544                        }))
545                        .children(self.secondary_click_message.iter().map(|message| {
546                            Button::new(message.clone(), message.clone())
547                                .style(ButtonStyle::Filled)
548                                .on_click(cx.listener(|this, _, cx| {
549                                    if let Some(on_click) = this.secondary_on_click.as_ref() {
550                                        (on_click)(cx)
551                                    };
552                                    this.dismiss(cx)
553                                }))
554                        })),
555                )
556        }
557    }
558}
559
560pub trait NotifyResultExt {
561    type Ok;
562
563    fn notify_err(
564        self,
565        workspace: &mut Workspace,
566        cx: &mut ViewContext<Workspace>,
567    ) -> Option<Self::Ok>;
568
569    fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<Self::Ok>;
570}
571
572impl<T, E> NotifyResultExt for Result<T, E>
573where
574    E: std::fmt::Debug + std::fmt::Display,
575{
576    type Ok = T;
577
578    fn notify_err(self, workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Option<T> {
579        match self {
580            Ok(value) => Some(value),
581            Err(err) => {
582                log::error!("TODO {err:?}");
583                workspace.show_error(&err, cx);
584                None
585            }
586        }
587    }
588
589    fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<T> {
590        match self {
591            Ok(value) => Some(value),
592            Err(err) => {
593                log::error!("{err:?}");
594                cx.update_root(|view, cx| {
595                    if let Ok(workspace) = view.downcast::<Workspace>() {
596                        workspace.update(cx, |workspace, cx| workspace.show_error(&err, cx))
597                    }
598                })
599                .ok();
600                None
601            }
602        }
603    }
604}
605
606pub trait NotifyTaskExt {
607    fn detach_and_notify_err(self, cx: &mut WindowContext);
608}
609
610impl<R, E> NotifyTaskExt for Task<Result<R, E>>
611where
612    E: std::fmt::Debug + std::fmt::Display + Sized + 'static,
613    R: 'static,
614{
615    fn detach_and_notify_err(self, cx: &mut WindowContext) {
616        cx.spawn(|mut cx| async move { self.await.notify_async_err(&mut cx) })
617            .detach();
618    }
619}
620
621pub trait DetachAndPromptErr<R> {
622    fn prompt_err(
623        self,
624        msg: &str,
625        cx: &mut WindowContext,
626        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
627    ) -> Task<Option<R>>;
628
629    fn detach_and_prompt_err(
630        self,
631        msg: &str,
632        cx: &mut WindowContext,
633        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
634    );
635}
636
637impl<R> DetachAndPromptErr<R> for Task<anyhow::Result<R>>
638where
639    R: 'static,
640{
641    fn prompt_err(
642        self,
643        msg: &str,
644        cx: &mut WindowContext,
645        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
646    ) -> Task<Option<R>> {
647        let msg = msg.to_owned();
648        cx.spawn(|mut cx| async move {
649            let result = self.await;
650            if let Err(err) = result.as_ref() {
651                log::error!("{err:?}");
652                if let Ok(prompt) = cx.update(|cx| {
653                    let detail = f(err, cx).unwrap_or_else(|| format!("{err}. Please try again."));
654                    cx.prompt(PromptLevel::Critical, &msg, Some(&detail), &["Ok"])
655                }) {
656                    prompt.await.ok();
657                }
658                return None;
659            }
660            Some(result.unwrap())
661        })
662    }
663
664    fn detach_and_prompt_err(
665        self,
666        msg: &str,
667        cx: &mut WindowContext,
668        f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
669    ) {
670        self.prompt_err(msg, cx, f).detach();
671    }
672}