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