notifications.rs

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