activity_indicator.rs

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