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