diff --git a/Cargo.lock b/Cargo.lock index 587fd71eef..c075e43b68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10417,6 +10417,29 @@ dependencies = [ "url", ] +[[package]] +name = "lsp_command_selector" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor", + "futures 0.3.32", + "fuzzy", + "gpui", + "language", + "log", + "lsp", + "menu", + "picker", + "project", + "serde_json", + "settings", + "ui", + "util", + "workspace", + "zlog", +] + [[package]] name = "lsp_locations" version = "0.1.0" @@ -22806,6 +22829,7 @@ dependencies = [ "languages", "line_ending_selector", "log", + "lsp_command_selector", "lsp_locations", "markdown", "markdown_preview", diff --git a/Cargo.toml b/Cargo.toml index f3d5d49741..144d3f138f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,7 @@ members = [ "crates/llama_cpp", "crates/lmstudio", "crates/lsp", + "crates/lsp_command_selector", "crates/lsp_locations", "crates/markdown", "crates/markdown_preview", @@ -399,6 +400,7 @@ livekit_client = { path = "crates/livekit_client" } llama_cpp = { path = "crates/llama_cpp" } lmstudio = { path = "crates/lmstudio" } lsp = { path = "crates/lsp" } +lsp_command_selector = { path = "crates/lsp_command_selector" } lsp_locations = { path = "crates/lsp_locations" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 37ad4da639..a8d9d34047 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1222,6 +1222,12 @@ // Editor bindings. }, }, + { + "context": "LspCommandSelector > Picker > Editor", + "bindings": { + "tab": "lsp_command_selector::ToggleArgumentsFocus", + }, + }, { "context": "ChannelModal > Picker > Editor", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 75776e0955..d4988839d7 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1275,6 +1275,13 @@ // Editor bindings. }, }, + { + "context": "LspCommandSelector > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "tab": "lsp_command_selector::ToggleArgumentsFocus", + }, + }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index c7747268ef..f423ba4839 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1230,6 +1230,13 @@ // Editor bindings. }, }, + { + "context": "LspCommandSelector > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "tab": "lsp_command_selector::ToggleArgumentsFocus", + }, + }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index a1f78c2be3..b99cf949c1 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -401,6 +401,7 @@ impl Server { forward_mutating_project_request::, ) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9602d859b9..855827d57a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -365,7 +365,7 @@ pub fn init(cx: &mut App) { workspace::register_serializable_item::(cx); cx.observe_new( - |workspace: &mut Workspace, _: Option<&mut Window>, _cx: &mut Context| { + |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context| { workspace.register_action(Editor::new_file); workspace.register_action(Editor::new_file_split); workspace.register_action(Editor::new_file_vertical); @@ -373,6 +373,19 @@ pub fn init(cx: &mut App) { workspace.register_action(Editor::cancel_language_server_work); workspace.register_action(Editor::toggle_focus); workspace.register_action(Editor::view_bookmarks); + if let Some(window) = window { + cx.subscribe_in( + workspace.project(), + window, + |workspace, _, event, window, cx| { + if let project::Event::LanguageServerShowDocument(request) = event { + items::handle_lsp_show_document(workspace, request, window, cx) + .detach(); + } + }, + ) + .detach(); + } }, ) .detach(); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 1f1d483a5c..ba56029d12 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -46051,3 +46051,164 @@ async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut Test ); }); } + +#[gpui::test] +async fn test_lsp_show_document(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let target_path = EditorLspTestContext::root_path() + .join("dir") + .join("target.rs"); + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(&target_path, b"fn target() {}".to_vec()) + .await; + + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: lsp::Uri::from_file_path(&target_path).unwrap(), + external: None, + take_focus: Some(true), + selection: Some(lsp::Range::new( + lsp::Position::new(0, 3), + lsp::Position::new(0, 9), + )), + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + cx.run_until_parked(); + + cx.update_workspace(|workspace, _, cx| { + let editor = workspace + .active_item_as::(cx) + .expect("an editor should be opened for the shown document"); + editor.update(cx, |editor, cx| { + let path = editor + .buffer() + .read(cx) + .as_singleton() + .expect("a singleton buffer should be opened") + .read(cx) + .file() + .expect("the opened buffer should have a file") + .path() + .clone(); + assert_eq!(path.as_ref(), rel_path("dir/target.rs")); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + vec![Point::new(0, 3)..Point::new(0, 9)] + ); + }); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_external(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let initial_item_id = cx + .update_workspace(|workspace, _, cx| workspace.active_item(cx).map(|item| item.item_id())); + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: "https://zed.dev/docs".parse::().unwrap(), + external: Some(true), + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + assert_eq!(cx.opened_url(), Some("https://zed.dev/docs".to_string())); + cx.run_until_parked(); + cx.update_workspace(|workspace, _, cx| { + assert_eq!( + workspace.active_item(cx).map(|item| item.item_id()), + initial_item_id, + "external documents should not open any workspace items" + ); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_without_take_focus(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let target_path = EditorLspTestContext::root_path() + .join("dir") + .join("target.rs"); + let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake() + .insert_file(&target_path, b"fn target() {}".to_vec()) + .await; + + let initial_editor = cx.editor.clone(); + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: lsp::Uri::from_file_path(&target_path).unwrap(), + external: None, + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + cx.run_until_parked(); + + cx.update_workspace(|workspace, _, cx| { + let opened_editor = workspace + .active_item_as::(cx) + .expect("an editor should be opened for the shown document"); + assert_ne!( + opened_editor.entity_id(), + initial_editor.entity_id(), + "the shown document should become the active item" + ); + }); +} + +#[gpui::test] +async fn test_lsp_show_document_unsupported_uri(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; + + let response = cx + .lsp + .server + .request::( + lsp::ShowDocumentParams { + uri: "untitled:some-document".parse::().unwrap(), + external: None, + take_focus: None, + selection: None, + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: false }); +} diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 163602bf2b..6eda5462dc 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -26,8 +26,11 @@ use language::{ use lsp::DiagnosticSeverity; use multi_buffer::{BufferOffset, MultiBufferOffset, MultiBufferRow, PathKey}; use project::{ - File, Project, ProjectItem as _, ProjectPath, git_store::GitStore, lsp_store::FormatTrigger, - project_settings::ProjectSettings, search::SearchQuery, + File, Project, ProjectItem as _, ProjectPath, + git_store::GitStore, + lsp_store::{FormatTrigger, LanguageServerShowDocumentRequest}, + project_settings::ProjectSettings, + search::SearchQuery, }; use rope::TextSummary; use rpc::proto::{self, update_view}; @@ -43,10 +46,15 @@ use std::{ }; use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _}; use ui::{IconDecorationKind, prelude::*}; -use util::{ResultExt, TryFutureExt, debug_panic, paths::PathExt, rel_path::RelPath}; +use util::{ + ResultExt, TryFutureExt, debug_panic, + paths::{PathExt, UrlExt as _}, + rel_path::RelPath, +}; use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; use workspace::{ - CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, + CollaboratorId, ItemId, ItemNavHistory, OpenOptions, OpenVisible, ToolbarItemLocation, ViewId, + Workspace, WorkspaceId, invalid_item_view::InvalidItemView, item::{FollowableItem, Item, ItemBufferKind, ItemEvent, ProjectItem, SaveOptions}, searchable::{ @@ -2504,6 +2512,68 @@ fn compute_modified_ranges( merged } +pub(crate) fn handle_lsp_show_document( + workspace: &mut Workspace, + request: &LanguageServerShowDocumentRequest, + window: &mut Window, + cx: &mut Context, +) -> Task<()> { + let request = request.clone(); + if request.external { + cx.open_url(request.uri.as_str()); + request.respond(true); + return Task::ready(()); + } + let Ok(abs_path) = request.uri.to_file_path_ext(workspace.path_style(cx)) else { + log::error!( + "language server requested to show document with unsupported uri {}", + request.uri.as_str() + ); + request.respond(false); + return Task::ready(()); + }; + let open_task = workspace.open_abs_path( + abs_path, + OpenOptions { + visible: Some(OpenVisible::None), + focus: Some(request.take_focus), + ..OpenOptions::default() + }, + window, + cx, + ); + cx.spawn_in(window, async move |_, cx| { + let success = match open_task.await { + Ok(item) => match item.downcast::().zip(request.selection) { + Some((editor, selection)) => editor + .update_in(cx, |editor, window, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let range = language::range_from_lsp(selection); + let start = snapshot.point_utf16_to_offset( + snapshot.clip_point_utf16(range.start, Bias::Left), + ); + let end = snapshot.point_utf16_to_offset( + snapshot.clip_point_utf16(range.end, Bias::Left), + ); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |selections| selections.select_ranges([start..end]), + ); + }) + .is_ok(), + None => true, + }, + Err(error) => { + log::error!("failed to show document for a language server: {error:#}"); + false + } + }; + request.respond(success); + }) +} + #[cfg(test)] mod tests { use crate::editor_tests::init_test; diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 9e95b5760a..ece0e2c3d6 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -1141,7 +1141,7 @@ impl LanguageServer { additional_properties_support: Some(true), }), }), - ..WindowClientCapabilities::default() + show_document: Some(ShowDocumentClientCapabilities { support: true }), }), }, trace: None, diff --git a/crates/lsp_command_selector/Cargo.toml b/crates/lsp_command_selector/Cargo.toml new file mode 100644 index 0000000000..ad367a6e9f --- /dev/null +++ b/crates/lsp_command_selector/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "lsp_command_selector" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/lsp_command_selector.rs" +doctest = false + +[dependencies] +editor.workspace = true +fuzzy.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +lsp.workspace = true +picker.workspace = true +project.workspace = true +serde_json.workspace = true +ui.workspace = true +workspace.workspace = true + +[dev-dependencies] +anyhow.workspace = true +editor = { workspace = true, features = ["test-support"] } +futures.workspace = true +menu.workspace = true +gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +util.workspace = true +workspace = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/lsp_command_selector/LICENSE-GPL b/crates/lsp_command_selector/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/lsp_command_selector/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/lsp_command_selector/src/lsp_command_selector.rs b/crates/lsp_command_selector/src/lsp_command_selector.rs new file mode 100644 index 0000000000..f1b053d442 --- /dev/null +++ b/crates/lsp_command_selector/src/lsp_command_selector.rs @@ -0,0 +1,1182 @@ +use std::{ + collections::BTreeSet, + sync::{Arc, atomic::AtomicBool}, +}; + +use editor::Editor; +use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; +use gpui::{ + AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + ParentElement, Render, SharedString, Task, WeakEntity, Window, actions, +}; +use language::Buffer; +use lsp::{LanguageServerId, LanguageServerName}; +use picker::{Picker, PickerDelegate}; +use project::{File, LspStore, Project}; +use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*}; +use workspace::{ModalView, Workspace}; + +actions!(lsp_command_selector, [Toggle, ToggleArgumentsFocus]); + +pub fn init(cx: &mut App) { + cx.observe_new(LspCommandSelector::register).detach(); +} + +pub struct LspCommandSelector { + picker: Entity>, +} + +impl LspCommandSelector { + fn register(workspace: &mut Workspace, _: Option<&mut Window>, _: &mut Context) { + workspace.register_action_renderer(|div, workspace, _, cx| { + let commands_available = active_buffer(workspace, cx).is_some_and(|buffer| { + available_commands(&buffer, workspace.project(), cx) + .next() + .is_some() + }); + if !commands_available { + return div; + } + div.on_action(cx.listener(|workspace, _: &Toggle, window, cx| { + Self::toggle(workspace, window, cx); + })) + }); + } + + fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + let Some(buffer) = active_buffer(workspace, cx) else { + return; + }; + let project = workspace.project(); + let commands = available_commands(&buffer, project, cx).collect::>(); + let lsp_store = project.read(cx).lsp_store(); + workspace.toggle_modal(window, cx, move |window, cx| { + LspCommandSelector::new(lsp_store, commands, window, cx) + }); + } + + fn new( + lsp_store: Entity, + commands: BTreeSet, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let arguments_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text( + "JSON arguments (42, {\"key\": \"value\"}, …) or plain text for one string", + window, + cx, + ); + editor + }); + let delegate = LspCommandSelectorDelegate::new( + cx.entity().downgrade(), + lsp_store, + arguments_editor, + commands, + ); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + Self { picker } + } + + fn toggle_arguments_focus( + &mut self, + _: &ToggleArgumentsFocus, + window: &mut Window, + cx: &mut Context, + ) { + let arguments_focus_handle = self + .picker + .read(cx) + .delegate + .arguments_editor + .focus_handle(cx); + if arguments_focus_handle.contains_focused(window, cx) { + let picker_focus_handle = self.picker.focus_handle(cx); + window.focus(&picker_focus_handle, cx); + } else { + window.focus(&arguments_focus_handle, cx); + } + } +} + +impl Render for LspCommandSelector { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("LspCommandSelector") + .on_action(cx.listener(Self::toggle_arguments_focus)) + .child(self.picker.clone()) + } +} + +impl Focusable for LspCommandSelector { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl EventEmitter for LspCommandSelector {} +impl ModalView for LspCommandSelector {} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct AvailableCommand { + server_name: LanguageServerName, + server_id: LanguageServerId, + command: SharedString, +} + +enum Execution { + Idle, + Running { _task: Task<()> }, + Failed(SharedString), +} + +pub struct LspCommandSelectorDelegate { + selector: WeakEntity, + lsp_store: Entity, + arguments_editor: Entity, + commands: Arc<[AvailableCommand]>, + execution: Execution, + candidates: Arc<[StringMatchCandidate]>, + matches: Vec, + selected_index: usize, +} + +impl LspCommandSelectorDelegate { + fn new( + selector: WeakEntity, + lsp_store: Entity, + arguments_editor: Entity, + commands: BTreeSet, + ) -> Self { + let commands = commands.into_iter().collect::>(); + let candidates = commands + .iter() + .enumerate() + .map(|(index, command)| StringMatchCandidate::new(index, &command.command)) + .collect::>(); + Self { + selector, + lsp_store, + arguments_editor, + commands, + execution: Execution::Idle, + candidates, + matches: Vec::new(), + selected_index: 0, + } + } + + fn is_executing(&self) -> bool { + matches!(self.execution, Execution::Running { .. }) + } + + fn parse_arguments(&self, cx: &App) -> Vec { + let arguments_text = self.arguments_editor.read(cx).text(cx); + let trimmed_arguments = arguments_text.trim(); + if trimmed_arguments.is_empty() { + return Vec::new(); + } + serde_json::Deserializer::from_str(trimmed_arguments) + .into_iter() + .collect::, _>>() + .unwrap_or_else(|_| vec![serde_json::Value::String(trimmed_arguments.to_owned())]) + } +} + +impl PickerDelegate for LspCommandSelectorDelegate { + type ListItem = ListItem; + + fn name() -> &'static str { + "lsp command selector" + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Select a language server command…".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { + !self.is_executing() && self.matches.get(ix).is_some() + } + + fn has_another_open_menu(&self, window: &Window, cx: &App) -> bool { + self.arguments_editor + .focus_handle(cx) + .contains_focused(window, cx) + } + + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + if self.is_executing() { + return; + } + let Some(mat) = self.matches.get(self.selected_index) else { + return; + }; + let Some(command) = self.commands.get(mat.candidate_id).cloned() else { + return; + }; + let arguments = self.parse_arguments(cx); + let task = self.lsp_store.update(cx, |lsp_store, cx| { + lsp_store.execute_lsp_command( + command.server_id, + command.command.to_string(), + arguments, + cx, + ) + }); + self.arguments_editor + .update(cx, |editor, _| editor.set_read_only(true)); + let task = cx.spawn_in(window, async move |picker, cx| { + let execution = match task.await { + Ok(result) => { + if let Some(result) = result { + log::info!("LSP command {} returned {result}", command.command); + } + Execution::Idle + } + Err(error) => { + let error = format!( + "Failed to execute LSP command {}: {error:#}", + command.command + ); + log::error!("{error}"); + Execution::Failed(SharedString::from(error)) + } + }; + picker + .update_in(cx, |picker, window, cx| { + let succeeded = matches!(execution, Execution::Idle); + picker.delegate.execution = execution; + picker + .delegate + .arguments_editor + .update(cx, |editor, _| editor.set_read_only(false)); + cx.notify(); + if succeeded { + picker.delegate.dismissed(window, cx); + } + }) + .ok(); + }); + self.execution = Execution::Running { _task: task }; + cx.notify(); + } + + fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + self.selector.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let background = cx.background_executor().clone(); + let candidates = self.candidates.clone(); + cx.spawn_in(window, async move |picker, cx| { + let matches = if query.is_empty() { + candidates + .iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string.clone(), + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + match_strings( + &candidates, + &query, + false, + true, + 100, + &AtomicBool::new(false), + background, + ) + .await + }; + + picker + .update_in(cx, |picker, window, cx| { + picker.delegate.matches = matches; + picker.set_selected_index(0, None, false, window, cx); + cx.notify(); + }) + .ok(); + }) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + _: &mut Context>, + ) -> Option { + let mat = self.matches.get(ix)?; + let command = self.commands.get(mat.candidate_id)?; + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .disabled(self.is_executing()) + .child(HighlightedLabel::new( + mat.string.clone(), + mat.positions.clone(), + )) + .end_slot( + Label::new(command.server_name.0.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + } + + fn render_footer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + Some( + v_flex() + .p_2() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + h_flex() + .gap_2() + .justify_between() + .child( + Label::new("Arguments") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + h_flex() + .gap_1() + .child(ui::KeyBinding::for_action_in( + &ToggleArgumentsFocus, + &self.arguments_editor.focus_handle(cx), + cx, + )) + .child( + Label::new("to switch focus") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ) + .child(self.arguments_editor.clone()) + .map(|footer| match &self.execution { + Execution::Idle => footer, + Execution::Running { .. } => footer.child( + Label::new("Executing command…") + .size(LabelSize::Small) + .color(Color::Muted), + ), + Execution::Failed(error) => footer.child( + Label::new(error.clone()) + .size(LabelSize::Small) + .color(Color::Error), + ), + }) + .into_any_element(), + ) + } +} + +fn active_buffer(workspace: &Workspace, cx: &App) -> Option> { + let editor = workspace.active_item(cx)?.act_as::(cx)?; + let editor = editor.read(cx); + editor + .buffer() + .read(cx) + .as_singleton() + .or_else(|| editor.active_buffer(cx)) +} + +fn available_commands<'a>( + buffer: &Entity, + project: &Entity, + cx: &'a App, +) -> impl Iterator + 'a { + let project = project.read(cx); + let lsp_store = project.lsp_store().read(cx); + let buffer = buffer.read(cx); + let adapters = buffer + .language() + .map(|language| project.languages().lsp_adapters(&language.name())) + .unwrap_or_default(); + let worktree_owning_servers = File::from_dyn(buffer.file()) + .and_then(|file| project.worktree_for_id(file.worktree_id(cx), cx)) + .map(|worktree| worktree.read(cx)) + .filter(|worktree| worktree.is_visible()) + .map(|worktree| worktree.id()); + let opened_in_servers = lsp_store.language_server_ids_for_opened_buffer(buffer.remote_id()); + project + .language_server_statuses(cx) + .filter(move |(server_id, status)| { + adapters.iter().any(|adapter| adapter.name() == status.name) + && match (opened_in_servers, worktree_owning_servers, status.worktree) { + (Some(server_ids), _, _) => server_ids.contains(server_id), + (None, Some(buffer_worktree), Some(server_worktree)) => { + buffer_worktree == server_worktree + } + (None, _, _) => true, + } + }) + .filter_map(move |(server_id, status)| { + let provider = lsp_store + .lsp_server_capabilities + .get(&server_id)? + .execute_command_provider + .as_ref()?; + Some((server_id, status.name.clone(), &provider.commands)) + }) + .flat_map(|(server_id, server_name, commands)| { + commands.iter().map(move |command| AvailableCommand { + server_name: server_name.clone(), + server_id, + command: SharedString::from(command.clone()), + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::{ + StreamExt as _, + channel::{mpsc::UnboundedReceiver, oneshot}, + }; + use gpui::{KeyBinding, TestAppContext, VisualTestContext}; + use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher}; + use project::{Project, ProjectPath}; + use serde_json::json; + use std::{ + ops::Deref, + path::PathBuf, + sync::{Arc, Mutex}, + }; + use util::{path, rel_path::rel_path}; + use workspace::{AppState, MultiWorkspace, OpenOptions, OpenVisible}; + + #[gpui::test] + async fn test_executing_commands_from_the_picker(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let SelectorTest { + workspace, + fake_server, + executed_commands, + mut cx, + .. + } = init_selector_test(cx).await; + let (response_sender, response_receiver) = oneshot::channel(); + let response_receiver = Arc::new(Mutex::new(Some(response_receiver))); + fake_server.set_request_handler::({ + let executed_commands = executed_commands.clone(); + move |params, _| { + executed_commands + .lock() + .expect("executed commands lock should not be poisoned") + .push((params.command, params.arguments)); + let response_receiver = response_receiver + .lock() + .expect("response receiver lock should not be poisoned") + .take() + .expect("execute command should only be requested once"); + async move { + response_receiver + .await + .expect("response sender should remain alive"); + Ok(None) + } + } + }); + + let picker = open_selector(&workspace, &mut cx); + picker.read_with(&cx, |picker, _| { + assert_eq!( + picker + .delegate + .matches + .iter() + .map(|mat| mat.string.as_str()) + .collect::>(), + vec!["mdo.index", "mdo.today"], + ); + }); + + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(ToggleArgumentsFocus); + cx.run_until_parked(); + picker.update_in(&mut cx, |picker, window, cx| { + picker.delegate.arguments_editor.update(cx, |editor, cx| { + editor.set_text("\"foo\" 42", window, cx); + }); + }); + cx.dispatch_action(menu::Confirm); + assert!( + selector_is_open(&workspace, &cx), + "the selector should remain open until the server responds" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState { + executing: true, + error: None, + arguments_read_only: true, + command_selectable: false, + }, + ); + + cx.dispatch_action(menu::Confirm); + assert_eq!( + executed_commands + .lock() + .expect("executed commands lock should not be poisoned") + .as_slice(), + &[("mdo.today".to_string(), vec![json!("foo"), json!(42)])], + "confirmation while executing should not send another command" + ); + + response_sender + .send(()) + .expect("execute command response receiver should remain alive"); + cx.run_until_parked(); + + assert!( + !selector_is_open(&workspace, &cx), + "the selector should be dismissed after a successful command execution" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState::idle(), + "the dismissed selector should be ready for reopening" + ); + } + + #[gpui::test] + async fn test_command_error_is_shown_in_the_picker_footer(cx: &mut TestAppContext) { + let SelectorTest { + workspace, + fake_server, + mut cx, + .. + } = init_selector_test(cx).await; + fake_server.set_request_handler::( + move |_, _| async move { Err(anyhow::anyhow!("server rejected command")) }, + ); + + let picker = open_selector(&workspace, &mut cx); + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + assert!( + selector_is_open(&workspace, &cx), + "the selector should remain open after a failed command execution" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState { + executing: false, + error: Some( + "Failed to execute LSP command mdo.today: server rejected command".to_string() + ), + arguments_read_only: false, + command_selectable: true, + }, + ); + + fake_server.set_request_handler::( + move |_, _| async move { Ok(None) }, + ); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert!( + !selector_is_open(&workspace, &cx), + "the selector should be dismissed after a successful retry" + ); + assert_eq!( + picker_state(&picker, &mut cx), + PickerState::idle(), + "the error should be cleared and the selector should be ready for reopening" + ); + } + + #[gpui::test] + async fn test_action_is_not_registered_without_commands(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test_with_capabilities( + cx, + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: Vec::new(), + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ) + .await; + + cx.dispatch_action(Toggle); + cx.run_until_parked(); + assert!( + !selector_is_open(&workspace, &cx), + "the selector should not open when no server advertises commands" + ); + } + + #[gpui::test] + async fn test_action_is_not_registered_for_unrelated_buffers(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test(cx).await; + + let worktree_id = workspace.read_with(&cx, |workspace, cx| { + workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .expect("project should have a worktree") + .read(cx) + .id() + }); + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: rel_path("notes.txt").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("file should open"); + cx.run_until_parked(); + + cx.dispatch_action(Toggle); + cx.run_until_parked(); + assert!( + !selector_is_open(&workspace, &cx), + "the selector should not open for a buffer whose language has no matching server, \ + even while another server in the project advertises commands" + ); + } + + #[gpui::test] + async fn test_commands_are_scoped_to_the_servers_of_the_buffer_worktree( + cx: &mut TestAppContext, + ) { + let SelectorTest { + workspace, + fake_server: first_server, + mut fake_servers, + executed_commands: first_server_commands, + mut cx, + } = init_selector_test(cx).await; + let fs = workspace.read_with(&cx, |workspace, cx| { + workspace.project().read(cx).fs().clone() + }); + fs.as_fake() + .insert_tree(path!("/other"), json!({ "other.md": "# other\n" })) + .await; + let (other_worktree, _) = workspace + .update(&mut cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.find_or_create_worktree(path!("/other"), true, cx) + }) + }) + .await + .expect("other worktree should be created"); + let other_worktree_id = other_worktree.read_with(&cx, |worktree, _| worktree.id()); + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id: other_worktree_id, + path: rel_path("other.md").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("file should open"); + let second_server = fake_servers + .next() + .await + .expect("a second server should start for the other worktree"); + let second_server_commands = Arc::new(Mutex::new(Vec::new())); + second_server.set_request_handler::({ + let second_server_commands = second_server_commands.clone(); + move |params, _| { + second_server_commands + .lock() + .unwrap() + .push((params.command, params.arguments)); + async move { Ok(None) } + } + }); + cx.run_until_parked(); + assert_ne!( + first_server.server.server_id(), + second_server.server.server_id(), + "each worktree should get its own server instance" + ); + + let picker = open_selector(&workspace, &mut cx); + picker.read_with(&cx, |picker, _| { + assert_eq!( + picker + .delegate + .commands + .iter() + .map(|command| (command.command.as_ref(), command.server_id)) + .collect::>(), + vec![ + ("mdo.index", second_server.server.server_id()), + ("mdo.today", second_server.server.server_id()), + ], + "only the commands of the server for the active buffer's worktree should be listed" + ); + }); + + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert_eq!( + ( + first_server_commands.lock().unwrap().as_slice(), + second_server_commands.lock().unwrap().as_slice(), + ), + ( + [].as_slice(), + [("mdo.today".to_string(), Vec::new())].as_slice(), + ), + "the command should be executed by the server of the active buffer's worktree only" + ); + } + + #[gpui::test] + async fn test_commands_of_reused_servers_for_buffers_outside_the_project( + cx: &mut TestAppContext, + ) { + let SelectorTest { + workspace, + fake_server, + mut fake_servers, + executed_commands, + mut cx, + } = init_selector_test(cx).await; + let fs = workspace.read_with(&cx, |workspace, cx| { + workspace.project().read(cx).fs().clone() + }); + fs.as_fake() + .insert_tree(path!("/outside"), json!({ "readme.md": "# outside\n" })) + .await; + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/outside/readme.md")), + OpenOptions { + visible: Some(OpenVisible::None), + ..OpenOptions::default() + }, + window, + cx, + ) + }) + .await + .expect("file outside the project should open"); + cx.run_until_parked(); + assert_eq!( + fake_servers.try_recv().ok().map(|_| "a new server"), + None, + "a buffer outside the project should reuse the existing server" + ); + + let picker = open_selector(&workspace, &mut cx); + picker.read_with(&cx, |picker, _| { + assert_eq!( + picker + .delegate + .commands + .iter() + .map(|command| (command.command.as_ref(), command.server_id)) + .collect::>(), + vec![ + ("mdo.index", fake_server.server.server_id()), + ("mdo.today", fake_server.server.server_id()), + ], + "the reused server's commands should be listed for a buffer outside the project" + ); + }); + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("index", window, cx); + }); + cx.run_until_parked(); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert_eq!( + executed_commands.lock().unwrap().as_slice(), + &[("mdo.index".to_string(), Vec::new())], + ); + } + + #[gpui::test] + async fn test_toggling_arguments_focus(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test(cx).await; + + let picker = open_selector(&workspace, &mut cx); + let picker_focus_handle = picker.read_with(&cx, |picker, cx| picker.focus_handle(cx)); + let arguments_focus_handle = picker.read_with(&cx, |picker, cx| { + picker.delegate.arguments_editor.focus_handle(cx) + }); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (true, false), + "the query editor should be focused when the selector opens" + ); + }); + + cx.dispatch_action(ToggleArgumentsFocus); + cx.run_until_parked(); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (false, true), + "the arguments editor should be focused after toggling" + ); + }); + + cx.dispatch_action(ToggleArgumentsFocus); + cx.run_until_parked(); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (true, false), + "the query editor should be focused after toggling again" + ); + }); + } + + #[gpui::test] + async fn test_tab_keystroke_toggles_arguments_focus(cx: &mut TestAppContext) { + let SelectorTest { + workspace, mut cx, .. + } = init_selector_test(cx).await; + cx.update(|_, cx| { + cx.bind_keys([ + KeyBinding::new("tab", menu::SelectNext, None), + KeyBinding::new("tab", editor::actions::Tab, Some("Editor")), + KeyBinding::new("tab", picker::ConfirmCompletion, Some("Picker > Editor")), + KeyBinding::new( + "tab", + ToggleArgumentsFocus, + Some("LspCommandSelector > Picker > Editor"), + ), + ]); + }); + + let picker = open_selector(&workspace, &mut cx); + let picker_focus_handle = picker.read_with(&cx, |picker, cx| picker.focus_handle(cx)); + let arguments_focus_handle = picker.read_with(&cx, |picker, cx| { + picker.delegate.arguments_editor.focus_handle(cx) + }); + + cx.simulate_keystrokes("tab"); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (false, true), + "tab should win over the less specific tab bindings and focus the arguments editor" + ); + }); + + cx.simulate_keystrokes("tab"); + cx.update(|window, _| { + assert_eq!( + ( + picker_focus_handle.is_focused(window), + arguments_focus_handle.is_focused(window), + ), + (true, false), + "tab should focus the query editor back" + ); + }); + } + + #[gpui::test] + async fn test_plain_text_arguments_are_sent_as_a_single_string(cx: &mut TestAppContext) { + let SelectorTest { + workspace, + executed_commands, + mut cx, + .. + } = init_selector_test(cx).await; + + let picker = open_selector(&workspace, &mut cx); + picker.update_in(&mut cx, |picker, window, cx| { + picker.set_query("today", window, cx); + }); + cx.run_until_parked(); + picker.update_in(&mut cx, |picker, window, cx| { + picker.delegate.arguments_editor.update(cx, |editor, cx| { + editor.set_text("next friday", window, cx); + }); + }); + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + + assert!( + !selector_is_open(&workspace, &cx), + "the selector should be dismissed after execution" + ); + assert_eq!( + executed_commands + .lock() + .unwrap() + .drain(..) + .collect::>(), + vec![("mdo.today".to_string(), vec![json!("next friday")])], + "plain text that is not a JSON stream should be sent as a single string argument" + ); + } + + struct SelectorTest { + workspace: Entity, + fake_server: lsp::FakeLanguageServer, + fake_servers: UnboundedReceiver, + executed_commands: Arc)>>>, + cx: VisualTestContext, + } + + async fn init_selector_test(cx: &mut TestAppContext) -> SelectorTest { + init_selector_test_with_capabilities( + cx, + lsp::ServerCapabilities { + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec!["mdo.today".to_string(), "mdo.index".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + ) + .await + } + + async fn init_selector_test_with_capabilities( + cx: &mut TestAppContext, + capabilities: lsp::ServerCapabilities, + ) -> SelectorTest { + zlog::init_test(); + let app_state = cx.update(|cx| { + let app_state = AppState::test(cx); + settings::init(cx); + super::init(cx); + editor::init(cx); + app_state + }); + app_state + .fs + .as_fake() + .insert_tree( + path!("/test"), + json!({ + "main.md": "# hi\n", + "notes.txt": "plain text\n", + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["md".to_string()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }, + None, + ))); + let mut fake_servers = language_registry.register_fake_lsp( + "Markdown", + FakeLspAdapter { + name: "test-server", + capabilities, + ..FakeLspAdapter::default() + }, + ); + + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let multi_workspace = window.root(cx).expect("window should have a root"); + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let cx = &mut cx; + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + + let worktree_id = project.update(cx, |project, cx| { + project + .worktrees(cx) + .next() + .expect("project should have a worktree") + .read(cx) + .id() + }); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + ProjectPath { + worktree_id, + path: rel_path("main.md").into(), + }, + None, + true, + window, + cx, + ) + }) + .await + .expect("file should open"); + + let fake_server = fake_servers.next().await.expect("server should start"); + let executed_commands = Arc::new(Mutex::new(Vec::new())); + fake_server.set_request_handler::({ + let executed_commands = executed_commands.clone(); + move |params, _| { + executed_commands + .lock() + .unwrap() + .push((params.command, params.arguments)); + async move { Ok(None) } + } + }); + cx.run_until_parked(); + + SelectorTest { + workspace, + fake_server, + fake_servers, + executed_commands, + cx: cx.clone(), + } + } + + fn open_selector( + workspace: &Entity, + cx: &mut VisualTestContext, + ) -> Entity> { + cx.dispatch_action(Toggle); + cx.run_until_parked(); + workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .expect("lsp command selector should be open") + .read(cx) + .picker + .clone() + }) + } + + fn selector_is_open(workspace: &Entity, cx: &VisualTestContext) -> bool { + workspace.read_with(cx, |workspace, cx| { + workspace.active_modal::(cx).is_some() + }) + } + + #[derive(Debug, PartialEq)] + struct PickerState { + executing: bool, + error: Option, + arguments_read_only: bool, + command_selectable: bool, + } + + impl PickerState { + fn idle() -> Self { + Self { + executing: false, + error: None, + arguments_read_only: false, + command_selectable: true, + } + } + } + + fn picker_state( + picker: &Entity>, + cx: &mut VisualTestContext, + ) -> PickerState { + picker.update_in(cx, |picker, window, cx| PickerState { + executing: picker.delegate.is_executing(), + error: match &picker.delegate.execution { + Execution::Failed(error) => Some(error.to_string()), + Execution::Idle | Execution::Running { .. } => None, + }, + arguments_read_only: picker.delegate.arguments_editor.read(cx).read_only(cx), + command_selectable: picker.delegate.can_select(0, window, cx), + }) + } +} diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index e140a62a2d..8b980757d5 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -1194,11 +1194,11 @@ impl LocalLspStore { language_server .on_request::({ - let this = lsp_store.clone(); + let lsp_store = lsp_store.clone(); let name = name.to_string(); let adapter = adapter.clone(); move |params, cx| { - let this = this.clone(); + let lsp_store = lsp_store.clone(); let name = name.to_string(); let adapter = adapter.clone(); let mut cx = cx.clone(); @@ -1219,29 +1219,49 @@ impl LocalLspStore { tx, ); - let did_update = this - .update(&mut cx, |_, cx| { - cx.emit(LspStoreEvent::LanguageServerPrompt(request)); - }) - .is_ok(); - if did_update { - let response = rx.recv().await.ok(); - if let Some(ref selected_action) = response { - let context = language::PromptResponseContext { - message, - selected_action: selected_action.clone(), - }; - adapter.process_prompt_response(&context, &mut cx) - } - - Ok(response) - } else { - Ok(None) + lsp_store.update(&mut cx, |_, cx| { + cx.emit(LspStoreEvent::LanguageServerPrompt(request)); + })?; + let response = rx.recv().await.ok(); + if let Some(ref selected_action) = response { + let context = language::PromptResponseContext { + message, + selected_action: selected_action.clone(), + }; + adapter.process_prompt_response(&context, &mut cx) } + + Ok(response) } } }) .detach(); + + language_server + .on_request::({ + let lsp_store = lsp_store.clone(); + move |params, cx| { + let lsp_store = lsp_store.clone(); + let mut cx = cx.clone(); + async move { + let (tx, rx) = async_channel::bounded(1); + let request = LanguageServerShowDocumentRequest { + uri: params.uri, + external: params.external.unwrap_or(false), + take_focus: params.take_focus.unwrap_or(false), + selection: params.selection, + response_channel: tx, + }; + lsp_store.update(&mut cx, |_, cx| { + cx.emit(LspStoreEvent::LanguageServerShowDocument(request)); + })?; + let success = rx.recv().await.unwrap_or(false); + Ok(lsp::ShowDocumentResult { success }) + } + } + }) + .detach(); + language_server .on_notification::({ let this = lsp_store.clone(); @@ -4606,6 +4626,7 @@ pub enum LspStoreEvent { new_language: Option>, }, Notification(String), + LanguageServerShowDocument(LanguageServerShowDocumentRequest), RefreshInlayHints { server_id: LanguageServerId, }, @@ -4757,6 +4778,7 @@ impl LspStore { }); client.add_entity_request_handler(Self::handle_lsp_command::); + client.add_entity_request_handler(Self::handle_execute_lsp_command); client.add_entity_request_handler(Self::handle_lsp_ext_cancel_flycheck); client.add_entity_request_handler(Self::handle_lsp_ext_run_flycheck); client.add_entity_request_handler(Self::handle_lsp_ext_clear_flycheck); @@ -6555,6 +6577,67 @@ impl LspStore { } } + pub fn execute_lsp_command( + &self, + server_id: LanguageServerId, + command: String, + arguments: Vec, + cx: &mut Context, + ) -> Task>> { + if let Some((upstream_client, project_id)) = self.upstream_client() { + let request = upstream_client.request(proto::ExecuteLspCommand { + project_id, + language_server_id: server_id.to_proto(), + command, + arguments: arguments + .iter() + .map(|argument| argument.to_string()) + .collect(), + }); + cx.background_spawn(async move { + let response = request.await?; + response + .result + .map(|result| serde_json::from_str(&result)) + .transpose() + .context("deserializing executeCommand result") + }) + } else if self.mode.is_local() { + let Some(server) = self.language_server_for_id(server_id) else { + return Task::ready(Err(anyhow!("language server {server_id} not found"))); + }; + let available_commands = server + .capabilities() + .execute_command_provider + .as_ref() + .map(|options| options.commands.clone()) + .unwrap_or_default(); + if !available_commands.contains(&command) { + return Task::ready(Err(anyhow!( + "command {command} is not advertised by the language server" + ))); + } + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + cx.background_spawn(async move { + server + .request::( + lsp::ExecuteCommandParams { + command, + arguments, + ..lsp::ExecuteCommandParams::default() + }, + request_timeout, + ) + .await + .into_response() + }) + } else { + Task::ready(Err(anyhow!("no upstream client and not local"))) + } + } + pub fn resolve_code_action( &self, buffer: &Entity, @@ -11403,6 +11486,29 @@ impl LspStore { Ok(proto::Ack {}) } + async fn handle_execute_lsp_command( + lsp_store: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let server_id = LanguageServerId::from_proto(envelope.payload.language_server_id); + let arguments = envelope + .payload + .arguments + .iter() + .map(|argument| serde_json::from_str(argument)) + .collect::, _>>() + .context("deserializing executeCommand arguments")?; + let result = lsp_store + .update(&mut cx, |lsp_store, cx| { + lsp_store.execute_lsp_command(server_id, envelope.payload.command, arguments, cx) + }) + .await?; + Ok(proto::ExecuteLspCommandResponse { + result: result.map(|result| result.to_string()), + }) + } + async fn handle_lsp_ext_run_flycheck( lsp_store: Entity, envelope: TypedEnvelope, @@ -15804,6 +15910,30 @@ impl PartialEq for LanguageServerPromptRequest { } } +#[derive(Clone, Debug)] +pub struct LanguageServerShowDocumentRequest { + pub uri: lsp::Uri, + pub external: bool, + pub take_focus: bool, + pub selection: Option, + pub(crate) response_channel: async_channel::Sender, +} + +impl LanguageServerShowDocumentRequest { + pub fn respond(self, success: bool) { + self.response_channel.try_send(success).ok(); + } +} + +impl PartialEq for LanguageServerShowDocumentRequest { + fn eq(&self, other: &Self) -> bool { + self.uri == other.uri + && self.external == other.external + && self.take_focus == other.take_focus + && self.selection == other.selection + } +} + #[derive(Clone, Debug, PartialEq)] pub enum LanguageServerLogType { Log(MessageType), diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9cdfa00bdf..270446bc52 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -167,8 +167,9 @@ pub use buffer_store::ProjectTransaction; pub use lsp_command::{CallHierarchyItem, IncomingCall, OutgoingCall}; pub use lsp_store::{ DiagnosticSummary, InvalidationStrategy, LanguageServerLogType, LanguageServerProgress, - LanguageServerPromptRequest, LanguageServerStatus, LanguageServerToQuery, LspStore, - LspStoreEvent, ProgressToken, SERVER_PROGRESS_THROTTLE_TIMEOUT, + LanguageServerPromptRequest, LanguageServerShowDocumentRequest, LanguageServerStatus, + LanguageServerToQuery, LspStore, LspStoreEvent, ProgressToken, + SERVER_PROGRESS_THROTTLE_TIMEOUT, }; pub use toolchain_store::{ToolchainStore, Toolchains}; const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500; @@ -363,6 +364,7 @@ pub enum Event { notification_id: SharedString, }, LanguageServerPrompt(LanguageServerPromptRequest), + LanguageServerShowDocument(LanguageServerShowDocumentRequest), LanguageNotFound(Entity), ActiveEntryChanged(Option), ActivateProjectPanel, @@ -1675,6 +1677,8 @@ impl Project { remote_proto.add_entity_message_handler(Self::handle_toast); remote_proto.add_entity_message_handler(Self::handle_telemetry_event); remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request); + remote_proto + .add_entity_request_handler(Self::handle_language_server_show_document_request); remote_proto.add_entity_message_handler(Self::handle_hide_toast); remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server); remote_proto.add_entity_request_handler(Self::handle_trust_worktrees); @@ -3756,6 +3760,9 @@ impl Project { LspStoreEvent::LanguageServerPrompt(prompt) => { cx.emit(Event::LanguageServerPrompt(prompt.clone())) } + LspStoreEvent::LanguageServerShowDocument(request) => { + cx.emit(Event::LanguageServerShowDocument(request.clone())) + } LspStoreEvent::DiskBasedDiagnosticsStarted { language_server_id } => { cx.emit(Event::DiskBasedDiagnosticsStarted { language_server_id: *language_server_id, @@ -5614,6 +5621,49 @@ impl Project { }) } + async fn handle_language_server_show_document_request( + project: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let payload = envelope.payload; + let selection = payload.selection_start.zip(payload.selection_end).map( + |(selection_start, selection_end)| lsp::Range { + start: lsp::Position { + line: selection_start.row, + character: selection_start.column, + }, + end: lsp::Position { + line: selection_end.row, + character: selection_end.column, + }, + }, + ); + let uri = lsp::Uri::from_str(&payload.uri) + .with_context(|| format!("parsing show document uri {}", payload.uri))?; + let (tx, rx) = async_channel::bounded(1); + project.update(&mut cx, |_, cx| { + cx.emit(Event::LanguageServerShowDocument( + LanguageServerShowDocumentRequest { + uri, + external: payload.external, + take_focus: payload.take_focus, + selection, + response_channel: tx, + }, + )); + }); + drop(project); + + let success = rx.recv().await.unwrap_or(false); + anyhow::ensure!( + success, + "show document request for {} was not handled successfully", + payload.uri + ); + Ok(proto::Ack {}) + } + async fn handle_hide_toast( this: Entity, envelope: TypedEnvelope, diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index c60ff7ded0..fd3987e248 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -960,6 +960,26 @@ message LanguageServerPromptResponse { optional uint64 action_response = 1; } +message LanguageServerShowDocumentRequest { + uint64 project_id = 1; + string uri = 2; + bool external = 3; + bool take_focus = 4; + optional PointUtf16 selection_start = 5; + optional PointUtf16 selection_end = 6; +} + +message ExecuteLspCommand { + uint64 project_id = 1; + uint64 language_server_id = 2; + string command = 3; + repeated string arguments = 4; +} + +message ExecuteLspCommandResponse { + optional string result = 1; +} + message GetDocumentColor { uint64 project_id = 1; uint64 buffer_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index e89edf3cde..9b91481458 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -518,7 +518,10 @@ message Envelope { GetOutgoingCalls get_outgoing_calls = 486; GetOutgoingCallsResponse get_outgoing_calls_response = 487; LspExtExpandAbbreviation lsp_ext_expand_abbreviation = 488; - LspExtExpandAbbreviationResponse lsp_ext_expand_abbreviation_response = 489; // current max + LspExtExpandAbbreviationResponse lsp_ext_expand_abbreviation_response = 489; + LanguageServerShowDocumentRequest language_server_show_document_request = 490; + ExecuteLspCommand execute_lsp_command = 491; + ExecuteLspCommandResponse execute_lsp_command_response = 492; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 1fe9f1c6a3..30e9502fe0 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -179,6 +179,7 @@ messages!( (LanguageServerLog, Foreground), (LanguageServerPromptRequest, Foreground), (LanguageServerPromptResponse, Foreground), + (LanguageServerShowDocumentRequest, Background), (LeaveChannelBuffer, Background), (LeaveChannelChat, Foreground), (LeaveProject, Foreground), @@ -203,6 +204,8 @@ messages!( (LspExtSwitchSourceHeaderResponse, Background), (LspExtGoToParentModule, Background), (LspExtGoToParentModuleResponse, Background), + (ExecuteLspCommand, Background), + (ExecuteLspCommandResponse, Background), (LspExtCancelFlycheck, Background), (LspExtRunFlycheck, Background), (LspExtClearFlycheck, Background), @@ -578,6 +581,7 @@ request_messages!( (StopLanguageServers, Ack), (LspExtSwitchSourceHeader, LspExtSwitchSourceHeaderResponse), (LspExtGoToParentModule, LspExtGoToParentModuleResponse), + (ExecuteLspCommand, ExecuteLspCommandResponse), (LspExtCancelFlycheck, Ack), (LspExtRunFlycheck, Ack), (LspExtClearFlycheck, Ack), @@ -589,6 +593,7 @@ request_messages!( (GetPermalinkToLine, GetPermalinkToLineResponse), (FlushBufferedMessages, Ack), (LanguageServerPromptRequest, LanguageServerPromptResponse), + (LanguageServerShowDocumentRequest, Ack), (GitGetBranches, GitBranchesResponse), (UpdateGitBranch, Ack), (ListToolchains, ListToolchainsResponse), @@ -809,6 +814,7 @@ entity_messages!( LspExtRunnables, LspExtSwitchSourceHeader, LspExtGoToParentModule, + ExecuteLspCommand, LspExtCancelFlycheck, LspExtRunFlycheck, LspExtClearFlycheck, @@ -820,6 +826,7 @@ entity_messages!( GetFilePermalink, GetPermalinkToLine, LanguageServerPromptRequest, + LanguageServerShowDocumentRequest, GitGetBranches, UpdateGitBranch, ListToolchains, diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 370368e2a0..6a172ca154 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -479,6 +479,33 @@ impl HeadlessProject { }) .log_err(); } + LspStoreEvent::LanguageServerShowDocument(show_document_request) => { + let request = self + .session + .request(proto::LanguageServerShowDocumentRequest { + project_id: REMOTE_SERVER_PROJECT_ID, + uri: show_document_request.uri.as_str().to_owned(), + external: show_document_request.external, + take_focus: show_document_request.take_focus, + selection_start: show_document_request.selection.map(|selection| { + proto::PointUtf16 { + row: selection.start.line, + column: selection.start.character, + } + }), + selection_end: show_document_request.selection.map(|selection| { + proto::PointUtf16 { + row: selection.end.line, + column: selection.end.character, + } + }), + }); + let show_document_request = show_document_request.clone(); + cx.background_spawn(async move { + show_document_request.respond(request.await.is_ok()); + }) + .detach(); + } LspStoreEvent::LanguageServerPrompt(prompt) => { let request = self.session.request(proto::LanguageServerPromptRequest { project_id: REMOTE_SERVER_PROJECT_ID, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index ca69068650..e7d0273cff 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -4391,6 +4391,242 @@ async fn test_remote_apply_code_action_skips_unadvertised_command( assert_eq!(transaction.0.len(), 0); } +#[gpui::test] +async fn test_remote_lsp_show_document(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": "fn one() -> usize { 1 }", + "other.rs": "fn two() -> usize { 2 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; + + cx.update_entity(&project, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + ..FakeLspAdapter::default() + }, + ) + }); + + let mut fake_lsp = server_cx.update(|cx| { + headless.read(cx).languages.register_fake_lsp_server( + LanguageServerName("rust-analyzer".into()), + lsp::ServerCapabilities::default(), + None, + ) + }); + + 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 shown_uri = lsp::Uri::from_file_path(path!("/code/project1/src/other.rs")).unwrap(); + let shown_selection = lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 6)); + let handled_requests = Arc::new(AtomicUsize::new(0)); + cx.update({ + let project = project.clone(); + let shown_uri = shown_uri.clone(); + let handled_requests = handled_requests.clone(); + move |cx| { + cx.subscribe(&project, move |_, event, _| { + if let project::Event::LanguageServerShowDocument(request) = event { + assert_eq!(request.uri, shown_uri); + assert_eq!(request.selection, Some(shown_selection)); + assert_eq!((request.external, request.take_focus), (false, true)); + handled_requests.fetch_add(1, Ordering::Release); + request.clone().respond(true); + } + }) + .detach(); + } + }); + + let response = fake_lsp + .request::( + lsp::ShowDocumentParams { + uri: shown_uri, + external: None, + take_focus: Some(true), + selection: Some(shown_selection), + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .expect("show document request should not error"); + assert_eq!(response, lsp::ShowDocumentResult { success: true }); + assert_eq!(handled_requests.load(Ordering::Acquire), 1); +} + +#[gpui::test] +async fn test_remote_execute_lsp_command(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": "fn one() -> usize { 1 }" + } + }, + }), + ) + .await; + + let (project, headless) = init_test(&fs, cx, server_cx).await; + + cx.update_entity(&project, |project, _| { + project.languages().register_test_language(LanguageConfig { + name: "Rust".into(), + matcher: (LanguageMatcher { + path_suffixes: vec!["rs".into()], + ..LanguageMatcher::default() + }) + .into(), + ..LanguageConfig::default() + }); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: "rust-analyzer", + ..FakeLspAdapter::default() + }, + ) + }); + + 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!["the-command".to_string()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + Some(Box::new(|fake| { + fake.set_request_handler::( + |params, _| async move { + assert_eq!(params.command, "the-command"); + assert_eq!(params.arguments, vec![json!("foo"), json!(42)]); + Ok(Some(json!({"answer": 3}))) + }, + ); + })), + ) + }); + + 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 = cx.read(|cx| { + project + .read(cx) + .language_server_statuses(cx) + .next() + .expect("a language server should be running") + .0 + }); + + let result = project + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.execute_lsp_command( + server_id, + "the-command".to_string(), + vec![json!("foo"), json!(42)], + cx, + ) + }) + }) + .await + .expect("executing an advertised command should succeed"); + assert_eq!(result, Some(json!({"answer": 3}))); + + let unadvertised = project + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.execute_lsp_command( + server_id, + "unadvertised-command".to_string(), + Vec::new(), + cx, + ) + }) + }) + .await; + assert_eq!( + unadvertised + .err() + .map(|error| format!("{error:#}").contains("not advertised")), + Some(true), + "unadvertised commands should be rejected" + ); +} + #[gpui::test] async fn test_remote_restore_unstaged_hunk_clears_diff( cx: &mut TestAppContext, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index bdb4cc595c..d9562b672b 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -161,6 +161,7 @@ language_selector.workspace = true language_tools.workspace = true languages = { workspace = true, features = ["load-grammars"] } line_ending_selector.workspace = true +lsp_command_selector.workspace = true log.workspace = true lsp_locations.workspace = true markdown.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 4c2395dcd2..67555b9c73 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -762,6 +762,7 @@ fn main() { encoding_selector::init(cx); language_selector::init(cx); line_ending_selector::init(cx); + lsp_command_selector::init(cx); toolchain_selector::init(cx); theme_selector::init(cx); settings_profile_selector::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 2bce5d08b4..c4bf14ed8e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5935,6 +5935,7 @@ mod tests { "language_selector", "welcome", "line_ending_selector", + "lsp_command_selector", "lsp_tool", "markdown", "menu",