From d2d2f4d6535658835a323d9f780d3cac589b9375 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 9 Feb 2026 16:39:19 +0200 Subject: [PATCH] Support code lens in the editor # Conflicts: # crates/editor/src/editor.rs --- assets/settings/default.json | 9 + crates/editor/src/actions.rs | 2 + crates/editor/src/code_lens.rs | 913 ++++++++++++++++++ crates/editor/src/editor.rs | 23 +- crates/editor/src/editor_settings.rs | 4 +- crates/editor/src/editor_tests.rs | 9 - crates/editor/src/element.rs | 1 + crates/language/src/language.rs | 19 + crates/languages/src/go.rs | 84 ++ crates/languages/src/rust.rs | 61 +- crates/languages/src/vtsls.rs | 9 + crates/project/src/lsp_command.rs | 31 +- crates/project/src/lsp_store.rs | 55 +- crates/project/src/lsp_store/code_lens.rs | 127 ++- .../project/src/lsp_store/lsp_ext_command.rs | 115 +-- crates/project/src/project.rs | 22 +- crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/editor.rs | 37 +- crates/settings_ui/src/page_data.rs | 16 + crates/settings_ui/src/settings_ui.rs | 1 + crates/zed/src/zed/quick_action_bar.rs | 25 + docs/src/languages/go.md | 33 + docs/src/languages/typescript.md | 45 + docs/src/reference/all-settings.md | 17 + 24 files changed, 1518 insertions(+), 141 deletions(-) create mode 100644 crates/editor/src/code_lens.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 584c4c4d49d..a186b1a7997 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -342,6 +342,15 @@ // The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. "delay": 300, }, + // Whether to display code lenses from language servers above code elements. + // + // Possible values: + // + // 1. Do not display code lenses. + // "code_lens": "off", + // 2. Display code lenses from language servers above code elements. + // "code_lens": "on", + "code_lens": "off", // What to do when go to definition yields no results. // // 1. Do nothing: `none` diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 4c062300695..e60297302e2 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -841,6 +841,8 @@ actions!( ToggleIndentGuides, /// Toggles inlay hints display. ToggleInlayHints, + /// Toggles code lens display. + ToggleCodeLens, /// Toggles semantic highlights display. ToggleSemanticHighlights, /// Toggles inline values display. diff --git a/crates/editor/src/code_lens.rs b/crates/editor/src/code_lens.rs new file mode 100644 index 00000000000..301de9f6b11 --- /dev/null +++ b/crates/editor/src/code_lens.rs @@ -0,0 +1,913 @@ +use std::{collections::HashMap as StdHashMap, iter, ops::Range, sync::Arc}; + +use collections::{HashMap, HashSet}; +use futures::future::join_all; +use gpui::{MouseButton, SharedString, Task, WeakEntity}; +use itertools::Itertools; +use language::{BufferId, ClientCommand}; +use multi_buffer::{Anchor, MultiBufferRow, MultiBufferSnapshot, ToPoint as _}; +use project::{CodeAction, TaskSourceKind}; +use settings::Settings as _; +use task::TaskContext; +use text::Point; + +use ui::{Context, Window, div, prelude::*}; +use workspace::PreviewTabsSettings; + +use crate::{ + Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, MultibufferSelectionMode, SelectionEffects, + actions::ToggleCodeLens, + display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, +}; + +#[derive(Clone, Debug)] +struct CodeLensLine { + position: Anchor, + indent_column: u32, + items: Vec, +} + +#[derive(Clone, Debug)] +struct CodeLensItem { + title: SharedString, + action: CodeAction, +} + +pub(super) struct CodeLensState { + pub(super) block_ids: HashMap>, + resolve_task: Task<()>, +} + +impl Default for CodeLensState { + fn default() -> Self { + Self { + block_ids: HashMap::default(), + resolve_task: Task::ready(()), + } + } +} + +impl CodeLensState { + fn all_block_ids(&self) -> HashSet { + self.block_ids.values().flatten().copied().collect() + } +} + +fn group_lenses_by_row( + lenses: Vec<(Anchor, CodeLensItem)>, + snapshot: &MultiBufferSnapshot, +) -> impl Iterator { + let mut grouped: HashMap)> = HashMap::default(); + + for (position, item) in lenses { + let row = position.to_point(snapshot).row; + grouped + .entry(MultiBufferRow(row)) + .or_insert_with(|| (position, Vec::new())) + .1 + .push(item); + } + + grouped + .into_iter() + .map(|(_, (position, items))| { + let row = position.to_point(snapshot).row; + let indent_column = snapshot + .indent_size_for_line(multi_buffer::MultiBufferRow(row)) + .len; + CodeLensLine { + position, + indent_column, + items, + } + }) + .sorted_by_key(|lens| lens.position.to_point(snapshot).row) +} + +fn render_code_lens_line( + line_number: usize, + lens: CodeLensLine, + editor: WeakEntity, +) -> impl Fn(&mut crate::display_map::BlockContext) -> gpui::AnyElement { + move |cx| { + let mut children: Vec = Vec::new(); + let text_style = &cx.editor_style.text; + let font = text_style.font(); + let font_size = text_style.font_size.to_pixels(cx.window.rem_size()) * 0.9; + + for (i, item) in lens.items.iter().enumerate() { + if i > 0 { + children.push( + div() + .font(font.clone()) + .text_size(font_size) + .text_color(cx.app.theme().colors().text_muted) + .child(" | ") + .into_any_element(), + ); + } + + let title = item.title.clone(); + let action = item.action.clone(); + let editor_handle = editor.clone(); + let position = lens.position; + let id = (line_number as u64) << 32 | (i as u64); + + children.push( + div() + .id(ElementId::Integer(id)) + .font(font.clone()) + .text_size(font_size) + .text_color(cx.app.theme().colors().text_muted) + .cursor_pointer() + .hover(|style| style.text_color(cx.app.theme().colors().text)) + .child(title.clone()) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_down(MouseButton::Right, |_, _, cx| { + cx.stop_propagation(); + }) + .on_click({ + move |_event, window, cx| { + if let Some(editor) = editor_handle.upgrade() { + editor.update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::default(), + window, + cx, + |s| { + s.select_anchor_ranges([position..position]); + }, + ); + + let action = action.clone(); + if let Some(workspace) = editor.workspace() { + if try_handle_client_command( + &action, editor, &workspace, window, cx, + ) { + return; + } + + let project = workspace.read(cx).project().clone(); + let buffer = editor.buffer().clone(); + if let Some(excerpt_buffer) = buffer.read(cx).as_singleton() + { + project + .update(cx, |project, cx| { + project.apply_code_action( + excerpt_buffer.clone(), + action, + true, + cx, + ) + }) + .detach_and_log_err(cx); + } + } + }); + } + } + }) + .into_any_element(), + ); + } + + div() + .pl(cx.margins.gutter.full_width() + cx.em_width * (lens.indent_column as f32 + 0.5)) + .h_full() + .flex() + .flex_row() + .items_end() + .children(children) + .into_any_element() + } +} + +pub(super) fn try_handle_client_command( + action: &CodeAction, + editor: &mut Editor, + workspace: &gpui::Entity, + window: &mut Window, + cx: &mut Context, +) -> bool { + let Some(command) = action.lsp_action.command() else { + return false; + }; + + let arguments = command.arguments.as_deref().unwrap_or_default(); + let project = workspace.read(cx).project().clone(); + let client_command = project + .read(cx) + .lsp_store() + .read(cx) + .language_server_adapter_for_id(action.server_id) + .and_then(|adapter| adapter.adapter.client_command(&command.command, arguments)) + .or_else(|| match command.command.as_str() { + "editor.action.showReferences" + | "editor.action.goToLocations" + | "editor.action.peekLocations" => Some(ClientCommand::ShowLocations), + _ => None, + }); + + match client_command { + Some(ClientCommand::ScheduleTask(task_template)) => { + schedule_task(task_template, action, editor, workspace, window, cx) + } + Some(ClientCommand::ShowLocations) => { + try_show_references(arguments, action, workspace, window, cx) + } + None => false, + } +} + +fn schedule_task( + task_template: task::TaskTemplate, + action: &CodeAction, + editor: &Editor, + workspace: &gpui::Entity, + window: &mut Window, + cx: &mut Context, +) -> bool { + let task_context = TaskContext { + cwd: task_template.cwd.as_ref().map(std::path::PathBuf::from), + ..TaskContext::default() + }; + let language_name = editor + .buffer() + .read(cx) + .as_singleton() + .and_then(|buffer| buffer.read(cx).language()) + .map(|language| language.name()); + let task_source_kind = match language_name { + Some(language_name) => TaskSourceKind::Lsp { + server: action.server_id, + language_name: SharedString::from(language_name), + }, + None => TaskSourceKind::AbsPath { + id_base: "code-lens".into(), + abs_path: task_template + .cwd + .as_ref() + .map(std::path::PathBuf::from) + .unwrap_or_default(), + }, + }; + + workspace.update(cx, |workspace, cx| { + workspace.schedule_task( + task_source_kind, + &task_template, + &task_context, + false, + window, + cx, + ); + }); + true +} + +fn try_show_references( + arguments: &[serde_json::Value], + action: &CodeAction, + workspace: &gpui::Entity, + window: &mut Window, + cx: &mut Context, +) -> bool { + if arguments.len() < 3 { + return false; + } + let Ok(locations) = serde_json::from_value::>(arguments[2].clone()) else { + return false; + }; + if locations.is_empty() { + return false; + } + + let server_id = action.server_id; + let project = workspace.read(cx).project().clone(); + let workspace = workspace.clone(); + + cx.spawn_in(window, async move |_editor, cx| { + let mut buffer_locations: StdHashMap, Vec>> = + StdHashMap::default(); + + for location in &locations { + let open_task = cx.update(|_, cx| { + project.update(cx, |project, cx| { + let uri: lsp::Uri = location.uri.clone(); + project.open_local_buffer_via_lsp(uri, server_id, cx) + }) + })?; + let buffer = open_task.await?; + + let range = range_from_lsp(location.range); + buffer_locations.entry(buffer).or_default().push(range); + } + + workspace.update_in(cx, |workspace, window, cx| { + let target = buffer_locations + .iter() + .flat_map(|(k, v)| iter::repeat(k.clone()).zip(v)) + .map(|(buffer, location)| { + buffer + .read(cx) + .text_for_range(location.clone()) + .collect::() + }) + .filter(|text| !text.contains('\n')) + .unique() + .take(3) + .join(", "); + let title = if target.is_empty() { + "References".to_owned() + } else { + format!("References to {target}") + }; + let allow_preview = + PreviewTabsSettings::get_global(cx).enable_preview_multibuffer_from_code_navigation; + Editor::open_locations_in_multibuffer( + workspace, + buffer_locations, + title, + false, + allow_preview, + MultibufferSelectionMode::First, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + true +} + +fn range_from_lsp(range: lsp::Range) -> Range { + let start = Point::new(range.start.line, range.start.character); + let end = Point::new(range.end.line, range.end.character); + start..end +} + +impl Editor { + pub(super) fn refresh_code_lenses( + &mut self, + for_buffer: Option, + _window: &Window, + cx: &mut Context, + ) { + if !self.lsp_data_enabled() || self.code_lens.is_none() { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + + let buffers_to_query = self + .visible_buffers(cx) + .into_iter() + .filter(|buffer| self.is_lsp_relevant(buffer.read(cx).file(), cx)) + .chain(for_buffer.and_then(|buffer_id| self.buffer.read(cx).buffer(buffer_id))) + .filter(|editor_buffer| { + let editor_buffer_id = editor_buffer.read(cx).remote_id(); + for_buffer.is_none_or(|buffer_id| buffer_id == editor_buffer_id) + && self.registered_buffers.contains_key(&editor_buffer_id) + }) + .unique_by(|buffer| buffer.read(cx).remote_id()) + .collect::>(); + + if buffers_to_query.is_empty() { + return; + } + + let project = project.downgrade(); + self.refresh_code_lens_task = cx.spawn(async move |editor, cx| { + cx.background_executor() + .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT) + .await; + + let Some(tasks) = project + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + buffers_to_query + .into_iter() + .map(|buffer| { + let buffer_id = buffer.read(cx).remote_id(); + let task = lsp_store.code_lens_actions(&buffer, cx); + async move { (buffer_id, task.await) } + }) + .collect::>() + }) + }) + .ok() + else { + return; + }; + + let results = join_all(tasks).await; + if results.is_empty() { + return; + } + + let Ok(multi_buffer_snapshot) = + editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) + else { + return; + }; + + let mut new_lenses_per_buffer = HashMap::default(); + for (buffer_id, result) in results { + let actions = match result { + Ok(Some(actions)) => actions, + Ok(None) => continue, + Err(e) => { + log::error!("Failed to fetch code lenses for buffer {buffer_id:?}: {e:#}"); + continue; + } + }; + let individual_lenses = actions + .into_iter() + .filter_map(|action| { + let title = match &action.lsp_action { + project::LspAction::CodeLens(lens) => lens + .command + .as_ref() + .map(|cmd| SharedString::from(&cmd.title)), + _ => None, + }?; + let position = + multi_buffer_snapshot.anchor_in_excerpt(action.range.start)?; + Some((position, CodeLensItem { title, action })) + }) + .collect(); + let grouped = group_lenses_by_row(individual_lenses, &multi_buffer_snapshot); + new_lenses_per_buffer.insert(buffer_id, grouped.collect::>()); + } + + editor + .update(cx, |editor, cx| { + let code_lens = editor.code_lens.get_or_insert_with(CodeLensState::default); + let mut blocks_to_remove = HashSet::default(); + for buffer_id in new_lenses_per_buffer.keys() { + if let Some(old_ids) = code_lens.block_ids.remove(buffer_id) { + blocks_to_remove.extend(old_ids); + } + } + if !blocks_to_remove.is_empty() { + editor.remove_blocks(blocks_to_remove, None, cx); + } + + let editor_handle = cx.entity().downgrade(); + for (buffer_id, lens_lines) in new_lenses_per_buffer { + if lens_lines.is_empty() { + continue; + } + let blocks = lens_lines + .into_iter() + .enumerate() + .map(|(line_number, lens_line)| { + let position = lens_line.position; + BlockProperties { + placement: BlockPlacement::Above(position), + height: Some(1), + style: BlockStyle::Flex, + render: Arc::new(render_code_lens_line( + line_number, + lens_line, + editor_handle.clone(), + )), + priority: 0, + } + }) + .collect::>(); + let block_ids = editor.insert_blocks(blocks, None, cx); + editor + .code_lens + .get_or_insert_with(CodeLensState::default) + .block_ids + .entry(buffer_id) + .or_default() + .extend(block_ids); + } + + editor.resolve_visible_code_lenses(cx); + cx.notify(); + }) + .ok(); + }); + } + + pub fn supports_code_lens(&self, cx: &ui::App) -> bool { + let Some(project) = self.project.as_ref() else { + return false; + }; + let lsp_store = project.read(cx).lsp_store().read(cx); + lsp_store + .lsp_server_capabilities + .values() + .any(|caps| caps.code_lens_provider.is_some()) + } + + pub fn code_lens_enabled(&self) -> bool { + self.code_lens.is_some() + } + + pub fn toggle_code_lens_action( + &mut self, + _: &ToggleCodeLens, + window: &mut Window, + cx: &mut Context, + ) { + let currently_enabled = self.code_lens.is_some(); + self.toggle_code_lens(!currently_enabled, window, cx); + } + + pub(super) fn toggle_code_lens( + &mut self, + enabled: bool, + window: &mut Window, + cx: &mut Context, + ) { + if enabled { + self.code_lens.get_or_insert_with(CodeLensState::default); + self.refresh_code_lenses(None, window, cx); + } else { + self.clear_code_lenses(cx); + } + } + + pub(super) fn resolve_visible_code_lenses(&mut self, cx: &mut Context) { + if !self.lsp_data_enabled() || self.code_lens.is_none() { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + + let resolve_tasks = self + .visible_buffer_ranges(cx) + .into_iter() + .filter_map(|(snapshot, _, excerpt_range)| { + let buffer_id = snapshot.remote_id(); + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + let task = project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.resolve_visible_code_lenses(&buffer, excerpt_range.context, cx) + }) + }); + Some((buffer_id, task)) + }) + .collect::>(); + if resolve_tasks.is_empty() { + return; + } + + let code_lens = self.code_lens.get_or_insert_with(CodeLensState::default); + code_lens.resolve_task = cx.spawn(async move |editor, cx| { + let resolved_code_lens = join_all( + resolve_tasks + .into_iter() + .map(|(buffer_id, task)| async move { (buffer_id, task.await) }), + ) + .await; + editor + .update(cx, |editor, cx| { + editor.insert_resolved_code_lens_blocks(resolved_code_lens, cx); + }) + .ok(); + }); + } + + fn insert_resolved_code_lens_blocks( + &mut self, + resolved_code_lens: Vec<(BufferId, Vec)>, + cx: &mut Context, + ) { + let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); + let editor_handle = cx.entity().downgrade(); + + for (buffer_id, actions) in resolved_code_lens { + let lenses = actions + .into_iter() + .filter_map(|action| { + let title = match &action.lsp_action { + project::LspAction::CodeLens(lens) => lens + .command + .as_ref() + .map(|cmd| SharedString::from(&cmd.title)), + _ => None, + }?; + let position = multi_buffer_snapshot.anchor_in_excerpt(action.range.start)?; + Some((position, CodeLensItem { title, action })) + }) + .collect(); + + let blocks = group_lenses_by_row(lenses, &multi_buffer_snapshot) + .enumerate() + .map(|(line_number, lens_line)| { + let position = lens_line.position; + BlockProperties { + placement: BlockPlacement::Above(position), + height: Some(1), + style: BlockStyle::Flex, + render: Arc::new(render_code_lens_line( + line_number, + lens_line, + editor_handle.clone(), + )), + priority: 0, + } + }) + .collect::>(); + + if !blocks.is_empty() { + let block_ids = self.insert_blocks(blocks, None, cx); + self.code_lens + .get_or_insert_with(CodeLensState::default) + .block_ids + .entry(buffer_id) + .or_default() + .extend(block_ids); + } + } + cx.notify(); + } + + pub(super) fn clear_code_lenses(&mut self, cx: &mut Context) { + if let Some(code_lens) = self.code_lens.take() { + let all_blocks = code_lens.all_block_ids(); + if !all_blocks.is_empty() { + self.remove_blocks(all_blocks, None, cx); + } + cx.notify(); + } + self.refresh_code_lens_task = Task::ready(()); + } +} + +#[cfg(test)] +mod tests { + use futures::StreamExt; + use gpui::TestAppContext; + + use settings::CodeLens; + + use crate::{ + editor_tests::{init_test, update_test_editor_settings}, + test::editor_lsp_test_context::EditorLspTestContext, + }; + + #[gpui::test] + async fn test_code_lens_blocks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: None, + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["lens_cmd".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + Ok(Some(vec![ + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: Some(lsp::Command { + title: "2 references".to_owned(), + command: "lens_cmd".to_owned(), + arguments: None, + }), + data: None, + }, + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 19)), + command: Some(lsp::Command { + title: "0 references".to_owned(), + command: "lens_cmd".to_owned(), + arguments: None, + }), + data: None, + }, + ])) + }); + + cx.set_state("ˇfunction hello() {}\nfunction world() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received a code lens request" + ); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + assert_eq!( + editor.code_lens_enabled(), + true, + "code lens should be enabled" + ); + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.block_ids.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!(total_blocks, 2, "Should have inserted two code lens blocks"); + }); + } + + #[gpui::test] + async fn test_code_lens_disabled_by_default(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: None, + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["lens_cmd".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + cx.lsp + .set_request_handler::(|_, _| async move { + panic!("Should not request code lenses when disabled"); + }); + + cx.set_state("ˇfunction hello() {}"); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + assert_eq!( + editor.code_lens_enabled(), + false, + "code lens should not be enabled when setting is off" + ); + }); + } + + #[gpui::test] + async fn test_code_lens_toggling(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: None, + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["lens_cmd".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + Ok(Some(vec![lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: Some(lsp::Command { + title: "1 reference".to_owned(), + command: "lens_cmd".to_owned(), + arguments: None, + }), + data: None, + }])) + }); + + cx.set_state("ˇfunction hello() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received a code lens request" + ); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + assert_eq!( + editor.code_lens_enabled(), + true, + "code lens should be enabled" + ); + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.block_ids.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!(total_blocks, 1, "Should have one code lens block"); + }); + + cx.update_editor(|editor, _window, cx| { + editor.clear_code_lenses(cx); + }); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + assert_eq!( + editor.code_lens_enabled(), + false, + "code lens should be disabled after clearing" + ); + }); + } + + #[gpui::test] + async fn test_code_lens_resolve(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut code_lens_request = + cx.set_request_handler::(move |_, _, _| async { + Ok(Some(vec![ + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 19)), + command: None, + data: Some(serde_json::json!({"id": "lens_1"})), + }, + lsp::CodeLens { + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 19)), + command: None, + data: Some(serde_json::json!({"id": "lens_2"})), + }, + ])) + }); + + cx.lsp + .set_request_handler::(|lens, _| async move { + let id = lens + .data + .as_ref() + .and_then(|d| d.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let title = match id { + "lens_1" => "3 references", + "lens_2" => "1 implementation", + _ => "unknown", + }; + Ok(lsp::CodeLens { + command: Some(lsp::Command { + title: title.to_owned(), + command: format!("resolved_{id}"), + arguments: None, + }), + ..lens + }) + }); + + cx.set_state("ˇfunction hello() {}\nfunction world() {}"); + + assert!( + code_lens_request.next().await.is_some(), + "should have received a code lens request" + ); + cx.run_until_parked(); + + cx.editor.read_with(&cx.cx.cx, |editor, _cx| { + let total_blocks: usize = editor + .code_lens + .as_ref() + .map(|s| s.block_ids.values().map(|v| v.len()).sum()) + .unwrap_or(0); + assert_eq!( + total_blocks, 2, + "Unresolved lenses should have been resolved and displayed" + ); + }); + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f3dfef45783..b767cd44723 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -16,6 +16,7 @@ pub mod blink_manager; mod bracket_colorization; mod clangd_ext; pub mod code_context_menus; +mod code_lens; pub mod display_map; mod document_colors; mod document_symbols; @@ -97,6 +98,7 @@ use code_context_menus::{ AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu, CompletionsMenu, ContextMenuOrigin, }; +use code_lens::CodeLensState; use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; use dap::TelemetrySpawnLocation; @@ -1336,8 +1338,10 @@ pub struct Editor { selection_drag_state: SelectionDragState, colors: Option, + code_lens: Option, post_scroll_update: Task<()>, refresh_colors_task: Task<()>, + refresh_code_lens_task: Task<()>, use_document_folding_ranges: bool, refresh_folding_ranges_task: Task<()>, inlay_hints: Option, @@ -2159,7 +2163,7 @@ impl Editor { window, |editor, _, event, window, cx| match event { project::Event::RefreshCodeLens => { - // we always query lens with actions, without storing them, always refreshing them + editor.refresh_code_lenses(None, window, cx); } project::Event::RefreshInlayHints { server_id, @@ -2581,7 +2585,9 @@ impl Editor { runnables: RunnableData::new(), pull_diagnostics_task: Task::ready(()), colors: None, + code_lens: None, refresh_colors_task: Task::ready(()), + refresh_code_lens_task: Task::ready(()), use_document_folding_ranges: false, refresh_folding_ranges_task: Task::ready(()), inlay_hints: None, @@ -2753,6 +2759,9 @@ impl Editor { editor.colors = Some(LspColorData::new(cx)); editor.use_document_folding_ranges = true; editor.inlay_hints = Some(LspInlayHintData::new(inlay_hint_settings)); + if EditorSettings::get_global(cx).code_lens.enabled() { + editor.code_lens = Some(CodeLensState::default()); + } if let Some(buffer) = multi_buffer.read(cx).as_singleton() { editor.register_buffer(buffer.read(cx).remote_id(), cx); @@ -7131,6 +7140,10 @@ impl Editor { }) } CodeActionsItem::CodeAction { action, provider } => { + if code_lens::try_handle_client_command(&action, self, &workspace, window, cx) { + return Some(Task::ready(Ok(()))); + } + let apply_code_action = provider.apply_code_action(buffer, action, true, window, cx); let workspace = workspace.downgrade(); @@ -24822,6 +24835,12 @@ impl Editor { self.refresh_document_colors(None, window, cx); } + let code_lens_enabled = EditorSettings::get_global(cx).code_lens.enabled(); + let was_enabled = self.code_lens.is_some(); + if code_lens_enabled != was_enabled { + self.toggle_code_lens(code_lens_enabled, window, cx); + } + self.refresh_inlay_hints( InlayHintRefreshReason::SettingsChange(inlay_hint_settings( self.selections.newest_anchor().head(), @@ -25994,6 +26013,7 @@ impl Editor { self.refresh_semantic_tokens(for_buffer, None, cx); self.refresh_document_colors(for_buffer, window, cx); self.refresh_folding_ranges(for_buffer, window, cx); + self.refresh_code_lenses(for_buffer, window, cx); self.refresh_document_symbols(for_buffer, cx); } @@ -26164,6 +26184,7 @@ impl Editor { self.register_visible_buffers(cx); self.colorize_brackets(false, cx); self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + self.resolve_visible_code_lenses(cx); if !self.buffer().read(cx).is_singleton() || self.needs_initial_data_update { self.needs_initial_data_update = false; self.update_lsp_data(None, window, cx); diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 548053da7d7..e16c08f7a0d 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -4,7 +4,7 @@ use gpui::App; use language::CursorShape; use project::project_settings::DiagnosticSeverity; pub use settings::{ - CompletionDetailAlignment, CurrentLineHighlight, DelayMs, DiffViewStyle, DisplayIn, + CodeLens, CompletionDetailAlignment, CurrentLineHighlight, DelayMs, DiffViewStyle, DisplayIn, DocumentColorsRenderMode, DoubleClickInMultibuffer, GoToDefinitionFallback, HideMouseMode, MinimapThumb, MinimapThumbBorder, MultiCursorModifier, ScrollBeyondLastLine, ScrollbarDiagnostics, SeedQuerySetting, ShowMinimap, SnippetSortOrder, @@ -58,6 +58,7 @@ pub struct EditorSettings { pub diagnostics_max_severity: Option, pub inline_code_actions: bool, pub drag_and_drop_selection: DragAndDropSelection, + pub code_lens: CodeLens, pub lsp_document_colors: DocumentColorsRenderMode, pub minimum_contrast_for_highlights: f32, pub completion_menu_scrollbar: ShowScrollbar, @@ -293,6 +294,7 @@ impl Settings for EditorSettings { enabled: drag_and_drop_selection.enabled.unwrap(), delay: drag_and_drop_selection.delay.unwrap(), }, + code_lens: editor.code_lens.unwrap(), lsp_document_colors: editor.lsp_document_colors.unwrap(), minimum_contrast_for_highlights: editor.minimum_contrast_for_highlights.unwrap().0, completion_menu_scrollbar: editor diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 38f4259b4d4..0561ff5c875 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -27965,15 +27965,6 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex }), data: None, }, - lsp::CodeLens { - range: lsp::Range::default(), - command: Some(lsp::Command { - title: "Command not in capabilities".to_owned(), - command: "not in capabilities".to_owned(), - arguments: None, - }), - data: None, - }, lsp::CodeLens { range: lsp::Range { start: lsp::Position { diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 392a67bb0d4..37e68e07110 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -502,6 +502,7 @@ impl EditorElement { register_action(editor, window, Editor::toggle_relative_line_numbers); register_action(editor, window, Editor::toggle_indent_guides); register_action(editor, window, Editor::toggle_inlay_hints); + register_action(editor, window, Editor::toggle_code_lens_action); register_action(editor, window, Editor::toggle_semantic_highlights); register_action(editor, window, Editor::toggle_edit_predictions); if editor.read(cx).diagnostics_enabled() { diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 43bbe7a08c7..c3ffc3345de 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -203,6 +203,17 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { )) }); +/// Commands that the client (editor) handles locally rather than forwarding +/// to the language server. Servers embed these in code lens and code action +/// responses when they want the editor to perform a well-known UI action. +#[derive(Debug, Clone)] +pub enum ClientCommand { + /// Open a location list (references panel / peek view). + ShowLocations, + /// Schedule a task from an LSP command's arguments. + ScheduleTask(task::TaskTemplate), +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Location { pub buffer: Entity, @@ -556,6 +567,14 @@ pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller { Ok(original) } + fn client_command( + &self, + _command_name: &str, + _arguments: &[serde_json::Value], + ) -> Option { + None + } + /// Method only implemented by the default JSON language server adapter. /// Used to provide dynamic reloading of the JSON schemas used to /// provide autocompletion and diagnostics in Zed setting and keybind diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 73e9b162f4d..f4d0ce5f4d4 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -225,6 +225,9 @@ impl LspAdapter for GoLspAdapter { "parameterNames": true, "rangeVariableTypes": true }, + "codelenses": { + "test": true + }, "semanticTokens": semantic_tokens_enabled }); @@ -438,6 +441,19 @@ impl LspAdapter for GoLspAdapter { )) } + fn client_command( + &self, + command_name: &str, + arguments: &[serde_json::Value], + ) -> Option { + if let "gopls.run_tests" = command_name { + let template = go_test_task_template(arguments.first()?)?; + Some(ClientCommand::ScheduleTask(template)) + } else { + None + } + } + fn diagnostic_message_to_markdown(&self, message: &str) -> Option { static REGEX: LazyLock = LazyLock::new(|| Regex::new(r"(?m)\n\s*").expect("Failed to create REGEX")); @@ -445,6 +461,74 @@ impl LspAdapter for GoLspAdapter { } } +fn json_string_array(value: &serde_json::Value, key: &str) -> Vec { + value + .get(key) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +fn go_test_task_template(arg: &serde_json::Value) -> Option { + let tests = json_string_array(arg, "Tests"); + let benchmarks = json_string_array(arg, "Benchmarks"); + if tests.is_empty() && benchmarks.is_empty() { + return None; + } + + let mut go_args = vec!["test".to_string(), "-test.fullpath=true".to_string()]; + + if tests.is_empty() { + go_args.push("-benchmem".to_string()); + go_args.push("-run=^$".to_string()); + } else { + go_args.push("-timeout".to_string()); + go_args.push("30s".to_string()); + go_args.push("-run".to_string()); + if tests.len() == 1 { + go_args.push(format!("^{}$", tests[0])); + } else { + go_args.push(format!("^({})$", tests.join("|"))); + } + } + + if !benchmarks.is_empty() { + go_args.push("-bench".to_string()); + if benchmarks.len() == 1 { + go_args.push(format!("^{}$", benchmarks[0])); + } else { + go_args.push(format!("^({})$", benchmarks.join("|"))); + } + } + + go_args.push(".".to_string()); + + let label = if !tests.is_empty() { + format!("go test {}", tests.join(", ")) + } else { + format!("go bench {}", benchmarks.join(", ")) + }; + + let cwd = arg + .get("URI") + .and_then(|v| v.as_str()) + .and_then(|uri| uri.strip_prefix("file://")) + .and_then(|path| std::path::Path::new(path).parent()) + .map(|p| p.to_string_lossy().into_owned()); + + Some(task::TaskTemplate { + label, + command: "go".to_string(), + args: go_args, + cwd, + ..task::TaskTemplate::default() + }) +} + fn parse_version_output(output: &Output) -> Result<&str> { let version_stdout = str::from_utf8(&output.stdout).context("version command produced invalid utf8 output")?; diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index d92c1392c12..56d1f30f3c4 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -9,6 +9,7 @@ use http_client::github::{GitHubLspBinaryVersion, latest_github_release}; use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; pub use language::*; use lsp::{InitializeParams, LanguageServerBinary, LanguageServerBinaryOptions}; +use project::lsp_store::lsp_ext_command; use project::lsp_store::rust_analyzer_ext::CARGO_DIAGNOSTICS_SOURCE_NAME; use project::project_settings::ProjectSettings; use regex::Regex; @@ -608,21 +609,61 @@ impl LspAdapter for RustLspAdapter { .lsp .get(&SERVER_NAME) .is_some_and(|s| s.enable_lsp_tasks); - if enable_lsp_tasks { - let experimental = json!({ - "runnables": { - "kinds": [ "cargo", "shell" ], - }, - }); - if let Some(original_experimental) = &mut original.capabilities.experimental { - merge_json_value_into(experimental, original_experimental); - } else { - original.capabilities.experimental = Some(experimental); + + let mut experimental = json!({ + "commands": { + "commands": [ + "rust-analyzer.showReferences", + "rust-analyzer.gotoLocation", + "rust-analyzer.triggerParameterHints", + "rust-analyzer.rename", + ] } + }); + + if enable_lsp_tasks { + merge_json_value_into( + json!({ + "runnables": { + "kinds": [ "cargo", "shell" ], + }, + "commands": { + "commands": [ + "rust-analyzer.runSingle", + ] + } + }), + &mut experimental, + ); + } + + if let Some(original_experimental) = &mut original.capabilities.experimental { + merge_json_value_into(experimental, original_experimental); + } else { + original.capabilities.experimental = Some(experimental); } Ok(original) } + + fn client_command( + &self, + command_name: &str, + arguments: &[serde_json::Value], + ) -> Option { + match command_name { + "rust-analyzer.showReferences" => Some(ClientCommand::ShowLocations), + "rust-analyzer.runSingle" => { + let first_arg = arguments.first()?; + let runnable = + serde_json::from_value::(first_arg.clone()).ok()?; + let template = + lsp_ext_command::runnable_to_task_template(runnable.label, runnable.args); + Some(ClientCommand::ScheduleTask(template)) + } + _ => None, + } + } } impl LspInstaller for RustLspAdapter { diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 7ed170daa39..23434b81a98 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -269,6 +269,15 @@ impl LspAdapter for VtslsLspAdapter { "enabled": true } }, + "implementationsCodeLens": { + "enabled": true, + "showOnAllClassMethods": true, + "showOnInterfaceMethods": true + }, + "referencesCodeLens": { + "enabled": true, + "showOnAllFunctions": true + }, "tsserver": { "maxTsServerMemory": 8092 }, diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index d4a4f9b0496..e22f478eb9b 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -33,6 +33,7 @@ use lsp::{ OneOf, RenameOptions, ServerCapabilities, }; use serde_json::Value; + use signature_help::{lsp_to_proto_signature, proto_to_lsp_signature}; use std::{ cmp::Reverse, collections::hash_map, mem, ops::Range, path::Path, str::FromStr, sync::Arc, @@ -3851,45 +3852,27 @@ impl LspCommand for GetCodeLens { async fn response_from_lsp( self, message: Option>, - lsp_store: Entity, + _lsp_store: Entity, buffer: Entity, server_id: LanguageServerId, cx: AsyncApp, ) -> anyhow::Result> { let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot()); - let language_server = cx.update(|cx| { - lsp_store - .read(cx) - .language_server_for_id(server_id) - .with_context(|| { - format!("Missing the language server that just returned a response {server_id}") - }) - })?; - let server_capabilities = language_server.capabilities(); - let available_commands = server_capabilities - .execute_command_provider - .as_ref() - .map(|options| options.commands.as_slice()) - .unwrap_or_default(); - Ok(message - .unwrap_or_default() + let code_lenses = message.unwrap_or_default(); + + Ok(code_lenses .into_iter() - .filter(|code_lens| { - code_lens - .command - .as_ref() - .is_none_or(|command| available_commands.contains(&command.command)) - }) .map(|code_lens| { let code_lens_range = range_from_lsp(code_lens.range); let start = snapshot.clip_point_utf16(code_lens_range.start, Bias::Left); let end = snapshot.clip_point_utf16(code_lens_range.end, Bias::Right); let range = snapshot.anchor_before(start)..snapshot.anchor_after(end); + let resolved = code_lens.command.is_some(); CodeAction { server_id, range, lsp_action: LspAction::CodeLens(code_lens), - resolved: false, + resolved, } }) .collect()) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 68e6265a712..0e0e0bd9ad0 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1086,6 +1086,7 @@ impl LocalLspStore { let mut cx = cx.clone(); async move { this.update(&mut cx, |this, cx| { + this.invalidate_code_lens(); cx.emit(LspStoreEvent::RefreshCodeLens); this.downstream_client.as_ref().map(|(client, project_id)| { client.send(proto::RefreshCodeLens { @@ -5544,20 +5545,20 @@ impl LspStore { .await .context("resolving a code action")?; if let Some(edit) = action.lsp_action.edit() - && (edit.changes.is_some() || edit.document_changes.is_some()) { - return LocalLspStore::deserialize_workspace_edit( - this.upgrade().context("no app present")?, - edit.clone(), - push_to_history, - - lang_server.clone(), - cx, - ) - .await; - } + && (edit.changes.is_some() || edit.document_changes.is_some()) + { + return LocalLspStore::deserialize_workspace_edit( + this.upgrade().context("no app present")?, + edit.clone(), + push_to_history, + lang_server.clone(), + cx, + ) + .await; + } let Some(command) = action.lsp_action.command() else { - return Ok(ProjectTransaction::default()) + return Ok(ProjectTransaction::default()); }; let server_capabilities = lang_server.capabilities(); @@ -5568,15 +5569,17 @@ impl LspStore { .unwrap_or_default(); if !available_commands.contains(&command.command) { - log::warn!("Cannot execute a command {} not listed in the language server capabilities", command.command); - return Ok(ProjectTransaction::default()) + log::warn!( + "Executing command {} not listed in the language server capabilities", + command.command + ); } - let request_timeout = cx.update(|app| + let request_timeout = cx.update(|app| { ProjectSettings::get_global(app) - .global_lsp_settings - .get_request_timeout() - ); + .global_lsp_settings + .get_request_timeout() + }); this.update(cx, |this, _| { this.as_local_mut() @@ -5586,12 +5589,16 @@ impl LspStore { })?; let _result = lang_server - .request::(lsp::ExecuteCommandParams { - command: command.command.clone(), - arguments: command.arguments.clone().unwrap_or_default(), - ..lsp::ExecuteCommandParams::default() - }, request_timeout) - .await.into_response() + .request::( + lsp::ExecuteCommandParams { + command: command.command.clone(), + arguments: command.arguments.clone().unwrap_or_default(), + ..lsp::ExecuteCommandParams::default() + }, + request_timeout, + ) + .await + .into_response() .context("execute command")?; return this.update(cx, |this, _| { diff --git a/crates/project/src/lsp_store/code_lens.rs b/crates/project/src/lsp_store/code_lens.rs index 756c2dec06e..e7f80a57897 100644 --- a/crates/project/src/lsp_store/code_lens.rs +++ b/crates/project/src/lsp_store/code_lens.rs @@ -1,3 +1,4 @@ +use std::ops::Range; use std::sync::Arc; use anyhow::{Context as _, Result}; @@ -8,14 +9,14 @@ use futures::{ future::{Shared, join_all}, }; use gpui::{AppContext as _, AsyncApp, Context, Entity, Task}; -use language::Buffer; +use language::{Anchor, Buffer, ToOffset as _}; use lsp::LanguageServerId; use rpc::{TypedEnvelope, proto}; use settings::Settings as _; use std::time::Duration; use crate::{ - CodeAction, LspStore, LspStoreEvent, + CodeAction, LspAction, LspStore, LspStoreEvent, lsp_command::{GetCodeLens, LspCommand as _}, project_settings::ProjectSettings, }; @@ -36,6 +37,12 @@ impl CodeLensData { } impl LspStore { + pub(super) fn invalidate_code_lens(&mut self) { + for lsp_data in self.lsp_data.values_mut() { + lsp_data.code_lens = None; + } + } + pub fn code_lens_actions( &mut self, buffer: &Entity, @@ -107,7 +114,7 @@ impl LspStore { }; lsp_store - .update(cx, |lsp_store, _| { + .update(cx, |lsp_store, cx| { let lsp_data = lsp_store.current_lsp_data(buffer_id)?; let code_lens = lsp_data.code_lens.as_mut()?; if let Some(fetched_lens) = fetched_lens { @@ -120,6 +127,11 @@ impl LspStore { lsp_data.buffer_version = query_version_queried_for; code_lens.lens = fetched_lens; } + let snapshot = buffer.read(cx).snapshot(); + for actions in code_lens.lens.values_mut() { + actions + .sort_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot)); + } } code_lens.update = None; Some(code_lens.lens.values().flatten().cloned().collect()) @@ -202,6 +214,112 @@ impl LspStore { } } + pub fn resolve_visible_code_lenses( + &mut self, + buffer: &Entity, + visible_range: Range, + cx: &mut Context, + ) -> Task> { + let buffer_id = buffer.read(cx).remote_id(); + let snapshot = buffer.read(cx).snapshot(); + let visible_start = visible_range.start.to_offset(&snapshot); + let visible_end = visible_range.end.to_offset(&snapshot); + + let Some(code_lens) = self + .lsp_data + .get(&buffer_id) + .and_then(|data| data.code_lens.as_ref()) + else { + return Task::ready(Vec::new()); + }; + + let capable_servers = code_lens + .lens + .keys() + .filter_map(|server_id| { + let server = self.language_server_for_id(*server_id)?; + GetCodeLens::can_resolve_lens(&server.capabilities()) + .then_some((*server_id, server)) + }) + .collect::>(); + if capable_servers.is_empty() { + return Task::ready(Vec::new()); + } + + let to_resolve = code_lens + .lens + .iter() + .flat_map(|(server_id, actions)| { + let start_idx = + actions.partition_point(|a| a.range.start.to_offset(&snapshot) < visible_start); + let end_idx = start_idx + + actions[start_idx..] + .partition_point(|a| a.range.start.to_offset(&snapshot) <= visible_end); + actions[start_idx..end_idx].iter().enumerate().filter_map( + move |(local_idx, action)| { + let LspAction::CodeLens(lens) = &action.lsp_action else { + return None; + }; + if lens.command.is_some() { + return None; + } + Some((*server_id, start_idx + local_idx, lens.clone())) + }, + ) + }) + .collect::>(); + if to_resolve.is_empty() { + return Task::ready(Vec::new()); + } + + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + + cx.spawn(async move |lsp_store, cx| { + let mut resolved = Vec::new(); + for (server_id, index, lens) in to_resolve { + let Some(server) = capable_servers.get(&server_id) else { + continue; + }; + match server + .request::(lens, request_timeout) + .await + .into_response() + { + Ok(resolved_lens) => resolved.push((server_id, index, resolved_lens)), + Err(e) => log::warn!("Failed to resolve code lens: {e:#}"), + } + } + if resolved.is_empty() { + return Vec::new(); + } + + lsp_store + .update(cx, |lsp_store, _| { + let Some(code_lens) = lsp_store + .lsp_data + .get_mut(&buffer_id) + .and_then(|data| data.code_lens.as_mut()) + else { + return Vec::new(); + }; + let mut newly_resolved = Vec::new(); + for (server_id, index, resolved_lens) in resolved { + if let Some(actions) = code_lens.lens.get_mut(&server_id) { + if let Some(action) = actions.get_mut(index) { + action.resolved = true; + action.lsp_action = LspAction::CodeLens(resolved_lens); + newly_resolved.push(action.clone()); + } + } + } + newly_resolved + }) + .unwrap_or_default() + }) + } + #[cfg(any(test, feature = "test-support"))] pub fn forget_code_lens_task(&mut self, buffer_id: text::BufferId) -> Option { Some( @@ -220,7 +338,8 @@ impl LspStore { _: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - this.update(&mut cx, |_, cx| { + this.update(&mut cx, |this, cx| { + this.invalidate_code_lens(); cx.emit(LspStoreEvent::RefreshCodeLens); }); Ok(proto::Ack {}) diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index 9c284a14361..55395bd0663 100644 --- a/crates/project/src/lsp_store/lsp_ext_command.rs +++ b/crates/project/src/lsp_store/lsp_ext_command.rs @@ -584,6 +584,56 @@ pub struct LspRunnables { pub runnables: Vec<(Option, TaskTemplate)>, } +pub fn runnable_to_task_template(label: String, args: RunnableArgs) -> TaskTemplate { + let mut task_template = TaskTemplate::default(); + task_template.label = label; + match args { + RunnableArgs::Cargo(cargo) => { + match cargo.override_cargo { + Some(override_cargo) => { + let mut override_parts = override_cargo.split(" ").map(|s| s.to_string()); + task_template.command = override_parts + .next() + .unwrap_or_else(|| override_cargo.clone()); + task_template.args.extend(override_parts); + } + None => task_template.command = "cargo".to_string(), + }; + task_template.env = cargo.environment; + task_template.cwd = Some( + cargo + .workspace_root + .unwrap_or(cargo.cwd) + .to_string_lossy() + .to_string(), + ); + task_template.args.extend(cargo.cargo_args); + if !cargo.executable_args.is_empty() { + let shell_kind = task_template.shell.shell_kind(cfg!(windows)); + task_template.args.push("--".to_string()); + task_template.args.extend( + cargo + .executable_args + .into_iter() + // rust-analyzer's doctest data may contain things like `X::new` + // which cause shell issues when run as `$SHELL -i -c "cargo test ..."`. + // Escape extra cargo args unconditionally as those are unlikely to contain `~`. + .flat_map(|extra_arg| { + shell_kind.try_quote(&extra_arg).map(|s| s.to_string()) + }), + ); + } + } + RunnableArgs::Shell(shell) => { + task_template.command = shell.program; + task_template.args = shell.args; + task_template.env = shell.environment; + task_template.cwd = Some(shell.cwd.to_string_lossy().into_owned()); + } + } + task_template +} + #[async_trait(?Send)] impl LspCommand for GetLspRunnables { type Response = LspRunnables; @@ -632,70 +682,7 @@ impl LspCommand for GetLspRunnables { ), None => None, }; - let mut task_template = TaskTemplate::default(); - task_template.label = runnable.label; - match runnable.args { - RunnableArgs::Cargo(cargo) => { - match cargo.override_cargo { - Some(override_cargo) => { - let mut override_parts = - override_cargo.split(" ").map(|s| s.to_string()); - task_template.command = override_parts - .next() - .unwrap_or_else(|| override_cargo.clone()); - task_template.args.extend(override_parts); - } - None => task_template.command = "cargo".to_string(), - }; - task_template.env = cargo.environment; - task_template.cwd = Some( - cargo - .workspace_root - .unwrap_or(cargo.cwd) - .to_string_lossy() - .to_string(), - ); - task_template.args.extend(cargo.cargo_args); - if !cargo.executable_args.is_empty() { - let shell_kind = task_template.shell.shell_kind(cfg!(windows)); - task_template.args.push("--".to_string()); - task_template.args.extend( - cargo - .executable_args - .into_iter() - // rust-analyzer's doctest data may be smth. like - // ``` - // command: "cargo", - // args: [ - // "test", - // "--doc", - // "--package", - // "cargo-output-parser", - // "--", - // "X::new", - // "--show-output", - // ], - // ``` - // and `X::new` will cause troubles if not escaped properly, as later - // the task runs as `$SHELL -i -c "cargo test ..."`. - // - // We cannot escape all shell arguments unconditionally, as we use this for ssh commands, which may involve paths starting with `~`. - // That bit is not auto-expanded when using single quotes. - // Escape extra cargo args unconditionally as those are unlikely to contain `~`. - .flat_map(|extra_arg| { - shell_kind.try_quote(&extra_arg).map(|s| s.to_string()) - }), - ); - } - } - RunnableArgs::Shell(shell) => { - task_template.command = shell.program; - task_template.args = shell.args; - task_template.env = shell.environment; - task_template.cwd = Some(shell.cwd.to_string_lossy().into_owned()); - } - } - + let task_template = runnable_to_task_template(runnable.label, runnable.args); runnables.push((location, task_template)); } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index bb128388ae3..de24689779e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -758,7 +758,7 @@ impl LspAction { } } - fn edit(&self) -> Option<&lsp::WorkspaceEdit> { + pub fn edit(&self) -> Option<&lsp::WorkspaceEdit> { match self { Self::Action(action) => action.edit.as_ref(), Self::Command(_) => None, @@ -766,7 +766,7 @@ impl LspAction { } } - fn command(&self) -> Option<&lsp::Command> { + pub fn command(&self) -> Option<&lsp::Command> { match self { Self::Action(action) => action.command.as_ref(), Self::Command(command) => Some(command), @@ -4382,11 +4382,19 @@ impl Project { let code_lens_actions = self .lsp_store .update(cx, |lsp_store, cx| lsp_store.code_lens_actions(buffer, cx)); + let lsp_store = self.lsp_store.clone(); + let buffer = buffer.clone(); - cx.background_spawn(async move { + cx.spawn(async move |_, cx| { let mut code_lens_actions = code_lens_actions .await .map_err(|e| anyhow!("code lens fetch failed: {e:#}"))?; + let resolved = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.resolve_visible_code_lenses(&buffer, range.clone(), cx) + }) + .await; + if let Some(code_lens_actions) = &mut code_lens_actions { code_lens_actions.retain(|code_lens_action| { range @@ -4398,6 +4406,14 @@ impl Project { .cmp(&code_lens_action.range.end, &snapshot) .is_le() }); + for resolved_action in resolved { + if let Some(existing) = code_lens_actions.iter_mut().find(|existing_action| { + existing_action.server_id == resolved_action.server_id + && existing_action.range == resolved_action.range + }) { + *existing = resolved_action; + } + } } Ok(code_lens_actions) }) diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index c83e5657737..f3921f6878d 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -270,6 +270,7 @@ impl VsCodeSettings { hover_popover_sticky: self.read_bool("editor.hover.sticky"), hover_popover_hiding_delay: self.read_u64("editor.hover.hidingDelay").map(Into::into), inline_code_actions: None, + code_lens: None, jupyter: None, lsp_document_colors: None, lsp_highlight_debounce: None, diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 60c2686c084..f14e0ca9400 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -215,6 +215,11 @@ pub struct EditorSettingsContent { /// Drag and drop related settings pub drag_and_drop_selection: Option, + /// Whether to display code lenses from language servers above code elements. + /// + /// Default: "off" + pub code_lens: Option, + /// How to render LSP `textDocument/documentColor` colors in the editor. /// /// Default: [`DocumentColorsRenderMode::Inlay`] @@ -457,7 +462,7 @@ pub struct GutterContent { pub folds: Option, } -/// How to render LSP `textDocument/documentColor` colors in the editor. +/// Whether to display code lenses from language servers above code elements. #[derive( Copy, Clone, @@ -473,6 +478,36 @@ pub struct GutterContent { strum::VariantNames, )] #[serde(rename_all = "snake_case")] +pub enum CodeLens { + /// Do not display code lenses. + #[default] + Off, + /// Display code lenses from language servers above code elements. + On, +} + +impl CodeLens { + pub fn enabled(&self) -> bool { + self != &Self::Off + } +} + +/// How to render LSP `textDocument/documentColor` colors in the editor. +#[derive( + Debug, + Clone, + Copy, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + PartialEq, + Eq, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] pub enum DocumentColorsRenderMode { /// Do not query and render document colors. None, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0f5679b85f8..58bd51a9b77 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -8903,6 +8903,20 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { let is_global = active_language().is_none(); + let code_lens_item = [SettingsPageItem::SettingItem(SettingItem { + title: "Code Lens", + description: "Whether to display code lenses from language servers above code elements.", + field: Box::new(SettingField { + json_path: Some("code_lens"), + pick: |settings_content| settings_content.editor.code_lens.as_ref(), + write: |settings_content, value| { + settings_content.editor.code_lens = value; + }, + }), + metadata: None, + files: USER, + })]; + let lsp_document_colors_item = [SettingsPageItem::SettingItem(SettingItem { title: "LSP Document Colors", description: "How to render LSP color previews in the editor.", @@ -8927,6 +8941,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { whitespace_section(), completions_section(), inlay_hints_section(), + code_lens_item, lsp_document_colors_item, tasks_section(), miscellaneous_section(), @@ -8942,6 +8957,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { whitespace_section(), completions_section(), inlay_hints_section(), + code_lens_item, tasks_section(), miscellaneous_section(), ) diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 63813b2783b..eaa67bae90b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -535,6 +535,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index e35bd2aad5d..e2b33b88592 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -112,11 +112,13 @@ impl Render for QuickActionBar { let supports_inlay_hints = editor.update(cx, |editor, cx| editor.supports_inlay_hints(cx)); let supports_semantic_tokens = editor.update(cx, |editor, cx| editor.supports_semantic_tokens(cx)); + let supports_code_lens = editor.update(cx, |editor, cx| editor.supports_code_lens(cx)); let editor_value = editor.read(cx); let selection_menu_enabled = editor_value.selection_menu_enabled(cx); let inlay_hints_enabled = editor_value.inlay_hints_enabled(); let inline_values_enabled = editor_value.inline_values_enabled(); let semantic_highlights_enabled = editor_value.semantic_highlights_enabled(); + let code_lens_enabled = editor_value.code_lens_enabled(); let is_full = editor_value.mode().is_full(); let diagnostics_enabled = editor_value.diagnostics_max_severity != DiagnosticSeverity::Off; let supports_inline_diagnostics = editor_value.inline_diagnostics_enabled(); @@ -404,6 +406,29 @@ impl Render for QuickActionBar { ); } + if supports_code_lens { + menu = menu.toggleable_entry( + "Code Lens", + code_lens_enabled, + IconPosition::Start, + Some(editor::actions::ToggleCodeLens.boxed_clone()), + { + let editor = editor.clone(); + move |window, cx| { + editor + .update(cx, |editor, cx| { + editor.toggle_code_lens_action( + &editor::actions::ToggleCodeLens, + window, + cx, + ); + }) + .ok(); + } + }, + ); + } + if supports_minimap { menu = menu.toggleable_entry("Minimap", minimap_enabled, IconPosition::Start, Some(editor::actions::ToggleMinimap.boxed_clone()), { let editor = editor.clone(); diff --git a/docs/src/languages/go.md b/docs/src/languages/go.md index e55bd2e67ac..c535acd80f0 100644 --- a/docs/src/languages/go.md +++ b/docs/src/languages/go.md @@ -78,6 +78,39 @@ to override these settings. See [gopls inlayHints documentation](https://github.com/golang/tools/blob/master/gopls/doc/inlayHints.md) for more information. +## Code Lens + +Zed enables the `test` code lens for `gopls` by default. This shows "run test" and "run benchmark" links above `Test` and `Benchmark` functions in `*_test.go` files. To use them, enable the `code_lens` setting: + +```json [settings] +{ + "code_lens": "on" +} +``` + +You can override the default code lens settings in your `settings.json`: + +```json [settings] +{ + "lsp": { + "gopls": { + "initialization_options": { + "codelenses": { + "test": true, + "generate": true, + "regenerate_cgo": true, + "tidy": true, + "upgrade_dependency": true, + "vendor": true + } + } + } + } +} +``` + +See [gopls code lenses documentation](https://go.dev/gopls/codelenses) for more information. + ## Debugging Zed supports zero-configuration debugging of Go tests and entry points (`func main`) using Delve. Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these preconfigured debug tasks. diff --git a/docs/src/languages/typescript.md b/docs/src/languages/typescript.md index 25ec709e565..c4c454118ec 100644 --- a/docs/src/languages/typescript.md +++ b/docs/src/languages/typescript.md @@ -189,6 +189,51 @@ When using `vtsls`: } ``` +## Code Lens + +Zed enables references and implementations code lenses for `vtsls` by default. These show reference counts and implementation counts above functions, classes, and interfaces. To use them, enable the `code_lens` setting: + +```json [settings] +{ + "code_lens": "on" +} +``` + +You can override the default code lens settings in your `settings.json`: + +```json [settings] +{ + "lsp": { + "vtsls": { + "settings": { + "typescript": { + "implementationsCodeLens": { + "enabled": true, + "showOnAllClassMethods": true, + "showOnInterfaceMethods": true + }, + "referencesCodeLens": { + "enabled": true, + "showOnAllFunctions": true + } + }, + "javascript": { + "implementationsCodeLens": { + "enabled": true, + "showOnAllClassMethods": true, + "showOnInterfaceMethods": true + }, + "referencesCodeLens": { + "enabled": true, + "showOnAllFunctions": true + } + } + } + } + } +} +``` + ## Debugging Zed supports debugging TypeScript code out of the box with `vscode-js-debug`. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 6eada231df3..22495c9fc0d 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -450,6 +450,23 @@ When enabled, this setting will automatically close tabs for files that have bee Note: Dirty files (files with unsaved changes) will not be automatically closed even when this setting is enabled, ensuring you don't lose unsaved work. +## Code Lens + +- Description: Whether to display code lenses from language servers above code elements. Code lenses show contextual information such as reference counts, implementations, and other metadata provided by the language server. +- Setting: `code_lens` +- Default: `off` + +**Options** + +1. `off`: Do not display code lenses. +2. `on`: Display code lenses from language servers above code elements. + +```json [settings] +{ + "code_lens": "on" +} +``` + ## Confirm Quit - Description: Whether or not to prompt the user to confirm before closing the application.