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