Allow canceling in-progress language server work (e.g. `cargo check`) (#13173)

Max Brunsfeld and Richard created

Release Notes:

- Added a more detailed message in place of the generic `checking...`
messages when Rust-analyzer is running.
- Added a rate limit for language server status messages, to reduce
noisiness of those updates.
- Added a `cancel language server work` action which will cancel
long-running language server tasks.

---------

Co-authored-by: Richard <richard@zed.dev>

Change summary

crates/activity_indicator/src/activity_indicator.rs | 102 +++++--
crates/collab/src/tests/editor_tests.rs             |   7 
crates/diagnostics/src/items.rs                     |  43 ---
crates/editor/src/actions.rs                        |   1 
crates/editor/src/editor.rs                         |  14 +
crates/editor/src/element.rs                        |   1 
crates/gpui/src/executor.rs                         |  10 
crates/gpui/src/platform.rs                         |   5 
crates/gpui/src/platform/test/dispatcher.rs         |   9 
crates/lsp/src/lsp.rs                               |  10 
crates/project/src/project.rs                       | 200 ++++++++------
crates/project/src/project_tests.rs                 |  65 ++++
crates/proto/proto/zed.proto                        |   1 
crates/ui/src/components/icon.rs                    |   2 
script/zed-local                                    |   2 
15 files changed, 308 insertions(+), 164 deletions(-)

Detailed changes

crates/activity_indicator/src/activity_indicator.rs ๐Ÿ”—

@@ -3,22 +3,19 @@ use editor::Editor;
 use extension::ExtensionStore;
 use futures::StreamExt;
 use gpui::{
-    actions, svg, AppContext, CursorStyle, EventEmitter, InteractiveElement as _, Model,
-    ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, View,
-    ViewContext, VisualContext as _,
+    actions, percentage, Animation, AnimationExt as _, AppContext, CursorStyle, EventEmitter,
+    InteractiveElement as _, Model, ParentElement as _, Render, SharedString,
+    StatefulInteractiveElement, Styled, Transformation, View, ViewContext, VisualContext as _,
 };
 use language::{LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName};
 use project::{LanguageServerProgress, Project};
 use smallvec::SmallVec;
-use std::{cmp::Reverse, fmt::Write, sync::Arc};
+use std::{cmp::Reverse, fmt::Write, sync::Arc, time::Duration};
 use ui::prelude::*;
 use workspace::{item::ItemHandle, StatusItemView, Workspace};
 
 actions!(activity_indicator, [ShowErrorMessage]);
 
-const DOWNLOAD_ICON: &str = "icons/download.svg";
-const WARNING_ICON: &str = "icons/warning.svg";
-
 pub enum Event {
     ShowError { lsp_name: Arc<str>, error: String },
 }
@@ -35,14 +32,13 @@ struct LspStatus {
 }
 
 struct PendingWork<'a> {
-    language_server_name: &'a str,
     progress_token: &'a str,
     progress: &'a LanguageServerProgress,
 }
 
 #[derive(Default)]
 struct Content {
-    icon: Option<&'static str>,
+    icon: Option<gpui::AnyElement>,
     message: String,
     on_click: Option<Arc<dyn Fn(&mut ActivityIndicator, &mut ViewContext<ActivityIndicator>)>>,
 }
@@ -159,7 +155,6 @@ impl ActivityIndicator {
                         .pending_work
                         .iter()
                         .map(|(token, progress)| PendingWork {
-                            language_server_name: status.name.as_str(),
                             progress_token: token.as_str(),
                             progress,
                         })
@@ -175,31 +170,41 @@ impl ActivityIndicator {
         // Show any language server has pending activity.
         let mut pending_work = self.pending_language_server_work(cx);
         if let Some(PendingWork {
-            language_server_name,
             progress_token,
             progress,
         }) = pending_work.next()
         {
-            let mut message = language_server_name.to_string();
-
-            message.push_str(": ");
-            if let Some(progress_message) = progress.message.as_ref() {
-                message.push_str(progress_message);
-            } else {
-                message.push_str(progress_token);
-            }
+            let mut message = progress
+                .title
+                .as_deref()
+                .unwrap_or(progress_token)
+                .to_string();
 
             if let Some(percentage) = progress.percentage {
                 write!(&mut message, " ({}%)", percentage).unwrap();
             }
 
+            if let Some(progress_message) = progress.message.as_ref() {
+                message.push_str(": ");
+                message.push_str(progress_message);
+            }
+
             let additional_work_count = pending_work.count();
             if additional_work_count > 0 {
                 write!(&mut message, " + {} more", additional_work_count).unwrap();
             }
 
             return Content {
-                icon: None,
+                icon: Some(
+                    Icon::new(IconName::ArrowCircle)
+                        .size(IconSize::Small)
+                        .with_animation(
+                            "arrow-circle",
+                            Animation::new(Duration::from_secs(2)).repeat(),
+                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                        )
+                        .into_any_element(),
+                ),
                 message,
                 on_click: None,
             };
@@ -222,7 +227,11 @@ impl ActivityIndicator {
 
         if !downloading.is_empty() {
             return Content {
-                icon: Some(DOWNLOAD_ICON),
+                icon: Some(
+                    Icon::new(IconName::Download)
+                        .size(IconSize::Small)
+                        .into_any_element(),
+                ),
                 message: format!("Downloading {}...", downloading.join(", "),),
                 on_click: None,
             };
@@ -230,7 +239,11 @@ impl ActivityIndicator {
 
         if !checking_for_update.is_empty() {
             return Content {
-                icon: Some(DOWNLOAD_ICON),
+                icon: Some(
+                    Icon::new(IconName::Download)
+                        .size(IconSize::Small)
+                        .into_any_element(),
+                ),
                 message: format!(
                     "Checking for updates to {}...",
                     checking_for_update.join(", "),
@@ -241,7 +254,11 @@ impl ActivityIndicator {
 
         if !failed.is_empty() {
             return Content {
-                icon: Some(WARNING_ICON),
+                icon: Some(
+                    Icon::new(IconName::ExclamationTriangle)
+                        .size(IconSize::Small)
+                        .into_any_element(),
+                ),
                 message: format!(
                     "Failed to download {}. Click to show error.",
                     failed.join(", "),
@@ -255,7 +272,11 @@ impl ActivityIndicator {
         // Show any formatting failure
         if let Some(failure) = self.project.read(cx).last_formatting_failure() {
             return Content {
-                icon: Some(WARNING_ICON),
+                icon: Some(
+                    Icon::new(IconName::ExclamationTriangle)
+                        .size(IconSize::Small)
+                        .into_any_element(),
+                ),
                 message: format!("Formatting failed: {}. Click to see logs.", failure),
                 on_click: Some(Arc::new(|_, cx| {
                     cx.dispatch_action(Box::new(workspace::OpenLog));
@@ -267,17 +288,29 @@ impl ActivityIndicator {
         if let Some(updater) = &self.auto_updater {
             return match &updater.read(cx).status() {
                 AutoUpdateStatus::Checking => Content {
-                    icon: Some(DOWNLOAD_ICON),
+                    icon: Some(
+                        Icon::new(IconName::Download)
+                            .size(IconSize::Small)
+                            .into_any_element(),
+                    ),
                     message: "Checking for Zed updatesโ€ฆ".to_string(),
                     on_click: None,
                 },
                 AutoUpdateStatus::Downloading => Content {
-                    icon: Some(DOWNLOAD_ICON),
+                    icon: Some(
+                        Icon::new(IconName::Download)
+                            .size(IconSize::Small)
+                            .into_any_element(),
+                    ),
                     message: "Downloading Zed updateโ€ฆ".to_string(),
                     on_click: None,
                 },
                 AutoUpdateStatus::Installing => Content {
-                    icon: Some(DOWNLOAD_ICON),
+                    icon: Some(
+                        Icon::new(IconName::Download)
+                            .size(IconSize::Small)
+                            .into_any_element(),
+                    ),
                     message: "Installing Zed updateโ€ฆ".to_string(),
                     on_click: None,
                 },
@@ -292,7 +325,11 @@ impl ActivityIndicator {
                     })),
                 },
                 AutoUpdateStatus::Errored => Content {
-                    icon: Some(WARNING_ICON),
+                    icon: Some(
+                        Icon::new(IconName::ExclamationTriangle)
+                            .size(IconSize::Small)
+                            .into_any_element(),
+                    ),
                     message: "Auto update failed".to_string(),
                     on_click: Some(Arc::new(|this, cx| {
                         this.dismiss_error_message(&Default::default(), cx)
@@ -307,7 +344,11 @@ impl ActivityIndicator {
         {
             if let Some(extension_id) = extension_store.outstanding_operations().keys().next() {
                 return Content {
-                    icon: Some(DOWNLOAD_ICON),
+                    icon: Some(
+                        Icon::new(IconName::Download)
+                            .size(IconSize::Small)
+                            .into_any_element(),
+                    ),
                     message: format!("Updating {extension_id} extensionโ€ฆ"),
                     on_click: None,
                 };
@@ -338,7 +379,8 @@ impl Render for ActivityIndicator {
         }
 
         result
-            .children(content.icon.map(|icon| svg().path(icon)))
+            .gap_2()
+            .children(content.icon)
             .child(Label::new(SharedString::from(content.message)).size(LabelSize::Small))
     }
 }

crates/collab/src/tests/editor_tests.rs ๐Ÿ”—

@@ -28,7 +28,7 @@ use language::{
 use multi_buffer::MultiBufferRow;
 use project::{
     project_settings::{InlineBlameSettings, ProjectSettings},
-    SERVER_PROGRESS_DEBOUNCE_TIMEOUT,
+    SERVER_PROGRESS_THROTTLE_TIMEOUT,
 };
 use recent_projects::disconnected_overlay::DisconnectedOverlay;
 use rpc::RECEIVE_TIMEOUT;
@@ -1006,6 +1006,8 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
 
     let fake_language_server = fake_language_servers.next().await.unwrap();
     fake_language_server.start_progress("the-token").await;
+
+    executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT);
     fake_language_server.notify::<lsp::notification::Progress>(lsp::ProgressParams {
         token: lsp::NumberOrString::String("the-token".to_string()),
         value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report(
@@ -1015,7 +1017,6 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
             },
         )),
     });
-    executor.advance_clock(SERVER_PROGRESS_DEBOUNCE_TIMEOUT);
     executor.run_until_parked();
 
     project_a.read_with(cx_a, |project, _| {
@@ -1040,6 +1041,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
         assert_eq!(status.name, "the-language-server");
     });
 
+    executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT);
     fake_language_server.notify::<lsp::notification::Progress>(lsp::ProgressParams {
         token: lsp::NumberOrString::String("the-token".to_string()),
         value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report(
@@ -1049,7 +1051,6 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes
             },
         )),
     });
-    executor.advance_clock(SERVER_PROGRESS_DEBOUNCE_TIMEOUT);
     executor.run_until_parked();
 
     project_a.read_with(cx_a, |project, _| {

crates/diagnostics/src/items.rs ๐Ÿ”—

@@ -1,9 +1,7 @@
-use std::time::Duration;
-
 use editor::Editor;
 use gpui::{
-    percentage, rems, Animation, AnimationExt, EventEmitter, IntoElement, ParentElement, Render,
-    Styled, Subscription, Transformation, View, ViewContext, WeakView,
+    rems, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, View,
+    ViewContext, WeakView,
 };
 use language::Diagnostic;
 use ui::{h_flex, prelude::*, Button, ButtonLike, Color, Icon, IconName, Label, Tooltip};
@@ -61,42 +59,7 @@ impl Render for DiagnosticIndicator {
                 .child(Label::new(warning_count.to_string()).size(LabelSize::Small)),
         };
 
-        let has_in_progress_checks = self
-            .workspace
-            .upgrade()
-            .and_then(|workspace| {
-                workspace
-                    .read(cx)
-                    .project()
-                    .read(cx)
-                    .language_servers_running_disk_based_diagnostics()
-                    .next()
-            })
-            .is_some();
-
-        let status = if has_in_progress_checks {
-            Some(
-                h_flex()
-                    .gap_2()
-                    .child(
-                        Icon::new(IconName::ArrowCircle)
-                            .size(IconSize::Small)
-                            .with_animation(
-                                "arrow-circle",
-                                Animation::new(Duration::from_secs(2)).repeat(),
-                                |icon, delta| {
-                                    icon.transform(Transformation::rotate(percentage(delta)))
-                                },
-                            ),
-                    )
-                    .child(
-                        Label::new("Checkingโ€ฆ")
-                            .size(LabelSize::Small)
-                            .into_any_element(),
-                    )
-                    .into_any_element(),
-            )
-        } else if let Some(diagnostic) = &self.current_diagnostic {
+        let status = if let Some(diagnostic) = &self.current_diagnostic {
             let message = diagnostic.message.split('\n').next().unwrap().to_string();
             Some(
                 Button::new("diagnostic_message", message)

crates/editor/src/actions.rs ๐Ÿ”—

@@ -169,6 +169,7 @@ gpui::actions!(
         AddSelectionBelow,
         Backspace,
         Cancel,
+        CancelLanguageServerWork,
         ConfirmRename,
         ContextMenuFirst,
         ContextMenuLast,

crates/editor/src/editor.rs ๐Ÿ”—

@@ -9494,6 +9494,20 @@ impl Editor {
         }
     }
 
+    fn cancel_language_server_work(
+        &mut self,
+        _: &CancelLanguageServerWork,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let Some(project) = self.project.clone() {
+            self.buffer.update(cx, |multi_buffer, cx| {
+                project.update(cx, |project, cx| {
+                    project.cancel_language_server_work_for_buffers(multi_buffer.all_buffers(), cx);
+                });
+            })
+        }
+    }
+
     fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext<Self>) {
         cx.show_character_palette();
     }

crates/editor/src/element.rs ๐Ÿ”—

@@ -341,6 +341,7 @@ impl EditorElement {
             }
         });
         register_action(view, cx, Editor::restart_language_server);
+        register_action(view, cx, Editor::cancel_language_server_work);
         register_action(view, cx, Editor::show_character_palette);
         register_action(view, cx, |editor, action, cx| {
             if let Some(task) = editor.confirm_completion(action, cx) {

crates/gpui/src/executor.rs ๐Ÿ”—

@@ -13,7 +13,7 @@ use std::{
         Arc,
     },
     task::{Context, Poll},
-    time::Duration,
+    time::{Duration, Instant},
 };
 use util::TryFutureExt;
 use waker_fn::waker_fn;
@@ -316,6 +316,14 @@ impl BackgroundExecutor {
         }
     }
 
+    /// Get the current time.
+    ///
+    /// Calling this instead of `std::time::Instant::now` allows the use
+    /// of fake timers in tests.
+    pub fn now(&self) -> Instant {
+        self.dispatcher.now()
+    }
+
     /// Returns a task that will complete after the given duration.
     /// Depending on other concurrent tasks the elapsed duration may be longer
     /// than requested.

crates/gpui/src/platform.rs ๐Ÿ”—

@@ -38,7 +38,7 @@ use seahash::SeaHasher;
 use serde::{Deserialize, Serialize};
 use std::borrow::Cow;
 use std::hash::{Hash, Hasher};
-use std::time::Duration;
+use std::time::{Duration, Instant};
 use std::{
     fmt::{self, Debug},
     ops::Range,
@@ -275,6 +275,9 @@ pub trait PlatformDispatcher: Send + Sync {
     fn dispatch_after(&self, duration: Duration, runnable: Runnable);
     fn park(&self, timeout: Option<Duration>) -> bool;
     fn unparker(&self) -> Unparker;
+    fn now(&self) -> Instant {
+        Instant::now()
+    }
 
     #[cfg(any(test, feature = "test-support"))]
     fn as_test(&self) -> Option<&TestDispatcher> {

crates/gpui/src/platform/test/dispatcher.rs ๐Ÿ”—

@@ -11,7 +11,7 @@ use std::{
     pin::Pin,
     sync::Arc,
     task::{Context, Poll},
-    time::Duration,
+    time::{Duration, Instant},
 };
 use util::post_inc;
 
@@ -32,6 +32,7 @@ struct TestDispatcherState {
     background: Vec<Runnable>,
     deprioritized_background: Vec<Runnable>,
     delayed: Vec<(Duration, Runnable)>,
+    start_time: Instant,
     time: Duration,
     is_main_thread: bool,
     next_id: TestDispatcherId,
@@ -52,6 +53,7 @@ impl TestDispatcher {
             deprioritized_background: Vec::new(),
             delayed: Vec::new(),
             time: Duration::ZERO,
+            start_time: Instant::now(),
             is_main_thread: true,
             next_id: TestDispatcherId(1),
             allow_parking: false,
@@ -251,6 +253,11 @@ impl PlatformDispatcher for TestDispatcher {
         self.state.lock().is_main_thread
     }
 
+    fn now(&self) -> Instant {
+        let state = self.state.lock();
+        state.start_time + state.time
+    }
+
     fn dispatch(&self, runnable: Runnable, label: Option<TaskLabel>) {
         {
             let mut state = self.state.lock();

crates/lsp/src/lsp.rs ๐Ÿ”—

@@ -1351,6 +1351,14 @@ impl FakeLanguageServer {
 
     /// Simulate that the server has started work and notifies about its progress with the specified token.
     pub async fn start_progress(&self, token: impl Into<String>) {
+        self.start_progress_with(token, Default::default()).await
+    }
+
+    pub async fn start_progress_with(
+        &self,
+        token: impl Into<String>,
+        progress: WorkDoneProgressBegin,
+    ) {
         let token = token.into();
         self.request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams {
             token: NumberOrString::String(token.clone()),
@@ -1359,7 +1367,7 @@ impl FakeLanguageServer {
         .unwrap();
         self.notify::<notification::Progress>(ProgressParams {
             token: NumberOrString::String(token),
-            value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(Default::default())),
+            value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(progress)),
         });
     }
 

crates/project/src/project.rs ๐Ÿ”—

@@ -19,7 +19,7 @@ use client::{
     TypedEnvelope, UserStore,
 };
 use clock::ReplicaId;
-use collections::{hash_map, BTreeMap, HashMap, HashSet, VecDeque};
+use collections::{btree_map, hash_map, BTreeMap, HashMap, HashSet, VecDeque};
 use debounced_delay::DebouncedDelay;
 use futures::{
     channel::{
@@ -62,6 +62,7 @@ use lsp::{
     DocumentHighlightKind, Edit, FileSystemWatcher, InsertTextFormat, LanguageServer,
     LanguageServerBinary, LanguageServerId, LspRequestFuture, MessageActionItem, OneOf,
     ServerCapabilities, ServerHealthStatus, ServerStatus, TextEdit, Uri,
+    WorkDoneProgressCancelParams,
 };
 use lsp_command::*;
 use node_runtime::NodeRuntime;
@@ -131,7 +132,7 @@ pub use worktree::{
 const MAX_SERVER_REINSTALL_ATTEMPT_COUNT: u64 = 4;
 const SERVER_REINSTALL_DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
 const SERVER_LAUNCHING_BEFORE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
-pub const SERVER_PROGRESS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100);
+pub const SERVER_PROGRESS_THROTTLE_TIMEOUT: Duration = Duration::from_millis(100);
 
 const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500;
 
@@ -164,9 +165,6 @@ pub struct Project {
     worktrees_reordered: bool,
     active_entry: Option<ProjectEntryId>,
     buffer_ordered_messages_tx: mpsc::UnboundedSender<BufferOrderedMessage>,
-    pending_language_server_update: Option<BufferOrderedMessage>,
-    flush_language_server_update: Option<Task<()>>,
-
     languages: Arc<LanguageRegistry>,
     supplementary_language_servers:
         HashMap<LanguageServerId, (LanguageServerName, Arc<LanguageServer>)>,
@@ -381,6 +379,9 @@ pub struct LanguageServerStatus {
 
 #[derive(Clone, Debug, Serialize)]
 pub struct LanguageServerProgress {
+    pub is_disk_based_diagnostics_progress: bool,
+    pub is_cancellable: bool,
+    pub title: Option<String>,
     pub message: Option<String>,
     pub percentage: Option<usize>,
     #[serde(skip_serializing)]
@@ -723,8 +724,6 @@ impl Project {
                 worktrees: Vec::new(),
                 worktrees_reordered: false,
                 buffer_ordered_messages_tx: tx,
-                flush_language_server_update: None,
-                pending_language_server_update: None,
                 collaborators: Default::default(),
                 opened_buffers: Default::default(),
                 shared_buffers: Default::default(),
@@ -864,8 +863,6 @@ impl Project {
                 worktrees: Vec::new(),
                 worktrees_reordered: false,
                 buffer_ordered_messages_tx: tx,
-                pending_language_server_update: None,
-                flush_language_server_update: None,
                 loading_buffers_by_path: Default::default(),
                 loading_buffers: Default::default(),
                 shared_buffers: Default::default(),
@@ -4142,6 +4139,40 @@ impl Project {
         .detach();
     }
 
+    pub fn cancel_language_server_work_for_buffers(
+        &mut self,
+        buffers: impl IntoIterator<Item = Model<Buffer>>,
+        cx: &mut ModelContext<Self>,
+    ) {
+        let servers = buffers
+            .into_iter()
+            .flat_map(|buffer| {
+                self.language_server_ids_for_buffer(buffer.read(cx), cx)
+                    .into_iter()
+            })
+            .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();
+                        }
+                    }
+                }
+            }
+        }
+    }
+
     fn check_errored_server(
         language: Arc<Language>,
         adapter: Arc<CachedLspAdapter>,
@@ -4211,35 +4242,7 @@ impl Project {
         .detach();
     }
 
-    fn enqueue_language_server_progress(
-        &mut self,
-        message: BufferOrderedMessage,
-        cx: &mut ModelContext<Self>,
-    ) {
-        self.pending_language_server_update.replace(message);
-        self.flush_language_server_update.get_or_insert_with(|| {
-            cx.spawn(|this, mut cx| async move {
-                cx.background_executor()
-                    .timer(SERVER_PROGRESS_DEBOUNCE_TIMEOUT)
-                    .await;
-                this.update(&mut cx, |this, _| {
-                    this.flush_language_server_update.take();
-                    if let Some(update) = this.pending_language_server_update.take() {
-                        this.enqueue_buffer_ordered_message(update).ok();
-                    }
-                })
-                .ok();
-            })
-        });
-    }
-
     fn enqueue_buffer_ordered_message(&mut self, message: BufferOrderedMessage) -> Result<()> {
-        if let Some(pending_message) = self.pending_language_server_update.take() {
-            self.flush_language_server_update.take();
-            self.buffer_ordered_messages_tx
-                .unbounded_send(pending_message)
-                .map_err(|e| anyhow!(e))?;
-        }
         self.buffer_ordered_messages_tx
             .unbounded_send(message)
             .map_err(|e| anyhow!(e))
@@ -4259,6 +4262,7 @@ impl Project {
                 return;
             }
         };
+
         let lsp::ProgressParamsValue::WorkDone(progress) = progress.value;
         let language_server_status =
             if let Some(status) = self.language_server_statuses.get_mut(&language_server_id) {
@@ -4281,32 +4285,36 @@ impl Project {
             lsp::WorkDoneProgress::Begin(report) => {
                 if is_disk_based_diagnostics_progress {
                     self.disk_based_diagnostics_started(language_server_id, cx);
-                } else {
-                    self.on_lsp_work_start(
-                        language_server_id,
-                        token.clone(),
-                        LanguageServerProgress {
-                            message: report.message.clone(),
-                            percentage: report.percentage.map(|p| p as usize),
-                            last_update_at: Instant::now(),
-                        },
-                        cx,
-                    );
                 }
+                self.on_lsp_work_start(
+                    language_server_id,
+                    token.clone(),
+                    LanguageServerProgress {
+                        title: Some(report.title),
+                        is_disk_based_diagnostics_progress,
+                        is_cancellable: report.cancellable.unwrap_or(false),
+                        message: report.message.clone(),
+                        percentage: report.percentage.map(|p| p as usize),
+                        last_update_at: cx.background_executor().now(),
+                    },
+                    cx,
+                );
             }
             lsp::WorkDoneProgress::Report(report) => {
-                if !is_disk_based_diagnostics_progress {
-                    self.on_lsp_work_progress(
-                        language_server_id,
-                        token.clone(),
-                        LanguageServerProgress {
-                            message: report.message.clone(),
-                            percentage: report.percentage.map(|p| p as usize),
-                            last_update_at: Instant::now(),
-                        },
-                        cx,
-                    );
-                    self.enqueue_language_server_progress(
+                if self.on_lsp_work_progress(
+                    language_server_id,
+                    token.clone(),
+                    LanguageServerProgress {
+                        title: None,
+                        is_disk_based_diagnostics_progress,
+                        is_cancellable: report.cancellable.unwrap_or(false),
+                        message: report.message.clone(),
+                        percentage: report.percentage.map(|p| p as usize),
+                        last_update_at: cx.background_executor().now(),
+                    },
+                    cx,
+                ) {
+                    self.enqueue_buffer_ordered_message(
                         BufferOrderedMessage::LanguageServerUpdate {
                             language_server_id,
                             message: proto::update_language_server::Variant::WorkProgress(
@@ -4317,17 +4325,15 @@ impl Project {
                                 },
                             ),
                         },
-                        cx,
-                    );
+                    )
+                    .ok();
                 }
             }
             lsp::WorkDoneProgress::End(_) => {
                 language_server_status.progress_tokens.remove(&token);
-
+                self.on_lsp_work_end(language_server_id, token.clone(), cx);
                 if is_disk_based_diagnostics_progress {
                     self.disk_based_diagnostics_finished(language_server_id, cx);
-                } else {
-                    self.on_lsp_work_end(language_server_id, token.clone(), cx);
                 }
             }
         }
@@ -4350,6 +4356,7 @@ impl Project {
                 language_server_id,
                 message: proto::update_language_server::Variant::WorkStart(proto::LspWorkStart {
                     token,
+                    title: progress.title,
                     message: progress.message,
                     percentage: progress.percentage.map(|p| p as u32),
                 }),
@@ -4364,25 +4371,34 @@ impl Project {
         token: String,
         progress: LanguageServerProgress,
         cx: &mut ModelContext<Self>,
-    ) {
+    ) -> bool {
         if let Some(status) = self.language_server_statuses.get_mut(&language_server_id) {
-            let entry = status
-                .pending_work
-                .entry(token)
-                .or_insert(LanguageServerProgress {
-                    message: Default::default(),
-                    percentage: Default::default(),
-                    last_update_at: progress.last_update_at,
-                });
-            if progress.message.is_some() {
-                entry.message = progress.message;
-            }
-            if progress.percentage.is_some() {
-                entry.percentage = progress.percentage;
+            match status.pending_work.entry(token) {
+                btree_map::Entry::Vacant(entry) => {
+                    entry.insert(progress);
+                    cx.notify();
+                    return true;
+                }
+                btree_map::Entry::Occupied(mut entry) => {
+                    let entry = entry.get_mut();
+                    if (progress.last_update_at - entry.last_update_at)
+                        >= SERVER_PROGRESS_THROTTLE_TIMEOUT
+                    {
+                        entry.last_update_at = progress.last_update_at;
+                        if progress.message.is_some() {
+                            entry.message = progress.message;
+                        }
+                        if progress.percentage.is_some() {
+                            entry.percentage = progress.percentage;
+                        }
+                        cx.notify();
+                        return true;
+                    }
+                }
             }
-            entry.last_update_at = progress.last_update_at;
-            cx.notify();
         }
+
+        false
     }
 
     fn on_lsp_work_end(
@@ -4392,8 +4408,11 @@ impl Project {
         cx: &mut ModelContext<Self>,
     ) {
         if let Some(status) = self.language_server_statuses.get_mut(&language_server_id) {
-            cx.emit(Event::RefreshInlayHints);
-            status.pending_work.remove(&token);
+            if let Some(work) = status.pending_work.remove(&token) {
+                if !work.is_disk_based_diagnostics_progress {
+                    cx.emit(Event::RefreshInlayHints);
+                }
+            }
             cx.notify();
         }
 
@@ -7384,9 +7403,12 @@ impl Project {
                                     language_server.server_id(),
                                     id.to_string(),
                                     LanguageServerProgress {
+                                        is_disk_based_diagnostics_progress: false,
+                                        is_cancellable: false,
+                                        title: None,
                                         message: status.clone(),
                                         percentage: None,
-                                        last_update_at: Instant::now(),
+                                        last_update_at: cx.background_executor().now(),
                                     },
                                     cx,
                                 );
@@ -9005,9 +9027,12 @@ impl Project {
                         language_server_id,
                         payload.token,
                         LanguageServerProgress {
+                            title: payload.title,
+                            is_disk_based_diagnostics_progress: false,
+                            is_cancellable: false,
                             message: payload.message,
                             percentage: payload.percentage.map(|p| p as usize),
-                            last_update_at: Instant::now(),
+                            last_update_at: cx.background_executor().now(),
                         },
                         cx,
                     );
@@ -9018,9 +9043,12 @@ impl Project {
                         language_server_id,
                         payload.token,
                         LanguageServerProgress {
+                            title: None,
+                            is_disk_based_diagnostics_progress: false,
+                            is_cancellable: false,
                             message: payload.message,
                             percentage: payload.percentage.map(|p| p as usize),
-                            last_update_at: Instant::now(),
+                            last_update_at: cx.background_executor().now(),
                         },
                         cx,
                     );

crates/project/src/project_tests.rs ๐Ÿ”—

@@ -7,6 +7,7 @@ use language::{
     tree_sitter_rust, tree_sitter_typescript, Diagnostic, FakeLspAdapter, LanguageConfig,
     LanguageMatcher, LineEnding, OffsetRangeExt, Point, ToPoint,
 };
+use lsp::NumberOrString;
 use parking_lot::Mutex;
 use pretty_assertions::assert_eq;
 use serde_json::json;
@@ -1461,6 +1462,69 @@ async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::T
     assert_eq!(notification.version, 0);
 }
 
+#[gpui::test]
+async fn test_cancel_language_server_work(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let progress_token = "the-progress-token";
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree("/dir", json!({ "a.rs": "" })).await;
+
+    let project = Project::test(fs, ["/dir".as_ref()], cx).await;
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+    let mut fake_servers = language_registry.register_fake_lsp_adapter(
+        "Rust",
+        FakeLspAdapter {
+            name: "the-language-server",
+            disk_based_diagnostics_sources: vec!["disk".into()],
+            disk_based_diagnostics_progress_token: Some(progress_token.into()),
+            ..Default::default()
+        },
+    );
+
+    let buffer = project
+        .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
+        .await
+        .unwrap();
+
+    // Simulate diagnostics starting to update.
+    let mut fake_server = fake_servers.next().await.unwrap();
+    fake_server
+        .start_progress_with(
+            "another-token",
+            lsp::WorkDoneProgressBegin {
+                cancellable: Some(false),
+                ..Default::default()
+            },
+        )
+        .await;
+    fake_server
+        .start_progress_with(
+            progress_token,
+            lsp::WorkDoneProgressBegin {
+                cancellable: Some(true),
+                ..Default::default()
+            },
+        )
+        .await;
+    cx.executor().run_until_parked();
+
+    project.update(cx, |project, cx| {
+        project.cancel_language_server_work_for_buffers([buffer.clone()], cx)
+    });
+
+    let cancel_notification = fake_server
+        .receive_notification::<lsp::notification::WorkDoneProgressCancel>()
+        .await;
+    assert_eq!(
+        cancel_notification.token,
+        NumberOrString::String(progress_token.into())
+    );
+}
+
 #[gpui::test]
 async fn test_toggling_enable_language_server(cx: &mut gpui::TestAppContext) {
     init_test(cx);
@@ -3758,6 +3822,7 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_rename(cx: &mut gpui::TestAppContext) {
+    // hi
     init_test(cx);
 
     let fs = FakeFs::new(cx.executor());

crates/proto/proto/zed.proto ๐Ÿ”—

@@ -1199,6 +1199,7 @@ message UpdateLanguageServer {
 
 message LspWorkStart {
     string token = 1;
+    optional string title = 4;
     optional string message = 2;
     optional uint32 percentage = 3;
 }

crates/ui/src/components/icon.rs ๐Ÿ”—

@@ -117,6 +117,7 @@ pub enum IconName {
     Dash,
     Delete,
     Disconnected,
+    Download,
     Ellipsis,
     Envelope,
     Escape,
@@ -248,6 +249,7 @@ impl IconName {
             IconName::Dash => "icons/dash.svg",
             IconName::Delete => "icons/delete.svg",
             IconName::Disconnected => "icons/disconnected.svg",
+            IconName::Download => "icons/download.svg",
             IconName::Ellipsis => "icons/ellipsis.svg",
             IconName::Envelope => "icons/feedback.svg",
             IconName::Escape => "icons/escape.svg",

script/zed-local ๐Ÿ”—

@@ -172,7 +172,7 @@ setTimeout(() => {
       env: Object.assign({}, process.env, {
         ZED_IMPERSONATE: users[i],
         ZED_WINDOW_POSITION: position,
-        ZED_STATELESS: isStateful && i == 0 ? "1" : "",
+        ZED_STATELESS: isStateful && i == 0 ? "" : "1",
         ZED_ALWAYS_ACTIVE: "1",
         ZED_SERVER_URL: "http://localhost:3000",
         ZED_RPC_URL: "http://localhost:8080/rpc",