activity_indicator.rs

  1use auto_update::{AutoUpdateStatus, AutoUpdater, DismissErrorMessage, VersionCheckType};
  2use editor::Editor;
  3use extension_host::ExtensionStore;
  4use futures::StreamExt;
  5use gpui::{
  6    Animation, AnimationExt as _, App, Context, CursorStyle, Entity, EventEmitter,
  7    InteractiveElement as _, ParentElement as _, Render, SharedString, StatefulInteractiveElement,
  8    Styled, Transformation, Window, actions, percentage,
  9};
 10use language::{BinaryStatus, LanguageRegistry, LanguageServerId};
 11use project::{
 12    EnvironmentErrorMessage, LanguageServerProgress, LspStoreEvent, Project,
 13    ProjectEnvironmentEvent,
 14    git_store::{GitStoreEvent, Repository},
 15};
 16use smallvec::SmallVec;
 17use std::{
 18    cmp::Reverse,
 19    fmt::Write,
 20    path::Path,
 21    sync::Arc,
 22    time::{Duration, Instant},
 23};
 24use ui::{ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
 25use util::truncate_and_trailoff;
 26use workspace::{StatusItemView, Workspace, item::ItemHandle};
 27
 28const GIT_OPERATION_DELAY: Duration = Duration::from_millis(0);
 29
 30actions!(activity_indicator, [ShowErrorMessage]);
 31
 32pub enum Event {
 33    ShowError {
 34        server_name: SharedString,
 35        error: String,
 36    },
 37}
 38
 39pub struct ActivityIndicator {
 40    statuses: Vec<ServerStatus>,
 41    project: Entity<Project>,
 42    auto_updater: Option<Entity<AutoUpdater>>,
 43    context_menu_handle: PopoverMenuHandle<ContextMenu>,
 44}
 45
 46#[derive(Debug)]
 47struct ServerStatus {
 48    name: SharedString,
 49    status: BinaryStatus,
 50}
 51
 52struct PendingWork<'a> {
 53    language_server_id: LanguageServerId,
 54    progress_token: &'a str,
 55    progress: &'a LanguageServerProgress,
 56}
 57
 58struct Content {
 59    icon: Option<gpui::AnyElement>,
 60    message: String,
 61    on_click:
 62        Option<Arc<dyn Fn(&mut ActivityIndicator, &mut Window, &mut Context<ActivityIndicator>)>>,
 63    tooltip_message: Option<String>,
 64}
 65
 66impl ActivityIndicator {
 67    pub fn new(
 68        workspace: &mut Workspace,
 69        languages: Arc<LanguageRegistry>,
 70        window: &mut Window,
 71        cx: &mut Context<Workspace>,
 72    ) -> Entity<ActivityIndicator> {
 73        let project = workspace.project().clone();
 74        let auto_updater = AutoUpdater::get(cx);
 75        let workspace_handle = cx.entity();
 76        let this = cx.new(|cx| {
 77            let mut status_events = languages.language_server_binary_statuses();
 78            cx.spawn(async move |this, cx| {
 79                while let Some((name, status)) = status_events.next().await {
 80                    this.update(cx, |this: &mut ActivityIndicator, cx| {
 81                        this.statuses.retain(|s| s.name != name);
 82                        this.statuses.push(ServerStatus { name, status });
 83                        cx.notify();
 84                    })?;
 85                }
 86                anyhow::Ok(())
 87            })
 88            .detach();
 89
 90            cx.subscribe_in(
 91                &workspace_handle,
 92                window,
 93                |activity_indicator, _, event, window, cx| match event {
 94                    workspace::Event::ClearActivityIndicator { .. } => {
 95                        if activity_indicator.statuses.pop().is_some() {
 96                            activity_indicator.dismiss_error_message(
 97                                &DismissErrorMessage,
 98                                window,
 99                                cx,
100                            );
101                            cx.notify();
102                        }
103                    }
104                    _ => {}
105                },
106            )
107            .detach();
108
109            cx.subscribe(
110                &project.read(cx).lsp_store(),
111                |_, _, event, cx| match event {
112                    LspStoreEvent::LanguageServerUpdate { .. } => cx.notify(),
113                    _ => {}
114                },
115            )
116            .detach();
117
118            cx.subscribe(
119                &project.read(cx).environment().clone(),
120                |_, _, event, cx| match event {
121                    ProjectEnvironmentEvent::ErrorsUpdated => cx.notify(),
122                },
123            )
124            .detach();
125
126            cx.subscribe(
127                &project.read(cx).git_store().clone(),
128                |_, _, event: &GitStoreEvent, cx| match event {
129                    project::git_store::GitStoreEvent::JobsUpdated => cx.notify(),
130                    _ => {}
131                },
132            )
133            .detach();
134
135            if let Some(auto_updater) = auto_updater.as_ref() {
136                cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
137            }
138
139            Self {
140                statuses: Vec::new(),
141                project: project.clone(),
142                auto_updater,
143                context_menu_handle: Default::default(),
144            }
145        });
146
147        cx.subscribe_in(&this, window, move |_, _, event, window, cx| match event {
148            Event::ShowError { server_name, error } => {
149                let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx));
150                let project = project.clone();
151                let error = error.clone();
152                let server_name = server_name.clone();
153                cx.spawn_in(window, async move |workspace, cx| {
154                    let buffer = create_buffer.await?;
155                    buffer.update(cx, |buffer, cx| {
156                        buffer.edit(
157                            [(
158                                0..0,
159                                format!("Language server error: {}\n\n{}", server_name, error),
160                            )],
161                            None,
162                            cx,
163                        );
164                        buffer.set_capability(language::Capability::ReadOnly, cx);
165                    })?;
166                    workspace.update_in(cx, |workspace, window, cx| {
167                        workspace.add_item_to_active_pane(
168                            Box::new(cx.new(|cx| {
169                                Editor::for_buffer(buffer, Some(project.clone()), window, cx)
170                            })),
171                            None,
172                            true,
173                            window,
174                            cx,
175                        );
176                    })?;
177
178                    anyhow::Ok(())
179                })
180                .detach();
181            }
182        })
183        .detach();
184        this
185    }
186
187    fn show_error_message(&mut self, _: &ShowErrorMessage, _: &mut Window, cx: &mut Context<Self>) {
188        self.statuses.retain(|status| {
189            if let BinaryStatus::Failed { error } = &status.status {
190                cx.emit(Event::ShowError {
191                    server_name: status.name.clone(),
192                    error: error.clone(),
193                });
194                false
195            } else {
196                true
197            }
198        });
199
200        cx.notify();
201    }
202
203    fn dismiss_error_message(
204        &mut self,
205        _: &DismissErrorMessage,
206        _: &mut Window,
207        cx: &mut Context<Self>,
208    ) {
209        if let Some(updater) = &self.auto_updater {
210            updater.update(cx, |updater, cx| updater.dismiss_error(cx));
211        }
212    }
213
214    fn pending_language_server_work<'a>(
215        &self,
216        cx: &'a App,
217    ) -> impl Iterator<Item = PendingWork<'a>> {
218        self.project
219            .read(cx)
220            .language_server_statuses(cx)
221            .rev()
222            .filter_map(|(server_id, status)| {
223                if status.pending_work.is_empty() {
224                    None
225                } else {
226                    let mut pending_work = status
227                        .pending_work
228                        .iter()
229                        .map(|(token, progress)| PendingWork {
230                            language_server_id: server_id,
231                            progress_token: token.as_str(),
232                            progress,
233                        })
234                        .collect::<SmallVec<[_; 4]>>();
235                    pending_work.sort_by_key(|work| Reverse(work.progress.last_update_at));
236                    Some(pending_work)
237                }
238            })
239            .flatten()
240    }
241
242    fn pending_environment_errors<'a>(
243        &'a self,
244        cx: &'a App,
245    ) -> impl Iterator<Item = (&'a Arc<Path>, &'a EnvironmentErrorMessage)> {
246        self.project.read(cx).shell_environment_errors(cx)
247    }
248
249    fn content_to_render(&mut self, cx: &mut Context<Self>) -> Option<Content> {
250        // Show if any direnv calls failed
251        if let Some((abs_path, error)) = self.pending_environment_errors(cx).next() {
252            let abs_path = abs_path.clone();
253            return Some(Content {
254                icon: Some(
255                    Icon::new(IconName::Warning)
256                        .size(IconSize::Small)
257                        .into_any_element(),
258                ),
259                message: error.0.clone(),
260                on_click: Some(Arc::new(move |this, window, cx| {
261                    this.project.update(cx, |project, cx| {
262                        project.remove_environment_error(&abs_path, cx);
263                    });
264                    window.dispatch_action(Box::new(workspace::OpenLog), cx);
265                })),
266                tooltip_message: None,
267            });
268        }
269        // Show any language server has pending activity.
270        let mut pending_work = self.pending_language_server_work(cx);
271        if let Some(PendingWork {
272            progress_token,
273            progress,
274            ..
275        }) = pending_work.next()
276        {
277            let mut message = progress
278                .title
279                .as_deref()
280                .unwrap_or(progress_token)
281                .to_string();
282
283            if let Some(percentage) = progress.percentage {
284                write!(&mut message, " ({}%)", percentage).unwrap();
285            }
286
287            if let Some(progress_message) = progress.message.as_ref() {
288                message.push_str(": ");
289                message.push_str(progress_message);
290            }
291
292            let additional_work_count = pending_work.count();
293            if additional_work_count > 0 {
294                write!(&mut message, " + {} more", additional_work_count).unwrap();
295            }
296
297            return Some(Content {
298                icon: Some(
299                    Icon::new(IconName::ArrowCircle)
300                        .size(IconSize::Small)
301                        .with_animation(
302                            "arrow-circle",
303                            Animation::new(Duration::from_secs(2)).repeat(),
304                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
305                        )
306                        .into_any_element(),
307                ),
308                message,
309                on_click: Some(Arc::new(Self::toggle_language_server_work_context_menu)),
310                tooltip_message: None,
311            });
312        }
313
314        if let Some(session) = self
315            .project
316            .read(cx)
317            .dap_store()
318            .read(cx)
319            .sessions()
320            .find(|s| !s.read(cx).is_started())
321        {
322            return Some(Content {
323                icon: Some(
324                    Icon::new(IconName::ArrowCircle)
325                        .size(IconSize::Small)
326                        .with_animation(
327                            "arrow-circle",
328                            Animation::new(Duration::from_secs(2)).repeat(),
329                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
330                        )
331                        .into_any_element(),
332                ),
333                message: format!("Debug: {}", session.read(cx).adapter()),
334                tooltip_message: Some(session.read(cx).label().to_string()),
335                on_click: None,
336            });
337        }
338
339        let current_job = self
340            .project
341            .read(cx)
342            .active_repository(cx)
343            .map(|r| r.read(cx))
344            .and_then(Repository::current_job);
345        // Show any long-running git command
346        if let Some(job_info) = current_job {
347            if Instant::now() - job_info.start >= GIT_OPERATION_DELAY {
348                return Some(Content {
349                    icon: Some(
350                        Icon::new(IconName::ArrowCircle)
351                            .size(IconSize::Small)
352                            .with_animation(
353                                "arrow-circle",
354                                Animation::new(Duration::from_secs(2)).repeat(),
355                                |icon, delta| {
356                                    icon.transform(Transformation::rotate(percentage(delta)))
357                                },
358                            )
359                            .into_any_element(),
360                    ),
361                    message: job_info.message.into(),
362                    on_click: None,
363                    tooltip_message: None,
364                });
365            }
366        }
367
368        // Show any language server installation info.
369        let mut downloading = SmallVec::<[_; 3]>::new();
370        let mut checking_for_update = SmallVec::<[_; 3]>::new();
371        let mut failed = SmallVec::<[_; 3]>::new();
372        for status in &self.statuses {
373            match status.status {
374                BinaryStatus::CheckingForUpdate => checking_for_update.push(status.name.clone()),
375                BinaryStatus::Downloading => downloading.push(status.name.clone()),
376                BinaryStatus::Failed { .. } => failed.push(status.name.clone()),
377                BinaryStatus::None => {}
378            }
379        }
380
381        if !downloading.is_empty() {
382            return Some(Content {
383                icon: Some(
384                    Icon::new(IconName::Download)
385                        .size(IconSize::Small)
386                        .into_any_element(),
387                ),
388                message: format!(
389                    "Downloading {}...",
390                    downloading.iter().map(|name| name.as_ref()).fold(
391                        String::new(),
392                        |mut acc, s| {
393                            if !acc.is_empty() {
394                                acc.push_str(", ");
395                            }
396                            acc.push_str(s);
397                            acc
398                        }
399                    )
400                ),
401                on_click: Some(Arc::new(move |this, window, cx| {
402                    this.statuses
403                        .retain(|status| !downloading.contains(&status.name));
404                    this.dismiss_error_message(&DismissErrorMessage, window, cx)
405                })),
406                tooltip_message: None,
407            });
408        }
409
410        if !checking_for_update.is_empty() {
411            return Some(Content {
412                icon: Some(
413                    Icon::new(IconName::Download)
414                        .size(IconSize::Small)
415                        .into_any_element(),
416                ),
417                message: format!(
418                    "Checking for updates to {}...",
419                    checking_for_update.iter().map(|name| name.as_ref()).fold(
420                        String::new(),
421                        |mut acc, s| {
422                            if !acc.is_empty() {
423                                acc.push_str(", ");
424                            }
425                            acc.push_str(s);
426                            acc
427                        }
428                    ),
429                ),
430                on_click: Some(Arc::new(move |this, window, cx| {
431                    this.statuses
432                        .retain(|status| !checking_for_update.contains(&status.name));
433                    this.dismiss_error_message(&DismissErrorMessage, window, cx)
434                })),
435                tooltip_message: None,
436            });
437        }
438
439        if !failed.is_empty() {
440            return Some(Content {
441                icon: Some(
442                    Icon::new(IconName::Warning)
443                        .size(IconSize::Small)
444                        .into_any_element(),
445                ),
446                message: format!(
447                    "Failed to run {}. Click to show error.",
448                    failed
449                        .iter()
450                        .map(|name| name.as_ref())
451                        .fold(String::new(), |mut acc, s| {
452                            if !acc.is_empty() {
453                                acc.push_str(", ");
454                            }
455                            acc.push_str(s);
456                            acc
457                        }),
458                ),
459                on_click: Some(Arc::new(|this, window, cx| {
460                    this.show_error_message(&Default::default(), window, cx)
461                })),
462                tooltip_message: None,
463            });
464        }
465
466        // Show any formatting failure
467        if let Some(failure) = self.project.read(cx).last_formatting_failure(cx) {
468            return Some(Content {
469                icon: Some(
470                    Icon::new(IconName::Warning)
471                        .size(IconSize::Small)
472                        .into_any_element(),
473                ),
474                message: format!("Formatting failed: {}. Click to see logs.", failure),
475                on_click: Some(Arc::new(|indicator, window, cx| {
476                    indicator.project.update(cx, |project, cx| {
477                        project.reset_last_formatting_failure(cx);
478                    });
479                    window.dispatch_action(Box::new(workspace::OpenLog), cx);
480                })),
481                tooltip_message: None,
482            });
483        }
484
485        // Show any application auto-update info.
486        if let Some(updater) = &self.auto_updater {
487            return match &updater.read(cx).status() {
488                AutoUpdateStatus::Checking => Some(Content {
489                    icon: Some(
490                        Icon::new(IconName::Download)
491                            .size(IconSize::Small)
492                            .into_any_element(),
493                    ),
494                    message: "Checking for Zed updates…".to_string(),
495                    on_click: Some(Arc::new(|this, window, cx| {
496                        this.dismiss_error_message(&DismissErrorMessage, window, cx)
497                    })),
498                    tooltip_message: None,
499                }),
500                AutoUpdateStatus::Downloading { version } => Some(Content {
501                    icon: Some(
502                        Icon::new(IconName::Download)
503                            .size(IconSize::Small)
504                            .into_any_element(),
505                    ),
506                    message: "Downloading Zed update…".to_string(),
507                    on_click: Some(Arc::new(|this, window, cx| {
508                        this.dismiss_error_message(&DismissErrorMessage, window, cx)
509                    })),
510                    tooltip_message: Some(Self::version_tooltip_message(&version)),
511                }),
512                AutoUpdateStatus::Installing { version } => Some(Content {
513                    icon: Some(
514                        Icon::new(IconName::Download)
515                            .size(IconSize::Small)
516                            .into_any_element(),
517                    ),
518                    message: "Installing Zed update…".to_string(),
519                    on_click: Some(Arc::new(|this, window, cx| {
520                        this.dismiss_error_message(&DismissErrorMessage, window, cx)
521                    })),
522                    tooltip_message: Some(Self::version_tooltip_message(&version)),
523                }),
524                AutoUpdateStatus::Updated {
525                    binary_path,
526                    version,
527                } => Some(Content {
528                    icon: None,
529                    message: "Click to restart and update Zed".to_string(),
530                    on_click: Some(Arc::new({
531                        let reload = workspace::Reload {
532                            binary_path: Some(binary_path.clone()),
533                        };
534                        move |_, _, cx| workspace::reload(&reload, cx)
535                    })),
536                    tooltip_message: Some(Self::version_tooltip_message(&version)),
537                }),
538                AutoUpdateStatus::Errored => Some(Content {
539                    icon: Some(
540                        Icon::new(IconName::Warning)
541                            .size(IconSize::Small)
542                            .into_any_element(),
543                    ),
544                    message: "Auto update failed".to_string(),
545                    on_click: Some(Arc::new(|this, window, cx| {
546                        this.dismiss_error_message(&DismissErrorMessage, window, cx)
547                    })),
548                    tooltip_message: None,
549                }),
550                AutoUpdateStatus::Idle => None,
551            };
552        }
553
554        if let Some(extension_store) =
555            ExtensionStore::try_global(cx).map(|extension_store| extension_store.read(cx))
556        {
557            if let Some(extension_id) = extension_store.outstanding_operations().keys().next() {
558                return Some(Content {
559                    icon: Some(
560                        Icon::new(IconName::Download)
561                            .size(IconSize::Small)
562                            .into_any_element(),
563                    ),
564                    message: format!("Updating {extension_id} extension…"),
565                    on_click: Some(Arc::new(|this, window, cx| {
566                        this.dismiss_error_message(&DismissErrorMessage, window, cx)
567                    })),
568                    tooltip_message: None,
569                });
570            }
571        }
572
573        None
574    }
575
576    fn version_tooltip_message(version: &VersionCheckType) -> String {
577        format!("Version: {}", {
578            match version {
579                auto_update::VersionCheckType::Sha(sha) => format!("{}", sha.short()),
580                auto_update::VersionCheckType::Semantic(semantic_version) => {
581                    semantic_version.to_string()
582                }
583            }
584        })
585    }
586
587    fn toggle_language_server_work_context_menu(
588        &mut self,
589        window: &mut Window,
590        cx: &mut Context<Self>,
591    ) {
592        self.context_menu_handle.toggle(window, cx);
593    }
594}
595
596impl EventEmitter<Event> for ActivityIndicator {}
597
598const MAX_MESSAGE_LEN: usize = 50;
599
600impl Render for ActivityIndicator {
601    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
602        let result = h_flex()
603            .id("activity-indicator")
604            .on_action(cx.listener(Self::show_error_message))
605            .on_action(cx.listener(Self::dismiss_error_message));
606        let Some(content) = self.content_to_render(cx) else {
607            return result;
608        };
609        let this = cx.entity().downgrade();
610        let truncate_content = content.message.len() > MAX_MESSAGE_LEN;
611        result.gap_2().child(
612            PopoverMenu::new("activity-indicator-popover")
613                .trigger(
614                    ButtonLike::new("activity-indicator-trigger").child(
615                        h_flex()
616                            .id("activity-indicator-status")
617                            .gap_2()
618                            .children(content.icon)
619                            .map(|button| {
620                                if truncate_content {
621                                    button
622                                        .child(
623                                            Label::new(truncate_and_trailoff(
624                                                &content.message,
625                                                MAX_MESSAGE_LEN,
626                                            ))
627                                            .size(LabelSize::Small),
628                                        )
629                                        .tooltip(Tooltip::text(content.message))
630                                } else {
631                                    button
632                                        .child(Label::new(content.message).size(LabelSize::Small))
633                                        .when_some(
634                                            content.tooltip_message,
635                                            |this, tooltip_message| {
636                                                this.tooltip(Tooltip::text(tooltip_message))
637                                            },
638                                        )
639                                }
640                            })
641                            .when_some(content.on_click, |this, handler| {
642                                this.on_click(cx.listener(move |this, _, window, cx| {
643                                    handler(this, window, cx);
644                                }))
645                                .cursor(CursorStyle::PointingHand)
646                            }),
647                    ),
648                )
649                .anchor(gpui::Corner::BottomLeft)
650                .menu(move |window, cx| {
651                    let strong_this = this.upgrade()?;
652                    let mut has_work = false;
653                    let menu = ContextMenu::build(window, cx, |mut menu, _, cx| {
654                        for work in strong_this.read(cx).pending_language_server_work(cx) {
655                            has_work = true;
656                            let this = this.clone();
657                            let mut title = work
658                                .progress
659                                .title
660                                .as_deref()
661                                .unwrap_or(work.progress_token)
662                                .to_owned();
663
664                            if work.progress.is_cancellable {
665                                let language_server_id = work.language_server_id;
666                                let token = work.progress_token.to_string();
667                                let title = SharedString::from(title);
668                                menu = menu.custom_entry(
669                                    move |_, _| {
670                                        h_flex()
671                                            .w_full()
672                                            .justify_between()
673                                            .child(Label::new(title.clone()))
674                                            .child(Icon::new(IconName::XCircle))
675                                            .into_any_element()
676                                    },
677                                    move |_, cx| {
678                                        this.update(cx, |this, cx| {
679                                            this.project.update(cx, |project, cx| {
680                                                project.cancel_language_server_work(
681                                                    language_server_id,
682                                                    Some(token.clone()),
683                                                    cx,
684                                                );
685                                            });
686                                            this.context_menu_handle.hide(cx);
687                                            cx.notify();
688                                        })
689                                        .ok();
690                                    },
691                                );
692                            } else {
693                                if let Some(progress_message) = work.progress.message.as_ref() {
694                                    title.push_str(": ");
695                                    title.push_str(progress_message);
696                                }
697
698                                menu = menu.label(title);
699                            }
700                        }
701                        menu
702                    });
703                    has_work.then_some(menu)
704                }),
705        )
706    }
707}
708
709impl StatusItemView for ActivityIndicator {
710    fn set_active_pane_item(
711        &mut self,
712        _: Option<&dyn ItemHandle>,
713        _window: &mut Window,
714        _: &mut Context<Self>,
715    ) {
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use gpui::SemanticVersion;
722    use release_channel::AppCommitSha;
723
724    use super::*;
725
726    #[test]
727    fn test_version_tooltip_message() {
728        let message = ActivityIndicator::version_tooltip_message(&VersionCheckType::Semantic(
729            SemanticVersion::new(1, 0, 0),
730        ));
731
732        assert_eq!(message, "Version: 1.0.0");
733
734        let message = ActivityIndicator::version_tooltip_message(&VersionCheckType::Sha(
735            AppCommitSha::new("14d9a4189f058d8736339b06ff2340101eaea5af".to_string()),
736        ));
737
738        assert_eq!(message, "Version: 14d9a41…");
739    }
740}