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 { lsp_name: Arc<str>, error: String },
 23}
 24
 25pub struct ActivityIndicator {
 26    statuses: Vec<LspStatus>,
 27    project: Model<Project>,
 28    auto_updater: Option<Model<AutoUpdater>>,
 29    context_menu_handle: PopoverMenuHandle<ContextMenu>,
 30}
 31
 32struct LspStatus {
 33    name: LanguageServerName,
 34    status: LanguageServerBinaryStatus,
 35}
 36
 37struct PendingWork<'a> {
 38    language_server_id: LanguageServerId,
 39    progress_token: &'a str,
 40    progress: &'a LanguageServerProgress,
 41}
 42
 43#[derive(Default)]
 44struct Content {
 45    icon: Option<gpui::AnyElement>,
 46    message: String,
 47    on_click: Option<Arc<dyn Fn(&mut ActivityIndicator, &mut ViewContext<ActivityIndicator>)>>,
 48}
 49
 50impl ActivityIndicator {
 51    pub fn new(
 52        workspace: &mut Workspace,
 53        languages: Arc<LanguageRegistry>,
 54        cx: &mut ViewContext<Workspace>,
 55    ) -> View<ActivityIndicator> {
 56        let project = workspace.project().clone();
 57        let auto_updater = AutoUpdater::get(cx);
 58        let this = cx.new_view(|cx: &mut ViewContext<Self>| {
 59            let mut status_events = languages.language_server_binary_statuses();
 60            cx.spawn(|this, mut cx| async move {
 61                while let Some((name, status)) = status_events.next().await {
 62                    this.update(&mut cx, |this, cx| {
 63                        this.statuses.retain(|s| s.name != name);
 64                        this.statuses.push(LspStatus { name, status });
 65                        cx.notify();
 66                    })?;
 67                }
 68                anyhow::Ok(())
 69            })
 70            .detach();
 71            cx.observe(&project, |_, _, cx| cx.notify()).detach();
 72
 73            if let Some(auto_updater) = auto_updater.as_ref() {
 74                cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
 75            }
 76
 77            Self {
 78                statuses: Default::default(),
 79                project: project.clone(),
 80                auto_updater,
 81                context_menu_handle: Default::default(),
 82            }
 83        });
 84
 85        cx.subscribe(&this, move |_, _, event, cx| match event {
 86            Event::ShowError { lsp_name, error } => {
 87                let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx));
 88                let project = project.clone();
 89                let error = error.clone();
 90                let lsp_name = lsp_name.clone();
 91                cx.spawn(|workspace, mut cx| async move {
 92                    let buffer = create_buffer.await?;
 93                    buffer.update(&mut cx, |buffer, cx| {
 94                        buffer.edit(
 95                            [(
 96                                0..0,
 97                                format!("Language server error: {}\n\n{}", lsp_name, error),
 98                            )],
 99                            None,
100                            cx,
101                        );
102                    })?;
103                    workspace.update(&mut cx, |workspace, cx| {
104                        workspace.add_item_to_active_pane(
105                            Box::new(cx.new_view(|cx| {
106                                Editor::for_buffer(buffer, Some(project.clone()), cx)
107                            })),
108                            None,
109                            true,
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        self.context_menu_handle.toggle(cx);
371    }
372}
373
374impl EventEmitter<Event> for ActivityIndicator {}
375
376impl Render for ActivityIndicator {
377    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
378        let content = self.content_to_render(cx);
379
380        let mut result = h_flex()
381            .id("activity-indicator")
382            .on_action(cx.listener(Self::show_error_message))
383            .on_action(cx.listener(Self::dismiss_error_message));
384
385        if let Some(on_click) = content.on_click {
386            result = result
387                .cursor(CursorStyle::PointingHand)
388                .on_click(cx.listener(move |this, _, cx| {
389                    on_click(this, cx);
390                }))
391        }
392        let this = cx.view().downgrade();
393        result.gap_2().child(
394            PopoverMenu::new("activity-indicator-popover")
395                .trigger(
396                    ButtonLike::new("activity-indicator-trigger").child(
397                        h_flex()
398                            .gap_2()
399                            .children(content.icon)
400                            .child(Label::new(content.message).size(LabelSize::Small)),
401                    ),
402                )
403                .anchor(gpui::AnchorCorner::BottomLeft)
404                .menu(move |cx| {
405                    let strong_this = this.upgrade()?;
406                    ContextMenu::build(cx, |mut menu, cx| {
407                        for work in strong_this.read(cx).pending_language_server_work(cx) {
408                            let this = this.clone();
409                            let mut title = work
410                                .progress
411                                .title
412                                .as_deref()
413                                .unwrap_or(work.progress_token)
414                                .to_owned();
415
416                            if work.progress.is_cancellable {
417                                let language_server_id = work.language_server_id;
418                                let token = work.progress_token.to_string();
419                                let title = SharedString::from(title);
420                                menu = menu.custom_entry(
421                                    move |_| {
422                                        h_flex()
423                                            .w_full()
424                                            .justify_between()
425                                            .child(Label::new(title.clone()))
426                                            .child(Icon::new(IconName::XCircle))
427                                            .into_any_element()
428                                    },
429                                    move |cx| {
430                                        this.update(cx, |this, cx| {
431                                            this.project.update(cx, |project, cx| {
432                                                project.cancel_language_server_work(
433                                                    language_server_id,
434                                                    Some(token.clone()),
435                                                    cx,
436                                                );
437                                            });
438                                            this.context_menu_handle.hide(cx);
439                                            cx.notify();
440                                        })
441                                        .ok();
442                                    },
443                                );
444                            } else {
445                                if let Some(progress_message) = work.progress.message.as_ref() {
446                                    title.push_str(": ");
447                                    title.push_str(progress_message);
448                                }
449
450                                menu = menu.label(title);
451                            }
452                        }
453                        menu
454                    })
455                    .into()
456                }),
457        )
458    }
459}
460
461impl StatusItemView for ActivityIndicator {
462    fn set_active_pane_item(&mut self, _: Option<&dyn ItemHandle>, _: &mut ViewContext<Self>) {}
463}