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}