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