Improve LSP tasks ergonomics (#31551)

* stopped fetching LSP tasks for too long (but still use the hardcoded
value for the time being — the LSP tasks settings part is a simple bool
key and it's not very simple to fit in another value there)

* introduced `prefer_lsp` language task settings value, to control
whether in the gutter/modal/both/none LSP tasks are shown exclusively,
if possible

Release Notes:

- Added a way to prefer LSP tasks over Zed tasks
This commit is contained in:
Kirill Bulatov 2025-05-28 18:36:25 +03:00 committed by GitHub
parent 00bc154c46
commit 07403f0b08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 128 additions and 44 deletions

View file

@ -1314,7 +1314,17 @@
// Settings related to running tasks. // Settings related to running tasks.
"tasks": { "tasks": {
"variables": {}, "variables": {},
"enabled": true "enabled": true,
// Use LSP tasks over Zed language extension ones.
// If no LSP tasks are returned due to error/timeout or regular execution,
// Zed language extension tasks will be used instead.
//
// Other Zed tasks will still be shown:
// * Zed task from either of the task config file
// * Zed task from history (e.g. one-off task was spawned before)
//
// Default: true
"prefer_lsp": true
}, },
// An object whose keys are language names, and whose values // An object whose keys are language names, and whose values
// are arrays of filenames or extensions of files that should // are arrays of filenames or extensions of files that should

View file

@ -98,6 +98,7 @@ gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] }
languages = {workspace = true, features = ["test-support"] } languages = {workspace = true, features = ["test-support"] }
lsp = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] }
markdown = { workspace = true, features = ["test-support"] }
multi_buffer = { workspace = true, features = ["test-support"] } multi_buffer = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] }
release_channel.workspace = true release_channel.workspace = true

View file

@ -1670,6 +1670,13 @@ impl Editor {
editor editor
.refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx); .refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx);
} }
project::Event::LanguageServerAdded(..)
| project::Event::LanguageServerRemoved(..) => {
if editor.tasks_update_task.is_none() {
editor.tasks_update_task =
Some(editor.refresh_runnables(window, cx));
}
}
project::Event::SnippetEdit(id, snippet_edits) => { project::Event::SnippetEdit(id, snippet_edits) => {
if let Some(buffer) = editor.buffer.read(cx).buffer(*id) { if let Some(buffer) = editor.buffer.read(cx).buffer(*id) {
let focus_handle = editor.focus_handle(cx); let focus_handle = editor.focus_handle(cx);
@ -13543,6 +13550,7 @@ impl Editor {
} }
let project = self.project.as_ref().map(Entity::downgrade); let project = self.project.as_ref().map(Entity::downgrade);
let task_sources = self.lsp_task_sources(cx); let task_sources = self.lsp_task_sources(cx);
let multi_buffer = self.buffer.downgrade();
cx.spawn_in(window, async move |editor, cx| { cx.spawn_in(window, async move |editor, cx| {
cx.background_executor().timer(UPDATE_DEBOUNCE).await; cx.background_executor().timer(UPDATE_DEBOUNCE).await;
let Some(project) = project.and_then(|p| p.upgrade()) else { let Some(project) = project.and_then(|p| p.upgrade()) else {
@ -13626,7 +13634,19 @@ impl Editor {
return; return;
}; };
let rows = Self::runnable_rows(project, display_snapshot, new_rows, cx.clone()); let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| {
buffer.language_settings(cx).tasks.prefer_lsp
}) else {
return;
};
let rows = Self::runnable_rows(
project,
display_snapshot,
prefer_lsp && !lsp_tasks_by_rows.is_empty(),
new_rows,
cx.clone(),
);
editor editor
.update(cx, |editor, _| { .update(cx, |editor, _| {
editor.clear_tasks(); editor.clear_tasks();
@ -13654,15 +13674,21 @@ impl Editor {
fn runnable_rows( fn runnable_rows(
project: Entity<Project>, project: Entity<Project>,
snapshot: DisplaySnapshot, snapshot: DisplaySnapshot,
prefer_lsp: bool,
runnable_ranges: Vec<RunnableRange>, runnable_ranges: Vec<RunnableRange>,
mut cx: AsyncWindowContext, mut cx: AsyncWindowContext,
) -> Vec<((BufferId, BufferRow), RunnableTasks)> { ) -> Vec<((BufferId, BufferRow), RunnableTasks)> {
runnable_ranges runnable_ranges
.into_iter() .into_iter()
.filter_map(|mut runnable| { .filter_map(|mut runnable| {
let tasks = cx let mut tasks = cx
.update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx))
.ok()?; .ok()?;
if prefer_lsp {
tasks.retain(|(task_kind, _)| {
!matches!(task_kind, TaskSourceKind::Language { .. })
});
}
if tasks.is_empty() { if tasks.is_empty() {
return None; return None;
} }

View file

@ -9111,11 +9111,10 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
lsp::Url::from_file_path(path!("/file.rs")).unwrap() lsp::Url::from_file_path(path!("/file.rs")).unwrap()
); );
assert_eq!(params.options.tab_size, 8); assert_eq!(params.options.tab_size, 8);
Ok(Some(vec![])) Ok(Some(Vec::new()))
}) })
.next() .next()
.await; .await;
cx.executor().start_waiting();
save.await; save.await;
} }

View file

@ -1050,7 +1050,9 @@ mod tests {
for (range, event) in slice.iter() { for (range, event) in slice.iter() {
match event { match event {
MarkdownEvent::SubstitutedText(parsed) => rendered_text.push_str(parsed), MarkdownEvent::SubstitutedText(parsed) => {
rendered_text.push_str(parsed.as_str())
}
MarkdownEvent::Text | MarkdownEvent::Code => { MarkdownEvent::Text | MarkdownEvent::Code => {
rendered_text.push_str(&text[range.clone()]) rendered_text.push_str(&text[range.clone()])
} }

View file

@ -1,4 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use crate::Editor; use crate::Editor;
use collections::HashMap; use collections::HashMap;
@ -16,6 +17,7 @@ use project::LocationLink;
use project::Project; use project::Project;
use project::TaskSourceKind; use project::TaskSourceKind;
use project::lsp_store::lsp_ext_command::GetLspRunnables; use project::lsp_store::lsp_ext_command::GetLspRunnables;
use smol::future::FutureExt as _;
use smol::stream::StreamExt; use smol::stream::StreamExt;
use task::ResolvedTask; use task::ResolvedTask;
use task::TaskContext; use task::TaskContext;
@ -130,44 +132,58 @@ pub fn lsp_tasks(
.collect::<FuturesUnordered<_>>(); .collect::<FuturesUnordered<_>>();
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
let mut lsp_tasks = Vec::new(); cx.spawn(async move |cx| {
while let Some(server_to_query) = lsp_task_sources.next().await { let mut lsp_tasks = Vec::new();
if let Some((server_id, buffers)) = server_to_query { while let Some(server_to_query) = lsp_task_sources.next().await {
let source_kind = TaskSourceKind::Lsp(server_id); if let Some((server_id, buffers)) = server_to_query {
let id_base = source_kind.to_id_base(); let source_kind = TaskSourceKind::Lsp(server_id);
let mut new_lsp_tasks = Vec::new(); let id_base = source_kind.to_id_base();
for buffer in buffers { let mut new_lsp_tasks = Vec::new();
let lsp_buffer_context = lsp_task_context(&project, &buffer, cx) for buffer in buffers {
.await let lsp_buffer_context = lsp_task_context(&project, &buffer, cx)
.unwrap_or_default(); .await
.unwrap_or_default();
if let Ok(runnables_task) = project.update(cx, |project, cx| { if let Ok(runnables_task) = project.update(cx, |project, cx| {
let buffer_id = buffer.read(cx).remote_id(); let buffer_id = buffer.read(cx).remote_id();
project.request_lsp( project.request_lsp(
buffer, buffer,
LanguageServerToQuery::Other(server_id), LanguageServerToQuery::Other(server_id),
GetLspRunnables { GetLspRunnables {
buffer_id, buffer_id,
position: for_position, position: for_position,
},
cx,
)
}) {
if let Some(new_runnables) = runnables_task.await.log_err() {
new_lsp_tasks.extend(new_runnables.runnables.into_iter().filter_map(
|(location, runnable)| {
let resolved_task =
runnable.resolve_task(&id_base, &lsp_buffer_context)?;
Some((location, resolved_task))
}, },
)); cx,
)
}) {
if let Some(new_runnables) = runnables_task.await.log_err() {
new_lsp_tasks.extend(
new_runnables.runnables.into_iter().filter_map(
|(location, runnable)| {
let resolved_task = runnable
.resolve_task(&id_base, &lsp_buffer_context)?;
Some((location, resolved_task))
},
),
);
}
} }
} }
lsp_tasks.push((source_kind, new_lsp_tasks));
} }
lsp_tasks.push((source_kind, new_lsp_tasks));
} }
} lsp_tasks
lsp_tasks })
.race({
// `lsp::LSP_REQUEST_TIMEOUT` is larger than we want for the modal to open fast
let timer = cx.background_executor().timer(Duration::from_millis(200));
async move {
timer.await;
log::info!("Timed out waiting for LSP tasks");
Vec::new()
}
})
.await
}) })
} }

View file

@ -1045,6 +1045,15 @@ pub struct LanguageTaskConfig {
pub variables: HashMap<String, String>, pub variables: HashMap<String, String>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enabled: bool, pub enabled: bool,
/// Use LSP tasks over Zed language extension ones.
/// If no LSP tasks are returned due to error/timeout or regular execution,
/// Zed language extension tasks will be used instead.
///
/// Other Zed tasks will still be shown:
/// * Zed task from either of the task config file
/// * Zed task from history (e.g. one-off task was spawned before)
#[serde(default = "default_true")]
pub prefer_lsp: bool,
} }
impl InlayHintSettings { impl InlayHintSettings {

View file

@ -225,7 +225,7 @@ impl Markdown {
self.parse(cx); self.parse(cx);
} }
#[cfg(feature = "test-support")] #[cfg(any(test, feature = "test-support"))]
pub fn parsed_markdown(&self) -> &ParsedMarkdown { pub fn parsed_markdown(&self) -> &ParsedMarkdown {
&self.parsed_markdown &self.parsed_markdown
} }

View file

@ -1,6 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use crate::TaskContexts; use crate::TaskContexts;
use editor::Editor;
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
Action, AnyElement, App, AppContext as _, Context, DismissEvent, Entity, EventEmitter, Action, AnyElement, App, AppContext as _, Context, DismissEvent, Entity, EventEmitter,
@ -230,15 +231,28 @@ impl PickerDelegate for TasksModalDelegate {
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
let lsp_task_sources = self.task_contexts.lsp_task_sources.clone(); let lsp_task_sources = self.task_contexts.lsp_task_sources.clone();
let task_position = self.task_contexts.latest_selection; let task_position = self.task_contexts.latest_selection;
cx.spawn(async move |picker, cx| { cx.spawn(async move |picker, cx| {
let Ok(lsp_tasks) = workspace.update(cx, |workspace, cx| { let Ok((lsp_tasks, prefer_lsp)) = workspace.update(cx, |workspace, cx| {
editor::lsp_tasks( let lsp_tasks = editor::lsp_tasks(
workspace.project().clone(), workspace.project().clone(),
&lsp_task_sources, &lsp_task_sources,
task_position, task_position,
cx, cx,
) );
let prefer_lsp = workspace
.active_item(cx)
.and_then(|item| item.downcast::<Editor>())
.map(|editor| {
editor
.read(cx)
.buffer()
.read(cx)
.language_settings(cx)
.tasks
.prefer_lsp
})
.unwrap_or(false);
(lsp_tasks, prefer_lsp)
}) else { }) else {
return Vec::new(); return Vec::new();
}; };
@ -253,6 +267,8 @@ impl PickerDelegate for TasksModalDelegate {
}; };
let mut new_candidates = used; let mut new_candidates = used;
let add_current_language_tasks =
!prefer_lsp || lsp_tasks.is_empty();
new_candidates.extend(lsp_tasks.into_iter().flat_map( new_candidates.extend(lsp_tasks.into_iter().flat_map(
|(kind, tasks_with_locations)| { |(kind, tasks_with_locations)| {
tasks_with_locations tasks_with_locations
@ -263,7 +279,12 @@ impl PickerDelegate for TasksModalDelegate {
.map(move |(_, task)| (kind.clone(), task)) .map(move |(_, task)| (kind.clone(), task))
}, },
)); ));
new_candidates.extend(current); new_candidates.extend(current.into_iter().filter(
|(task_kind, _)| {
add_current_language_tasks
|| !matches!(task_kind, TaskSourceKind::Language { .. })
},
));
let match_candidates = string_match_candidates(&new_candidates); let match_candidates = string_match_candidates(&new_candidates);
let _ = picker.delegate.candidates.insert(new_candidates); let _ = picker.delegate.candidates.insert(new_candidates);
match_candidates match_candidates