Do not overly eagerly invalidate the runnables (#51500)

Follow-up of https://github.com/zed-industries/zed/pull/51299

Release Notes:

- N/A
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-03-13 16:27:01 +00:00
1 parent 3c36a8d80e
commit 093d062d05
2 files changed
+195 -18

No files matched your search

+9 -10
View File
@@ -2142,7 +2142,7 @@ impl Editor {
editor.registered_buffers.clear();
editor.register_visible_buffers(cx);
editor.invalidate_semantic_tokens(None);
editor.refresh_runnables(window, cx);
editor.refresh_runnables(None, window, cx);
editor.update_lsp_data(None, window, cx);
editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx);
}
@@ -2172,7 +2172,7 @@ impl Editor {
let buffer_id = *buffer_id;
if editor.buffer().read(cx).buffer(buffer_id).is_some() {
editor.register_buffer(buffer_id, cx);
editor.refresh_runnables(window, cx);
editor.refresh_runnables(Some(buffer_id), window, cx);
editor.update_lsp_data(Some(buffer_id), window, cx);
editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
refresh_linked_ranges(editor, window, cx);
@@ -2251,7 +2251,7 @@ impl Editor {
&task_inventory,
window,
|editor, _, window, cx| {
editor.refresh_runnables(window, cx);
editor.refresh_runnables(None, window, cx);
},
));
};
@@ -23789,7 +23789,7 @@ impl Editor {
.invalidate_buffer(&buffer.read(cx).remote_id());
self.update_lsp_data(Some(buffer_id), window, cx);
self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
self.refresh_runnables(window, cx);
self.refresh_runnables(None, window, cx);
self.colorize_brackets(false, cx);
self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
cx.emit(EditorEvent::ExcerptsAdded {
@@ -23850,12 +23850,11 @@ impl Editor {
}
self.colorize_brackets(false, cx);
self.update_lsp_data(None, window, cx);
self.refresh_runnables(window, cx);
self.refresh_runnables(None, window, cx);
cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() })
}
multi_buffer::Event::Reparsed(buffer_id) => {
self.clear_runnables(Some(*buffer_id));
self.refresh_runnables(window, cx);
self.refresh_runnables(Some(*buffer_id), window, cx);
self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
self.colorize_brackets(true, cx);
jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
@@ -23863,7 +23862,7 @@ impl Editor {
cx.emit(EditorEvent::Reparsed(*buffer_id));
}
multi_buffer::Event::DiffHunksToggled => {
self.refresh_runnables(window, cx);
self.refresh_runnables(None, window, cx);
}
multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => {
if !is_fresh_language {
@@ -23999,7 +23998,7 @@ impl Editor {
.unwrap_or(DiagnosticSeverity::Hint);
self.set_max_diagnostics_severity(new_severity, cx);
}
self.refresh_runnables(window, cx);
self.refresh_runnables(None, window, cx);
self.update_edit_prediction_settings(cx);
self.refresh_edit_prediction(true, false, window, cx);
self.refresh_inline_values(cx);
@@ -25379,7 +25378,7 @@ impl Editor {
self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
if !self.buffer().read(cx).is_singleton() {
self.update_lsp_data(None, window, cx);
self.refresh_runnables(window, cx);
self.refresh_runnables(None, window, cx);
}
}
}
+186 -8
View File
@@ -1,7 +1,7 @@
use std::{collections::BTreeMap, mem, ops::Range, sync::Arc};
use clock::Global;
use collections::HashMap;
use collections::{HashMap, HashSet};
use gpui::{
App, AppContext as _, AsyncWindowContext, ClickEvent, Context, Entity, Focusable as _,
MouseButton, Task, Window,
@@ -30,6 +30,7 @@ use crate::{
#[derive(Debug)]
pub(super) struct RunnableData {
runnables: HashMap<BufferId, (Global, BTreeMap<BufferRow, RunnableTasks>)>,
invalidate_buffer_data: HashSet<BufferId>,
runnables_update_task: Task<()>,
}
@@ -37,6 +38,7 @@ impl RunnableData {
pub fn new() -> Self {
Self {
runnables: HashMap::default(),
invalidate_buffer_data: HashSet::default(),
runnables_update_task: Task::ready(()),
}
}
@@ -108,7 +110,12 @@ pub struct ResolvedTasks {
}
impl Editor {
pub fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub fn refresh_runnables(
&mut self,
invalidate_buffer_data: Option<BufferId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.mode().is_full()
|| !EditorSettings::get_global(cx).gutter.runnables
|| !self.enable_runnables
@@ -117,13 +124,18 @@ impl Editor {
return;
}
if let Some(buffer) = self.buffer().read(cx).as_singleton() {
if self
.runnables
.has_cached(buffer.read(cx).remote_id(), &buffer.read(cx).version())
let buffer_id = buffer.read(cx).remote_id();
if invalidate_buffer_data != Some(buffer_id)
&& self
.runnables
.has_cached(buffer_id, &buffer.read(cx).version())
{
return;
}
}
if let Some(buffer_id) = invalidate_buffer_data {
self.runnables.invalidate_buffer_data.insert(buffer_id);
}
let project = self.project().map(Entity::downgrade);
let lsp_task_sources = self.lsp_task_sources(true, true, cx);
@@ -249,6 +261,10 @@ impl Editor {
.await;
editor
.update(cx, |editor, cx| {
for buffer_id in std::mem::take(&mut editor.runnables.invalidate_buffer_data) {
editor.clear_runnables(Some(buffer_id));
}
for ((buffer_id, row), mut new_tasks) in rows {
let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else {
continue;
@@ -332,6 +348,7 @@ impl Editor {
} else {
self.runnables.runnables.clear();
}
self.runnables.invalidate_buffer_data.clear();
self.runnables.runnables_update_task = Task::ready(());
}
@@ -697,12 +714,17 @@ impl Editor {
mod tests {
use std::{sync::Arc, time::Duration};
use futures::StreamExt as _;
use gpui::{AppContext as _, Task, TestAppContext};
use indoc::indoc;
use language::ContextProvider;
use language::{ContextProvider, FakeLspAdapter};
use languages::rust_lang;
use lsp::LanguageServerName;
use multi_buffer::{MultiBuffer, PathKey};
use project::{FakeFs, Project};
use project::{
FakeFs, Project,
lsp_store::lsp_ext_command::{CargoRunnableArgs, Runnable, RunnableArgs, RunnableKind},
};
use serde_json::json;
use task::{TaskTemplate, TaskTemplates};
use text::Point;
@@ -710,8 +732,11 @@ mod tests {
use crate::{
Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount,
test::build_editor_with_project,
};
const FAKE_LSP_NAME: &str = "the-fake-language-server";
struct TestRustContextProvider;
impl ContextProvider for TestRustContextProvider {
@@ -739,6 +764,28 @@ mod tests {
}
}
struct TestRustContextProviderWithLsp;
impl ContextProvider for TestRustContextProviderWithLsp {
fn associated_tasks(
&self,
_: Option<Arc<dyn language::File>>,
_: &gpui::App,
) -> Task<Option<TaskTemplates>> {
Task::ready(Some(TaskTemplates(vec![TaskTemplate {
label: "Run test".into(),
command: "cargo".into(),
args: vec!["test".into()],
tags: vec!["rust-test".into()],
..TaskTemplate::default()
}])))
}
fn lsp_task_source(&self) -> Option<LanguageServerName> {
Some(LanguageServerName::new_static(FAKE_LSP_NAME))
}
}
fn rust_lang_with_task_context() -> Arc<language::Language> {
Arc::new(
Arc::try_unwrap(rust_lang())
@@ -747,6 +794,14 @@ mod tests {
)
}
fn rust_lang_with_lsp_task_context() -> Arc<language::Language> {
Arc::new(
Arc::try_unwrap(rust_lang())
.unwrap()
.with_context_provider(Some(Arc::new(TestRustContextProviderWithLsp))),
)
}
fn collect_runnable_labels(
editor: &Editor,
) -> Vec<(text::BufferId, language::BufferRow, Vec<String>)> {
@@ -853,7 +908,7 @@ mod tests {
editor
.update(cx, |editor, window, cx| {
editor.clear_runnables(None);
editor.refresh_runnables(window, cx);
editor.refresh_runnables(None, window, cx);
})
.unwrap();
cx.executor().advance_clock(UPDATE_DEBOUNCE);
@@ -912,4 +967,127 @@ mod tests {
"first.rs runnables should survive an edit to second.rs"
);
}
#[gpui::test]
async fn test_lsp_runnables_removed_after_edit(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"main.rs": indoc! {"
#[test]
fn test_one() {
assert!(true);
}
fn helper() {}
"},
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang_with_lsp_task_context());
let mut fake_servers = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
name: FAKE_LSP_NAME,
..FakeLspAdapter::default()
},
);
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/main.rs"), cx)
})
.await
.unwrap();
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
let editor = cx.add_window(|window, cx| {
build_editor_with_project(project.clone(), multi_buffer, window, cx)
});
let fake_server = fake_servers.next().await.expect("fake LSP server");
use project::lsp_store::lsp_ext_command::Runnables;
fake_server.set_request_handler::<Runnables, _, _>(move |params, _| async move {
let text = params.text_document.uri.path().to_string();
if text.contains("main.rs") {
let uri = lsp::Uri::from_file_path(path!("/project/main.rs")).expect("valid uri");
Ok(vec![Runnable {
label: "LSP test_one".into(),
location: Some(lsp::LocationLink {
origin_selection_range: None,
target_uri: uri,
target_range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(3, 1),
),
target_selection_range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(3, 1),
),
}),
kind: RunnableKind::Cargo,
args: RunnableArgs::Cargo(CargoRunnableArgs {
environment: Default::default(),
cwd: path!("/project").into(),
override_cargo: None,
workspace_root: None,
cargo_args: vec!["test".into(), "test_one".into()],
executable_args: Vec::new(),
}),
}])
} else {
Ok(Vec::new())
}
});
// Trigger a refresh to pick up both tree-sitter and LSP runnables.
editor
.update(cx, |editor, window, cx| {
editor.refresh_runnables(None, window, cx);
})
.expect("editor update");
cx.executor().advance_clock(UPDATE_DEBOUNCE);
cx.executor().run_until_parked();
let labels = editor
.update(cx, |editor, _, _| collect_runnable_labels(editor))
.expect("editor update");
assert_eq!(
labels,
vec![(buffer_id, 0, vec!["LSP test_one".to_string()]),],
"LSP runnables should appear for #[test] fn"
);
// Remove `#[test]` attribute so the function is no longer a test.
buffer.update(cx, |buffer, cx| {
let test_attr_end = buffer.text().find("\nfn test_one").expect("find fn");
buffer.edit([(0..test_attr_end, "")], None, cx);
});
// Also update the LSP handler to return no runnables.
fake_server
.set_request_handler::<Runnables, _, _>(move |_, _| async move { Ok(Vec::new()) });
cx.executor().advance_clock(UPDATE_DEBOUNCE);
cx.executor().run_until_parked();
let labels = editor
.update(cx, |editor, _, _| collect_runnable_labels(editor))
.expect("editor update");
assert_eq!(
labels,
Vec::<(text::BufferId, language::BufferRow, Vec<String>)>::new(),
"Runnables should be removed after #[test] is deleted and LSP returns empty"
);
}
}