activity_indicator.rs

  1use auto_update::{AutoUpdateStatus, AutoUpdater, DismissErrorMessage};
  2use editor::Editor;
  3use extension::ExtensionStore;
  4use futures::StreamExt;
  5use gpui::{
  6    actions, percentage, Animation, AnimationExt as _, AppContext, CursorStyle, EventEmitter,
  7    InteractiveElement as _, Model, ParentElement as _, Render, SharedString,
  8    StatefulInteractiveElement, Styled, Transformation, View, ViewContext, VisualContext as _,
  9};
 10use language::{
 11    LanguageRegistry, LanguageServerBinaryStatus, LanguageServerId, LanguageServerName,
 12};
 13use project::{LanguageServerProgress, Project};
 14use smallvec::SmallVec;
 15use std::{cmp::Reverse, fmt::Write, sync::Arc, time::Duration};
 16use ui::{prelude::*, ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle};
 17use workspace::{item::ItemHandle, StatusItemView, Workspace};
 18
 19actions!(activity_indicator, [ShowErrorMessage]);
 20
 21pub enum Event {
 22    ShowError {
 23        lsp_name: LanguageServerName,
 24        error: String,
 25    },
 26}
 27
 28pub struct ActivityIndicator {
 29    statuses: Vec<LspStatus>,
 30    project: Model<Project>,
 31    auto_updater: Option<Model<AutoUpdater>>,
 32    context_menu_handle: PopoverMenuHandle<ContextMenu>,
 33}
 34
 35struct LspStatus {
 36    name: LanguageServerName,
 37    status: LanguageServerBinaryStatus,
 38}
 39
 40struct PendingWork<'a> {
 41    language_server_id: LanguageServerId,
 42    progress_token: &'a str,
 43    progress: &'a LanguageServerProgress,
 44}
 45
 46struct Content {
 47    icon: Option<gpui::AnyElement>,
 48    message: String,
 49    on_click: Option<Arc<dyn Fn(&mut ActivityIndicator, &mut ViewContext<ActivityIndicator>)>>,
 50}
 51
 52impl ActivityIndicator {
 53    pub fn new(
 54        workspace: &mut Workspace,
 55        languages: Arc<LanguageRegistry>,
 56        cx: &mut ViewContext<Workspace>,
 57    ) -> View<ActivityIndicator> {
 58        let project = workspace.project().clone();
 59        let auto_updater = AutoUpdater::get(cx);
 60        let this = cx.new_view(|cx: &mut ViewContext<Self>| {
 61            let mut status_events = languages.language_server_binary_statuses();
 62            cx.spawn(|this, mut cx| async move {
 63                while let Some((name, status)) = status_events.next().await {
 64                    this.update(&mut cx, |this, cx| {
 65                        this.statuses.retain(|s| s.name != name);
 66                        this.statuses.push(LspStatus { name, status });
 67                        cx.notify();
 68                    })?;
 69                }
 70                anyhow::Ok(())
 71            })
 72            .detach();
 73            cx.observe(&project, |_, _, cx| cx.notify()).detach();
 74
 75            if let Some(auto_updater) = auto_updater.as_ref() {
 76                cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
 77            }
 78
 79            Self {
 80                statuses: Default::default(),
 81                project: project.clone(),
 82                auto_updater,
 83                context_menu_handle: Default::default(),
 84            }
 85        });
 86
 87        cx.subscribe(&this, move |_, _, event, cx| match event {
 88            Event::ShowError { lsp_name, error } => {
 89                let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx));
 90                let project = project.clone();
 91                let error = error.clone();
 92                let lsp_name = lsp_name.clone();
 93                cx.spawn(|workspace, mut cx| async move {
 94                    let buffer = create_buffer.await?;
 95                    buffer.update(&mut cx, |buffer, cx| {
 96                        buffer.edit(
 97                            [(
 98                                0..0,
 99                                format!("Language server error: {}\n\n{}", lsp_name, error),
100                            )],
101                            None,
102                            cx,
103                        );
104                    })?;
105                    workspace.update(&mut cx, |workspace, cx| {
106                        workspace.add_item_to_active_pane(
107                            Box::new(cx.new_view(|cx| {
108                                Editor::for_buffer(buffer, Some(project.clone()), cx)
109                            })),
110                            None,
111                            true,
112                            cx,
113                        );
114                    })?;
115
116                    anyhow::Ok(())
117                })
118                .detach();
119            }
120        })
121        .detach();
122        this
123    }
124
125    fn show_error_message(&mut self, _: &ShowErrorMessage, cx: &mut ViewContext<Self>) {
126        self.statuses.retain(|status| {
127            if let LanguageServerBinaryStatus::Failed { error } = &status.status {
128                cx.emit(Event::ShowError {
129                    lsp_name: status.name.clone(),
130                    error: error.clone(),
131                });
132                false
133            } else {
134                true
135            }
136        });
137
138        cx.notify();
139    }
140
141    fn dismiss_error_message(&mut self, _: &DismissErrorMessage, cx: &mut ViewContext<Self>) {
142        if let Some(updater) = &self.auto_updater {
143            updater.update(cx, |updater, cx| {
144                updater.dismiss_error(cx);
145            });
146        }
147        cx.notify();
148    }
149
150    fn pending_language_server_work<'a>(
151        &self,
152        cx: &'a AppContext,
153    ) -> impl Iterator<Item = PendingWork<'a>> {
154        self.project
155            .read(cx)
156            .language_server_statuses(cx)
157            .rev()
158            .filter_map(|(server_id, status)| {
159                if status.pending_work.is_empty() {
160                    None
161                } else {
162                    let mut pending_work = status
163                        .pending_work
164                        .iter()
165                        .map(|(token, progress)| PendingWork {
166                            language_server_id: server_id,
167                            progress_token: token.as_str(),
168                            progress,
169                        })
170                        .collect::<SmallVec<[_; 4]>>();
171                    pending_work.sort_by_key(|work| Reverse(work.progress.last_update_at));
172                    Some(pending_work)
173                }
174            })
175            .flatten()
176    }
177
178    fn content_to_render(&mut self, cx: &mut ViewContext<Self>) -> Option<Content> {
179        // Show any language server has pending activity.
180        let mut pending_work = self.pending_language_server_work(cx);
181        if let Some(PendingWork {
182            progress_token,
183            progress,
184            ..
185        }) = pending_work.next()
186        {
187            let mut message = progress
188                .title
189                .as_deref()
190                .unwrap_or(progress_token)
191                .to_string();
192
193            if let Some(percentage) = progress.percentage {
194                write!(&mut message, " ({}%)", percentage).unwrap();
195            }
196
197            if let Some(progress_message) = progress.message.as_ref() {
198                message.push_str(": ");
199                message.push_str(progress_message);
200            }
201
202            let additional_work_count = pending_work.count();
203            if additional_work_count > 0 {
204                write!(&mut message, " + {} more", additional_work_count).unwrap();
205            }
206
207            return Some(Content {
208                icon: Some(
209                    Icon::new(IconName::ArrowCircle)
210                        .size(IconSize::Small)
211                        .with_animation(
212                            "arrow-circle",
213                            Animation::new(Duration::from_secs(2)).repeat(),
214                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
215                        )
216                        .into_any_element(),
217                ),
218                message,
219                on_click: Some(Arc::new(Self::toggle_language_server_work_context_menu)),
220            });
221        }
222
223        // Show any language server installation info.
224        let mut downloading = SmallVec::<[_; 3]>::new();
225        let mut checking_for_update = SmallVec::<[_; 3]>::new();
226        let mut failed = SmallVec::<[_; 3]>::new();
227        for status in &self.statuses {
228            match status.status {
229                LanguageServerBinaryStatus::CheckingForUpdate => {
230                    checking_for_update.push(status.name.clone())
231                }
232                LanguageServerBinaryStatus::Downloading => downloading.push(status.name.clone()),
233                LanguageServerBinaryStatus::Failed { .. } => failed.push(status.name.clone()),
234                LanguageServerBinaryStatus::None => {}
235            }
236        }
237
238        if !downloading.is_empty() {
239            return Some(Content {
240                icon: Some(
241                    Icon::new(IconName::Download)
242                        .size(IconSize::Small)
243                        .into_any_element(),
244                ),
245                message: format!(
246                    "Downloading {}...",
247                    downloading.iter().map(|name| name.0.as_ref()).fold(
248                        String::new(),
249                        |mut acc, s| {
250                            if !acc.is_empty() {
251                                acc.push_str(", ");
252                            }
253                            acc.push_str(s);
254                            acc
255                        }
256                    )
257                ),
258                on_click: Some(Arc::new(move |this, cx| {
259                    this.statuses
260                        .retain(|status| !downloading.contains(&status.name));
261                    this.dismiss_error_message(&DismissErrorMessage, cx)
262                })),
263            });
264        }
265
266        if !checking_for_update.is_empty() {
267            return Some(Content {
268                icon: Some(
269                    Icon::new(IconName::Download)
270                        .size(IconSize::Small)
271                        .into_any_element(),
272                ),
273                message: format!(
274                    "Checking for updates to {}...",
275                    checking_for_update.iter().map(|name| name.0.as_ref()).fold(
276                        String::new(),
277                        |mut acc, s| {
278                            if !acc.is_empty() {
279                                acc.push_str(", ");
280                            }
281                            acc.push_str(s);
282                            acc
283                        }
284                    ),
285                ),
286                on_click: Some(Arc::new(move |this, cx| {
287                    this.statuses
288                        .retain(|status| !checking_for_update.contains(&status.name));
289                    this.dismiss_error_message(&DismissErrorMessage, cx)
290                })),
291            });
292        }
293
294        if !failed.is_empty() {
295            return Some(Content {
296                icon: Some(
297                    Icon::new(IconName::Warning)
298                        .size(IconSize::Small)
299                        .into_any_element(),
300                ),
301                message: format!(
302                    "Failed to download {}. Click to show error.",
303                    failed
304                        .iter()
305                        .map(|name| name.0.as_ref())
306                        .fold(String::new(), |mut acc, s| {
307                            if !acc.is_empty() {
308                                acc.push_str(", ");
309                            }
310                            acc.push_str(s);
311                            acc
312                        }),
313                ),
314                on_click: Some(Arc::new(|this, cx| {
315                    this.show_error_message(&Default::default(), cx)
316                })),
317            });
318        }
319
320        // Show any formatting failure
321        if let Some(failure) = self.project.read(cx).last_formatting_failure(cx) {
322            return Some(Content {
323                icon: Some(
324                    Icon::new(IconName::Warning)
325                        .size(IconSize::Small)
326                        .into_any_element(),
327                ),
328                message: format!("Formatting failed: {}. Click to see logs.", failure),
329                on_click: Some(Arc::new(|_, cx| {
330                    cx.dispatch_action(Box::new(workspace::OpenLog));
331                })),
332            });
333        }
334
335        // Show any application auto-update info.
336        if let Some(updater) = &self.auto_updater {
337            return match &updater.read(cx).status() {
338                AutoUpdateStatus::Checking => Some(Content {
339                    icon: Some(
340                        Icon::new(IconName::Download)
341                            .size(IconSize::Small)
342                            .into_any_element(),
343                    ),
344                    message: "Checking for Zed updates…".to_string(),
345                    on_click: Some(Arc::new(|this, cx| {
346                        this.dismiss_error_message(&DismissErrorMessage, cx)
347                    })),
348                }),
349                AutoUpdateStatus::Downloading => Some(Content {
350                    icon: Some(
351                        Icon::new(IconName::Download)
352                            .size(IconSize::Small)
353                            .into_any_element(),
354                    ),
355                    message: "Downloading Zed update…".to_string(),
356                    on_click: Some(Arc::new(|this, cx| {
357                        this.dismiss_error_message(&DismissErrorMessage, cx)
358                    })),
359                }),
360                AutoUpdateStatus::Installing => Some(Content {
361                    icon: Some(
362                        Icon::new(IconName::Download)
363                            .size(IconSize::Small)
364                            .into_any_element(),
365                    ),
366                    message: "Installing Zed update…".to_string(),
367                    on_click: Some(Arc::new(|this, cx| {
368                        this.dismiss_error_message(&DismissErrorMessage, cx)
369                    })),
370                }),
371                AutoUpdateStatus::Updated { binary_path } => Some(Content {
372                    icon: None,
373                    message: "Click to restart and update Zed".to_string(),
374                    on_click: Some(Arc::new({
375                        let reload = workspace::Reload {
376                            binary_path: Some(binary_path.clone()),
377                        };
378                        move |_, cx| workspace::reload(&reload, cx)
379                    })),
380                }),
381                AutoUpdateStatus::Errored => Some(Content {
382                    icon: Some(
383                        Icon::new(IconName::Warning)
384                            .size(IconSize::Small)
385                            .into_any_element(),
386                    ),
387                    message: "Auto update failed".to_string(),
388                    on_click: Some(Arc::new(|this, cx| {
389                        this.dismiss_error_message(&DismissErrorMessage, cx)
390                    })),
391                }),
392                AutoUpdateStatus::Idle => None,
393            };
394        }
395
396        if let Some(extension_store) =
397            ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
398        {
399            if let Some(extension_id) = extension_store.outstanding_operations().keys().next() {
400                return Some(Content {
401                    icon: Some(
402                        Icon::new(IconName::Download)
403                            .size(IconSize::Small)
404                            .into_any_element(),
405                    ),
406                    message: format!("Updating {extension_id} extension…"),
407                    on_click: Some(Arc::new(|this, cx| {
408                        this.dismiss_error_message(&DismissErrorMessage, cx)
409                    })),
410                });
411            }
412        }
413
414        None
415    }
416
417    fn toggle_language_server_work_context_menu(&mut self, cx: &mut ViewContext<Self>) {
418        self.context_menu_handle.toggle(cx);
419    }
420}
421
422impl EventEmitter<Event> for ActivityIndicator {}
423
424impl Render for ActivityIndicator {
425    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
426        let result = h_flex()
427            .id("activity-indicator")
428            .on_action(cx.listener(Self::show_error_message))
429            .on_action(cx.listener(Self::dismiss_error_message));
430        let Some(content) = self.content_to_render(cx) else {
431            return result;
432        };
433        let this = cx.view().downgrade();
434        result.gap_2().child(
435            PopoverMenu::new("activity-indicator-popover")
436                .trigger(
437                    ButtonLike::new("activity-indicator-trigger").child(
438                        h_flex()
439                            .id("activity-indicator-status")
440                            .gap_2()
441                            .children(content.icon)
442                            .child(Label::new(content.message).size(LabelSize::Small))
443                            .when_some(content.on_click, |this, handler| {
444                                this.on_click(cx.listener(move |this, _, cx| {
445                                    handler(this, cx);
446                                }))
447                                .cursor(CursorStyle::PointingHand)
448                            }),
449                    ),
450                )
451                .anchor(gpui::AnchorCorner::BottomLeft)
452                .menu(move |cx| {
453                    let strong_this = this.upgrade()?;
454                    let mut has_work = false;
455                    let menu = ContextMenu::build(cx, |mut menu, cx| {
456                        for work in strong_this.read(cx).pending_language_server_work(cx) {
457                            has_work = true;
458                            let this = this.clone();
459                            let mut title = work
460                                .progress
461                                .title
462                                .as_deref()
463                                .unwrap_or(work.progress_token)
464                                .to_owned();
465
466                            if work.progress.is_cancellable {
467                                let language_server_id = work.language_server_id;
468                                let token = work.progress_token.to_string();
469                                let title = SharedString::from(title);
470                                menu = menu.custom_entry(
471                                    move |_| {
472                                        h_flex()
473                                            .w_full()
474                                            .justify_between()
475                                            .child(Label::new(title.clone()))
476                                            .child(Icon::new(IconName::XCircle))
477                                            .into_any_element()
478                                    },
479                                    move |cx| {
480                                        this.update(cx, |this, cx| {
481                                            this.project.update(cx, |project, cx| {
482                                                project.cancel_language_server_work(
483                                                    language_server_id,
484                                                    Some(token.clone()),
485                                                    cx,
486                                                );
487                                            });
488                                            this.context_menu_handle.hide(cx);
489                                            cx.notify();
490                                        })
491                                        .ok();
492                                    },
493                                );
494                            } else {
495                                if let Some(progress_message) = work.progress.message.as_ref() {
496                                    title.push_str(": ");
497                                    title.push_str(progress_message);
498                                }
499
500                                menu = menu.label(title);
501                            }
502                        }
503                        menu
504                    });
505                    has_work.then_some(menu)
506                }),
507        )
508    }
509}
510
511impl StatusItemView for ActivityIndicator {
512    fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, _: &mut ViewContext<Self>) {}
513}