From 89d2ace713cdd94775cefe8936a6b00fa75e2192 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 18 Jun 2024 12:44:35 -0700 Subject: [PATCH] Make LSP task cancellation discoverable (#13226) Release Notes: - Added the ability to cancel a cargo check by clicking on the status bar item. --- .../src/activity_indicator.rs | 99 +++++++++++++++++-- crates/collab/src/tests/editor_tests.rs | 8 +- crates/collab/src/tests/integration_tests.rs | 8 +- crates/project/src/project.rs | 46 ++++++--- crates/ui/src/components/context_menu.rs | 13 +++ 5 files changed, 144 insertions(+), 30 deletions(-) diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 25509544d7..89023c12cf 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -3,15 +3,18 @@ use editor::Editor; use extension::ExtensionStore; use futures::StreamExt; use gpui::{ - actions, percentage, Animation, AnimationExt as _, AppContext, CursorStyle, EventEmitter, - InteractiveElement as _, Model, ParentElement as _, Render, SharedString, - StatefulInteractiveElement, Styled, Transformation, View, ViewContext, VisualContext as _, + actions, anchored, deferred, percentage, Animation, AnimationExt as _, AppContext, CursorStyle, + DismissEvent, EventEmitter, InteractiveElement as _, Model, ParentElement as _, Render, + SharedString, StatefulInteractiveElement, Styled, Transformation, View, ViewContext, + VisualContext as _, +}; +use language::{ + LanguageRegistry, LanguageServerBinaryStatus, LanguageServerId, LanguageServerName, }; -use language::{LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName}; use project::{LanguageServerProgress, Project}; use smallvec::SmallVec; use std::{cmp::Reverse, fmt::Write, sync::Arc, time::Duration}; -use ui::prelude::*; +use ui::{prelude::*, ContextMenu}; use workspace::{item::ItemHandle, StatusItemView, Workspace}; actions!(activity_indicator, [ShowErrorMessage]); @@ -24,6 +27,7 @@ pub struct ActivityIndicator { statuses: Vec, project: Model, auto_updater: Option>, + context_menu: Option>, } struct LspStatus { @@ -32,6 +36,7 @@ struct LspStatus { } struct PendingWork<'a> { + language_server_id: LanguageServerId, progress_token: &'a str, progress: &'a LanguageServerProgress, } @@ -74,6 +79,7 @@ impl ActivityIndicator { statuses: Default::default(), project: project.clone(), auto_updater, + context_menu: None, } }); @@ -147,7 +153,7 @@ impl ActivityIndicator { .read(cx) .language_server_statuses() .rev() - .filter_map(|status| { + .filter_map(|(server_id, status)| { if status.pending_work.is_empty() { None } else { @@ -155,6 +161,7 @@ impl ActivityIndicator { .pending_work .iter() .map(|(token, progress)| PendingWork { + language_server_id: server_id, progress_token: token.as_str(), progress, }) @@ -172,6 +179,7 @@ impl ActivityIndicator { if let Some(PendingWork { progress_token, progress, + .. }) = pending_work.next() { let mut message = progress @@ -206,7 +214,7 @@ impl ActivityIndicator { .into_any_element(), ), message, - on_click: None, + on_click: Some(Arc::new(Self::toggle_language_server_work_context_menu)), }; } @@ -357,6 +365,75 @@ impl ActivityIndicator { Default::default() } + + fn toggle_language_server_work_context_menu(&mut self, cx: &mut ViewContext) { + if self.context_menu.take().is_some() { + return; + } + + self.build_lsp_work_context_menu(cx); + cx.notify(); + } + + fn build_lsp_work_context_menu(&mut self, cx: &mut ViewContext) { + let mut has_work = false; + let this = cx.view().downgrade(); + let context_menu = ContextMenu::build(cx, |mut menu, cx| { + for work in self.pending_language_server_work(cx) { + has_work = true; + + let this = this.clone(); + let title = SharedString::from( + work.progress + .title + .as_deref() + .unwrap_or(work.progress_token) + .to_string(), + ); + if work.progress.is_cancellable { + let language_server_id = work.language_server_id; + let token = work.progress_token.to_string(); + menu = menu.custom_entry( + move |_| { + h_flex() + .w_full() + .justify_between() + .child(Label::new(title.clone())) + .child(Icon::new(IconName::XCircle)) + .into_any_element() + }, + move |cx| { + this.update(cx, |this, cx| { + this.project.update(cx, |project, cx| { + project.cancel_language_server_work( + language_server_id, + Some(token.clone()), + cx, + ); + }); + this.context_menu.take(); + }) + .ok(); + }, + ); + } else { + menu = menu.label(title.clone()); + } + } + menu + }); + + if has_work { + cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { + this.context_menu.take(); + cx.notify(); + }) + .detach(); + cx.focus_view(&context_menu); + self.context_menu = Some(context_menu); + cx.notify(); + } + } } impl EventEmitter for ActivityIndicator {} @@ -382,6 +459,14 @@ impl Render for ActivityIndicator { .gap_2() .children(content.icon) .child(Label::new(SharedString::from(content.message)).size(LabelSize::Small)) + .children(self.context_menu.as_ref().map(|menu| { + deferred( + anchored() + .anchor(gpui::AnchorCorner::BottomLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) } } diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index e68b7fb372..ee02862d10 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -1020,7 +1020,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes executor.run_until_parked(); project_a.read_with(cx_a, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "the-language-server"); assert_eq!(status.pending_work.len(), 1); assert_eq!( @@ -1037,7 +1037,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes let project_b = client_b.build_dev_server_project(project_id, cx_b).await; project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "the-language-server"); }); @@ -1054,7 +1054,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes executor.run_until_parked(); project_a.read_with(cx_a, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "the-language-server"); assert_eq!(status.pending_work.len(), 1); assert_eq!( @@ -1064,7 +1064,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes }); project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "the-language-server"); assert_eq!(status.pending_work.len(), 1); assert_eq!( diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 7d191288af..f8e5c483af 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -4772,7 +4772,7 @@ async fn test_references( // User is informed that a request is pending. executor.run_until_parked(); project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().cloned().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "my-fake-lsp-adapter"); assert_eq!( status.pending_work.values().next().unwrap().message, @@ -4802,7 +4802,7 @@ async fn test_references( executor.run_until_parked(); project_b.read_with(cx_b, |project, cx| { // User is informed that a request is no longer pending. - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert!(status.pending_work.is_empty()); assert_eq!(references.len(), 3); @@ -4830,7 +4830,7 @@ async fn test_references( // User is informed that a request is pending. executor.run_until_parked(); project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().cloned().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert_eq!(status.name, "my-fake-lsp-adapter"); assert_eq!( status.pending_work.values().next().unwrap().message, @@ -4847,7 +4847,7 @@ async fn test_references( // User is informed that the request is no longer pending. executor.run_until_parked(); project_b.read_with(cx_b, |project, _| { - let status = project.language_server_statuses().next().unwrap(); + let status = project.language_server_statuses().next().unwrap().1; assert!(status.pending_work.is_empty()); }); } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 7c1c6bbdbd..15f86322a7 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4149,21 +4149,35 @@ impl Project { .collect::>(); for server_id in servers { - let status = self.language_server_statuses.get(&server_id); - let server = self.language_servers.get(&server_id); - if let Some((server, status)) = server.zip(status) { - if let LanguageServerState::Running { server, .. } = server { - for (token, progress) in &status.pending_work { - if progress.is_cancellable { - server - .notify::( - WorkDoneProgressCancelParams { - token: lsp::NumberOrString::String(token.clone()), - }, - ) - .ok(); + self.cancel_language_server_work(server_id, None, cx); + } + } + + pub fn cancel_language_server_work( + &mut self, + server_id: LanguageServerId, + token_to_cancel: Option, + _cx: &mut ModelContext, + ) { + let status = self.language_server_statuses.get(&server_id); + let server = self.language_servers.get(&server_id); + if let Some((server, status)) = server.zip(status) { + if let LanguageServerState::Running { server, .. } = server { + for (token, progress) in &status.pending_work { + if let Some(token_to_cancel) = token_to_cancel.as_ref() { + if token != token_to_cancel { + continue; } } + if progress.is_cancellable { + server + .notify::( + WorkDoneProgressCancelParams { + token: lsp::NumberOrString::String(token.clone()), + }, + ) + .ok(); + } } } } @@ -4580,8 +4594,10 @@ impl Project { pub fn language_server_statuses( &self, - ) -> impl DoubleEndedIterator { - self.language_server_statuses.values() + ) -> impl DoubleEndedIterator { + self.language_server_statuses + .iter() + .map(|(key, value)| (*key, value)) } pub fn last_formatting_failure(&self) -> Option<&str> { diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 11f24c0377..379f5a4a58 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -14,6 +14,7 @@ use theme::ThemeSettings; enum ContextMenuItem { Separator, Header(SharedString), + Label(SharedString), Entry { toggled: Option, label: SharedString, @@ -147,6 +148,12 @@ impl ContextMenu { self } + pub fn label(mut self, label: impl Into) -> Self { + let label = label.into(); + self.items.push(ContextMenuItem::Label(label)); + self + } + pub fn action(mut self, label: impl Into, action: Box) -> Self { self.items.push(ContextMenuItem::Entry { toggled: None, @@ -284,6 +291,7 @@ impl ContextMenuItem { fn is_selectable(&self) -> bool { match self { ContextMenuItem::Separator => false, + ContextMenuItem::Label { .. } => false, ContextMenuItem::Header(_) => false, ContextMenuItem::Entry { .. } => true, ContextMenuItem::CustomEntry { selectable, .. } => *selectable, @@ -333,6 +341,11 @@ impl Render for ContextMenu { .inset(true) .into_any_element() } + ContextMenuItem::Label(label) => ListItem::new(ix) + .inset(true) + .disabled(true) + .child(Label::new(label.clone())) + .into_any_element(), ContextMenuItem::Entry { toggled, label,