Make LSP task cancellation discoverable (#13226)

Max Brunsfeld created

Release Notes:

- Added the ability to cancel a cargo check by clicking on the status
bar item.

Change summary

crates/activity_indicator/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(-)

Detailed changes

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<LspStatus>,
     project: Model<Project>,
     auto_updater: Option<Model<AutoUpdater>>,
+    context_menu: Option<View<ContextMenu>>,
 }
 
 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<Self>) {
+        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<Self>) {
+        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<Event> 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)
+            }))
     }
 }
 

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!(

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());
     });
 }

crates/project/src/project.rs 🔗

@@ -4149,21 +4149,35 @@ impl Project {
             .collect::<HashSet<_>>();
 
         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::<lsp::notification::WorkDoneProgressCancel>(
-                                    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<String>,
+        _cx: &mut ModelContext<Self>,
+    ) {
+        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::<lsp::notification::WorkDoneProgressCancel>(
+                                WorkDoneProgressCancelParams {
+                                    token: lsp::NumberOrString::String(token.clone()),
+                                },
+                            )
+                            .ok();
+                    }
                 }
             }
         }
@@ -4580,8 +4594,10 @@ impl Project {
 
     pub fn language_server_statuses(
         &self,
-    ) -> impl DoubleEndedIterator<Item = &LanguageServerStatus> {
-        self.language_server_statuses.values()
+    ) -> impl DoubleEndedIterator<Item = (LanguageServerId, &LanguageServerStatus)> {
+        self.language_server_statuses
+            .iter()
+            .map(|(key, value)| (*key, value))
     }
 
     pub fn last_formatting_failure(&self) -> Option<&str> {

crates/ui/src/components/context_menu.rs 🔗

@@ -14,6 +14,7 @@ use theme::ThemeSettings;
 enum ContextMenuItem {
     Separator,
     Header(SharedString),
+    Label(SharedString),
     Entry {
         toggled: Option<bool>,
         label: SharedString,
@@ -147,6 +148,12 @@ impl ContextMenu {
         self
     }
 
+    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
+        let label = label.into();
+        self.items.push(ContextMenuItem::Label(label));
+        self
+    }
+
     pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> 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,