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