diff --git a/assets/settings/default.json b/assets/settings/default.json index 6cbe95849afb65c57bdef955cf0737cab60db49c..098586ee4fcb1ddbdbe6b9f1e9ff71a196e73952 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -339,6 +339,17 @@ // The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. "delay": 300, }, + // Whether and how to display code lenses from language servers. + // + // Possible values: + // + // 1. Do not display code lenses. + // "code_lens": "off", + // 2. Display code lenses from language servers above code elements. + // "code_lens": "on", + // 3. Display code lenses in the code action menu. + // "code_lens": "menu", + "code_lens": "off", // What to do when go to definition yields no results. // // 1. Do nothing: `none` diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 2ce3abf48f12b2ede1f0340e2e438d3df0704985..4eca02280ebe15b6ec94d21e1d3490b7a0e05908 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -1203,6 +1203,13 @@ async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppConte .await; let active_call_a = cx_a.read(ActiveCall::global); cx_b.update(editor::init); + cx_b.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.code_lens = Some(settings::CodeLens::Menu); + }); + }); + }); let command_name = "test_command"; let capabilities = lsp::ServerCapabilities { diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 7524c5b01bf090be3661d1af03f918aa3a7449fb..6a05f94cf628cdac1e57c1acc93a38bc0e91d0d7 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -849,6 +849,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 0000000000000000000000000000000000000000..c1bf2525d9eb7d10eb8efae67f20f52024f03f13 --- /dev/null +++ b/crates/editor/src/code_lens.rs @@ -0,0 +1,1066 @@ +use std::{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 { + lenses + .into_iter() + .into_group_map_by(|(position, _)| { + let row = position.to_point(snapshot).row; + MultiBufferRow(row) + }) + .into_iter() + .sorted_by_key(|(row, _)| *row) + .filter_map(|(row, entries)| { + let position = entries.first()?.0; + let items = entries.into_iter().map(|(_, item)| item).collect(); + let indent_column = snapshot.indent_size_for_line(row).len; + Some(CodeLensLine { + position, + indent_column, + items, + }) + }) +} + +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::with_capacity((2 * lens.items.len()).saturating_sub(1)); + 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(); + if let Some(buffer) = editor + .buffer() + .read(cx) + .buffer(action.range.start.buffer_id) + { + project + .update(cx, |project, cx| { + project + .apply_code_action(buffer, 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) + .buffer(action.range.start.buffer_id) + .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 = std::collections::HashMap::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_insert_with(Vec::new) + .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(); + new_lenses_per_buffer.insert( + buffer_id, + group_lenses_by_row(individual_lenses, &multi_buffer_snapshot) + .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); + }) + .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, visible_range, _)| { + let buffer_id = snapshot.remote_id(); + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + let visible_anchor_range = snapshot.anchor_before(visible_range.start) + ..snapshot.anchor_after(visible_range.end); + let task = project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.resolve_visible_code_lenses(&buffer, visible_anchor_range, 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 std::{ + sync::{Arc, Mutex}, + time::Duration, + }; + + use collections::HashSet; + use futures::StreamExt; + use gpui::TestAppContext; + use settings::CodeLens; + use util::path; + + use crate::{ + Editor, + 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" + ); + }); + } + + #[gpui::test] + async fn test_code_lens_resolve_only_visible(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(CodeLens::On); + }); + + let line_count: u32 = 100; + let lens_every: u32 = 10; + let lines = (0..line_count) + .map(|i| format!("function func_{i}() {{}}")) + .collect::>() + .join("\n"); + + let lens_lines = (0..line_count) + .filter(|i| i % lens_every == 0) + .collect::>(); + + let resolved_lines = Arc::new(Mutex::new(Vec::::new())); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), serde_json::json!({ "main.ts": lines })) + .await; + + let project = project::Project::test(fs, [path!("/dir").as_ref()], cx).await; + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(language::Language::new( + language::LanguageConfig { + name: "TypeScript".into(), + matcher: language::LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..language::LanguageMatcher::default() + }, + ..language::LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ))); + + let mut fake_servers = language_registry.register_fake_lsp( + "TypeScript", + language::FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + code_lens_provider: Some(lsp::CodeLensOptions { + resolve_provider: Some(true), + }), + ..lsp::ServerCapabilities::default() + }, + ..language::FakeLspAdapter::default() + }, + ); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + std::path::PathBuf::from(path!("/dir/main.ts")), + workspace::OpenOptions::default(), + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let fake_server = fake_servers.next().await.unwrap(); + + let lens_lines_for_handler = lens_lines.clone(); + fake_server.set_request_handler::(move |_, _| { + let lens_lines = lens_lines_for_handler.clone(); + async move { + Ok(Some( + lens_lines + .iter() + .map(|&line| lsp::CodeLens { + range: lsp::Range::new( + lsp::Position::new(line, 0), + lsp::Position::new(line, 10), + ), + command: None, + data: Some(serde_json::json!({ "line": line })), + }) + .collect(), + )) + } + }); + + { + let resolved_lines = resolved_lines.clone(); + fake_server.set_request_handler::( + move |lens, _| { + let resolved_lines = resolved_lines.clone(); + async move { + let line = lens + .data + .as_ref() + .and_then(|d| d.get("line")) + .and_then(|v| v.as_u64()) + .unwrap() as u32; + resolved_lines.lock().unwrap().push(line); + Ok(lsp::CodeLens { + command: Some(lsp::Command { + title: format!("{line} references"), + command: format!("show_refs_{line}"), + arguments: None, + }), + ..lens + }) + } + }, + ); + } + + cx.executor().advance_clock(Duration::from_millis(500)); + cx.run_until_parked(); + + let initial_resolved = resolved_lines + .lock() + .unwrap() + .drain(..) + .collect::>(); + assert_eq!( + initial_resolved, + HashSet::from_iter([0, 10, 20, 30, 40]), + "Only lenses visible at the top should be resolved" + ); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end(&crate::actions::MoveToEnd, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(500)); + cx.run_until_parked(); + + let after_scroll_resolved = resolved_lines + .lock() + .unwrap() + .drain(..) + .collect::>(); + assert_eq!( + after_scroll_resolved, + HashSet::from_iter([60, 70, 80, 90]), + "Only newly visible lenses at the bottom should be resolved, not middle ones" + ); + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8f207b2ff6f8fc9295c0b9301c1def133dfd3fcb..405f1f5752cd799e433efea87600a3072600e18c 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; @@ -98,6 +99,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; @@ -111,7 +113,7 @@ use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings}; use element::{LineWithInvisibles, PositionMap, layout_line}; use futures::{ FutureExt, - future::{self, Shared, join}, + future::{self, Shared}, }; use fuzzy::{StringMatch, StringMatchCandidate}; use git::blame::{GitBlame, GlobalBlameRenderer}; @@ -1338,8 +1340,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, @@ -2162,7 +2166,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, @@ -2591,7 +2595,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, @@ -2763,6 +2769,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.inline() { + 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); @@ -7207,6 +7216,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(); @@ -25162,6 +25175,12 @@ impl Editor { self.refresh_document_colors(None, window, cx); } + let code_lens_inline = EditorSettings::get_global(cx).code_lens.inline(); + let was_inline = self.code_lens.is_some(); + if code_lens_inline != was_inline { + self.toggle_code_lens(code_lens_inline, window, cx); + } + self.refresh_inlay_hints( InlayHintRefreshReason::SettingsChange(inlay_hint_settings( self.selections.newest_anchor().head(), @@ -26334,6 +26353,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); } @@ -26504,6 +26524,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); @@ -27760,21 +27781,22 @@ impl CodeActionProvider for Entity { cx: &mut App, ) -> Task>> { self.update(cx, |project, cx| { - let code_lens_actions = project.code_lens_actions(buffer, range.clone(), cx); + let code_lens_actions = if EditorSettings::get_global(cx).code_lens.show_in_menu() { + Some(project.code_lens_actions(buffer, range.clone(), cx)) + } else { + None + }; let code_actions = project.code_actions(buffer, range, None, cx); cx.background_spawn(async move { - let (code_lens_actions, code_actions) = join(code_lens_actions, code_actions).await; - Ok(code_lens_actions - .context("code lens fetch")? - .into_iter() - .flatten() - .chain( - code_actions - .context("code action fetch")? - .into_iter() - .flatten(), - ) - .collect()) + let code_lens_actions = match code_lens_actions { + Some(task) => task.await.context("code lens fetch")?.unwrap_or_default(), + None => Vec::new(), + }; + let code_actions = code_actions + .await + .context("code action fetch")? + .unwrap_or_default(); + Ok(code_lens_actions.into_iter().chain(code_actions).collect()) }) }) } diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index e70dd137ba382049b59691d4252e76ae75cb66d0..b35bce02af4a565e11733e6e8b6c78a9f543804c 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, @@ -295,6 +296,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 cb81da9c5e70fb7c3c681ce42571fb2a310a3504..647f40a95c3fca01dd0de74cbe9837feb461f11b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -28396,6 +28396,9 @@ async fn test_tree_sitter_brackets_newline_insertion(cx: &mut TestAppContext) { #[gpui::test(iterations = 10)] async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); + update_test_editor_settings(cx, &|settings| { + settings.code_lens = Some(settings::CodeLens::Menu); + }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( @@ -28488,15 +28491,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 3c01c12b42c2b7b31158cbc0ad63ad7374bb9991..daaad997c761c8082eb3fa1b40495d706ea9fdca 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 c6167a70283583e09fe1f5c87d7911cb20fb995d..505fbb4d7d2229884d1fdceee0711193c12a596a 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -202,6 +202,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, @@ -555,6 +566,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 73e9b162f4d6e76c4a42d4e24accfd90e79733c9..f4d0ce5f4d4b55e6905d5d5ecdb8fec92f2b9b57 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 d92c1392c128ed72b6e2972bc54dcf7dfc152b1e..56d1f30f3c46920c00d7e6b34c9f7136f17fd7c9 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 7ed170daa39135f14b084bc4b1535f272c325d47..23434b81a98589e57a769bc8eba42e55e5445623 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 d4a4f9b04968413c51607f71047752a9b779b79a..e22f478eb9b95e2c788ca4a52ad101f3d5f46e8e 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 5eb8eaf358b5ad91d43f4fa3e2ead0e458c66550..f1ae73d5acb54abcc26e04bb7a815b34fe6d8d88 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1087,6 +1087,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 { @@ -5572,20 +5573,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(); @@ -5596,15 +5597,18 @@ 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!( + "Skipping executeCommand for {}, not listed in language server capabilities", + command.command + ); + return Ok(ProjectTransaction::default()); } - 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() @@ -5614,12 +5618,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, _| { @@ -11946,12 +11954,17 @@ impl LspStore { &self, id: LanguageServerId, ) -> Option> { - self.as_local() - .and_then(|local| local.language_servers.get(&id)) - .and_then(|language_server_state| match language_server_state { - LanguageServerState::Running { adapter, .. } => Some(adapter.clone()), - _ => None, - }) + if let Some(local) = self.as_local() + && let Some(LanguageServerState::Running { adapter, .. }) = + local.language_servers.get(&id) + { + return Some(adapter.clone()); + } + // In remote (SSH/collab) mode there are no local `language_servers`, but + // `language_server_statuses` is kept in sync with the upstream and carries each + // server's registered name, which is enough to look the adapter up in the registry. + let name = &self.language_server_statuses.get(&id)?.name; + self.languages.adapter_for_name(name) } pub(super) fn update_local_worktree_language_servers( diff --git a/crates/project/src/lsp_store/code_lens.rs b/crates/project/src/lsp_store/code_lens.rs index 756c2dec06ea9d60c164f177ab26e6497b1bb5d3..02059bc076ef1d8dff70ff36e4114a24261a86ca 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,15 @@ 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 text::OffsetRangeExt as _; use crate::{ - CodeAction, LspStore, LspStoreEvent, + CodeAction, LspAction, LspStore, LspStoreEvent, Project, lsp_command::{GetCodeLens, LspCommand as _}, project_settings::ProjectSettings, }; @@ -36,10 +38,44 @@ 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; + } + } + + /// Fetches and returns all code lenses for the buffer. + /// + /// Resolution of individual lenses is the caller's responsibility; see + /// [`LspStore::resolve_visible_code_lenses`]. pub fn code_lens_actions( &mut self, buffer: &Entity, cx: &mut Context, + ) -> Task>>> { + let buffer_id = buffer.read(cx).remote_id(); + let fetch_task = self.fetch_code_lenses(buffer, cx); + + cx.spawn(async move |lsp_store, cx| { + fetch_task + .await + .map_err(|e| anyhow::anyhow!("code lens fetch failed: {e:#}"))?; + + let actions = lsp_store.read_with(cx, |lsp_store, _| { + lsp_store + .lsp_data + .get(&buffer_id) + .and_then(|data| data.code_lens.as_ref()) + .map(|code_lens| code_lens.lens.values().flatten().cloned().collect()) + })?; + Ok(actions) + }) + } + + fn fetch_code_lenses( + &mut self, + buffer: &Entity, + cx: &mut Context, ) -> CodeLensTask { let version_queried_for = buffer.read(cx).version(); let buffer_id = buffer.read(cx).remote_id(); @@ -83,7 +119,9 @@ impl LspStore { .timer(Duration::from_millis(30)) .await; let fetched_lens = lsp_store - .update(cx, |lsp_store, cx| lsp_store.fetch_code_lens(&buffer, cx)) + .update(cx, |lsp_store, cx| { + lsp_store.fetch_code_lens_for_buffer(&buffer, cx) + }) .map_err(Arc::new)? .await .context("fetching code lens") @@ -107,7 +145,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 +158,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()) @@ -131,7 +174,7 @@ impl LspStore { new_task } - pub(super) fn fetch_code_lens( + fn fetch_code_lens_for_buffer( &mut self, buffer: &Entity, cx: &mut Context, @@ -202,6 +245,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( @@ -216,13 +365,59 @@ impl LspStore { } pub(super) async fn handle_refresh_code_lens( - this: Entity, + lsp_store: Entity, _: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - this.update(&mut cx, |_, cx| { + lsp_store.update(&mut cx, |lsp_store, cx| { + lsp_store.invalidate_code_lens(); cx.emit(LspStoreEvent::RefreshCodeLens); }); Ok(proto::Ack {}) } } + +impl Project { + pub fn code_lens_actions( + &mut self, + buffer: &Entity, + range: Range, + cx: &mut Context, + ) -> Task>>> { + let snapshot = buffer.read(cx).snapshot(); + let range = range.to_point(&snapshot); + let range_start = snapshot.anchor_before(range.start); + let range_end = if range.start == range.end { + range_start + } else { + snapshot.anchor_after(range.end) + }; + let range = range_start..range_end; + let lsp_store = self.lsp_store(); + let fetch_task = + lsp_store.update(cx, |lsp_store, cx| lsp_store.code_lens_actions(buffer, cx)); + let buffer = buffer.clone(); + cx.spawn(async move |_, cx| { + let mut actions = fetch_task.await?; + if let Some(actions) = &mut actions { + let resolve_task = lsp_store.update(cx, |lsp_store, cx| { + lsp_store.resolve_visible_code_lenses(&buffer, range.clone(), cx) + }); + let resolved = resolve_task.await; + for resolved_action in resolved { + if let Some(action) = actions.iter_mut().find(|a| { + a.server_id == resolved_action.server_id && a.range == resolved_action.range + }) { + *action = resolved_action; + } + } + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + actions.retain(|action| { + range.start.cmp(&action.range.start, &snapshot).is_ge() + && range.end.cmp(&action.range.end, &snapshot).is_le() + }); + } + Ok(actions) + }) + } +} diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs index 9c284a143613c47aa3a5fcc9af5afac9d6dbbf4d..55395bd066326fbf0da1af878b8b77eb83ac118d 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 f45a6632f40f945de9043ab246e891595618d324..ab66da494dede56214a4f2d78bba102db9ba54b3 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -134,7 +134,7 @@ use std::{ use task_store::TaskStore; use terminals::Terminals; -use text::{Anchor, BufferId, OffsetRangeExt, Point, Rope}; +use text::{Anchor, BufferId, Point, Rope}; use toolchain_store::EmptyToolchainStore; use util::{ ResultExt as _, maybe, @@ -761,7 +761,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, @@ -769,7 +769,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), @@ -4385,45 +4385,6 @@ impl Project { }) } - pub fn code_lens_actions( - &mut self, - buffer: &Entity, - range: Range, - cx: &mut Context, - ) -> Task>>> { - let snapshot = buffer.read(cx).snapshot(); - let range = range.to_point(&snapshot); - let range_start = snapshot.anchor_before(range.start); - let range_end = if range.start == range.end { - range_start - } else { - snapshot.anchor_after(range.end) - }; - let range = range_start..range_end; - let code_lens_actions = self - .lsp_store - .update(cx, |lsp_store, cx| lsp_store.code_lens_actions(buffer, cx)); - - cx.background_spawn(async move { - let mut code_lens_actions = code_lens_actions - .await - .map_err(|e| anyhow!("code lens fetch failed: {e:#}"))?; - if let Some(code_lens_actions) = &mut code_lens_actions { - code_lens_actions.retain(|code_lens_action| { - range - .start - .cmp(&code_lens_action.range.start, &snapshot) - .is_ge() - && range - .end - .cmp(&code_lens_action.range.end, &snapshot) - .is_le() - }); - } - Ok(code_lens_actions) - }) - } - pub fn apply_code_action( &self, buffer_handle: Entity, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index c8876ed2328eb3946a80126e710ca7af29483ffb..6f2c2e3f22369be38e4f939db094d4d532a1551b 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2371,6 +2371,148 @@ async fn test_remote_external_agent_server( ); } +#[gpui::test] +async fn test_remote_apply_code_action_skips_unadvertised_command( + cx: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "README.md": "# project 1", + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; + + fs.insert_tree( + path!("/code/project1/.zed"), + json!({ + "settings.json": r#" + { + "languages": {"Rust":{"language_servers":["rust-analyzer"]}}, + "lsp": { + "rust-analyzer": { + "binary": { + "path": "~/.cargo/bin/rust-analyzer" + } + } + } + }"# + }), + ) + .await; + + cx.update_entity(&project, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..Default::default() + }, + ..Default::default() + }); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + ..Default::default() + }, + ) + }); + + // Register the fake LSP with an empty execute_command_provider and a handler that panics + // if it is ever reached: commands not advertised by the server must be rejected by + // `apply_code_action` before dispatching to the language server. + let mut fake_lsp = server_cx.update(|cx| { + headless.read(cx).languages.register_fake_lsp_server( + LanguageServerName("rust-analyzer".into()), + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: Vec::new(), + ..Default::default() + }), + ..Default::default() + }, + Some(Box::new(|fake| { + fake.set_request_handler::( + |params, _| async move { + panic!( + "Unadvertised command {} must not reach the language server", + params.command + ); + }, + ); + })), + ) + }); + + cx.run_until_parked(); + + let worktree_id = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap() + .0 + .read_with(cx, |worktree, _| worktree.id()); + + cx.run_until_parked(); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_buffer_with_lsp((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + let _fake_lsp = fake_lsp.next().await.unwrap(); + + let server_id = server_cx.read(|cx| { + *headless + .read(cx) + .lsp_store + .read(cx) + .as_local() + .unwrap() + .language_servers + .keys() + .next() + .unwrap() + }); + let buffer_id = cx.read(|cx| buffer.read(cx).remote_id()); + + let action = project::CodeAction { + server_id, + range: language::Anchor::min_min_range_for_buffer(buffer_id), + lsp_action: project::LspAction::Command(lsp::Command { + title: "\u{25b6}\u{fe0e} Run Tests".into(), + command: "rust-analyzer.runSingle".into(), + arguments: Some(vec![json!({"label": "test-mod tests"})]), + }), + resolved: true, + }; + + let transaction = project + .update(cx, |project, cx| { + project.apply_code_action(buffer.clone(), action, true, cx) + }) + .await + .expect("Unadvertised command must not be forwarded to executeCommand"); + assert_eq!(transaction.0.len(), 0); +} + pub async fn init_test( server_fs: &Arc, cx: &mut TestAppContext, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 323b8d7fef0c4352dab594f8b235e3c518328565..11b1de456dc797a60ea8aef0bbd88fa371c617ba 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -271,6 +271,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 d6cdf751fdfd413cf234b59bb7e4c32566e3a125..3ba21e830828b45a92814d0285b5360dedfd70da 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 and how to display code lenses from language servers. + /// + /// Default: "off" + pub code_lens: Option, + /// How to render LSP `textDocument/documentColor` colors in the editor. /// /// Default: [`DocumentColorsRenderMode::Inlay`] @@ -461,7 +466,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, @@ -477,6 +482,46 @@ pub struct GutterContent { strum::VariantNames, )] #[serde(rename_all = "snake_case")] +pub enum CodeLens { + /// Do not query and display code lenses. + #[default] + Off, + /// Display code lenses from language servers above code elements. + On, + /// Display code lenses in the code action menu. + Menu, +} + +impl CodeLens { + pub fn enabled(&self) -> bool { + self != &Self::Off + } + + pub fn inline(&self) -> bool { + *self == Self::On + } + + pub fn show_in_menu(&self) -> bool { + *self == Self::Menu + } +} + +/// 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 d0b0120955aa82079c5617db50bfce96c0e03f9a..8b41e7d4117d87869f1dda5aa837817650cb3305 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -8975,6 +8975,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 and how to display code lenses from language servers.", + 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.", @@ -8999,6 +9013,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(), @@ -9014,6 +9029,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 54d71f1b53621939c4c53c057f9d50c2c1ccb294..7301e03b68dcf4196f0c147afe73a777324df074 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -536,6 +536,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 c6cb2ed8ee6d5df32b4c4b711d544d581ce74d9f..83fd77ccc484c69dea5632c1b6492af2aaf802b5 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 e55bd2e67acd0b2c661ababc25ba5916810595c3..c535acd80f0881b15904152ead1f782c3a91359e 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 25ec709e565df86019b49b37dac9059a2bb7c880..c4c454118ec2e25c750236826a725e91bcbcab1a 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 4e905c2f3ac9d735bdc2b3875cc7eb78b8f4562e..1de73e0486f824b718729fa4c14eae872a3cdc27 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -450,6 +450,24 @@ 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 and how to display code lenses from language servers. 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 query and display code lenses. +2. `on`: Display code lenses from language servers above code elements. +3. `menu`: Display code lenses in the code action menu. + +```json [settings] +{ + "code_lens": "on" +} +``` + ## Confirm Quit - Description: Whether or not to prompt the user to confirm before closing the application.