diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index e9e7bb3cc..953f878a7 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -290,6 +290,58 @@ pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: host.project(window)?.lsp.execute_command(doc, command).await } +/// Resolves an interactive command without applying edits or executing it. +pub async fn lsp_resolve_command( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + server: ServerId, + command: Command, + answers: Vec, +) -> IdeResult> { + let lsp = host.project(window)?.lsp.clone(); + let cancel = host.begin_request(request); + let result = lsp.resolve_command(doc, server, command, answers, cancel).await; + host.end_request(request); + result +} + +/// Applies an explicitly invoked action after the interactive answers have been validated. +pub async fn lsp_execute_code_action( + host: &Host, + window: &str, + request: RequestId, + doc: DocId, + action: CodeAction, + answers: Vec, +) -> IdeResult> { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let result = async { + if cancel.is_cancelled() { + return Err(ide_core::IdeError::cancelled()); + } + if let Some(edit) = action.edit { + let documents = project.documents.clone(); + tokio::task::spawn_blocking(move || apply_edit(&documents, &edit)) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))??; + } + match action.command { + Some(command) => project + .lsp + .execute_action_command(doc, action.server, command, answers, cancel) + .await + .map(|paths| paths.into_iter().map(ide_core::IpcPath::from).collect()), + None => Ok(Vec::new()), + } + } + .await; + host.end_request(request); + result +} + /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_completion", |c| async move { @@ -374,6 +426,29 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) }); d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await }); d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) }); + d.add("lsp_resolve_command", |c| async move { + lsp_resolve_command( + &c.host, + &c.window, + c.arg("request")?, + c.arg("doc")?, + c.arg("server")?, + c.arg("command")?, + c.arg("answers")?, + ) + .await + }); + d.add("lsp_execute_code_action", |c| async move { + lsp_execute_code_action( + &c.host, + &c.window, + c.arg("request")?, + c.arg("doc")?, + c.arg("action")?, + c.arg("answers")?, + ) + .await + }); d.add("lsp_execute_command", |c| async move { lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await }); diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 55e6e0ff5..2c1d4532b 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -189,9 +189,12 @@ impl ClientHandler { // Convert before calling the hook: it re-enters document sync. let edit = convert::workspace_edit(&mgr.conv(self.encoding()), edit); let applied = match mgr.apply_edit_hook() { - Some(hook) => hook(edit), + Some(hook) => hook(edit.clone()), None => false, }; + if applied { + let _ = mgr.applied_edits.send((self.server.id, edit)); + } let mut reply = json!({ "applied": applied }); if !applied { reply["failureReason"] = json!("edit could not be applied"); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 5f349358f..6d44afeb9 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -169,6 +169,7 @@ pub struct LspManager { servers: Mutex>>, docs: Mutex>, apply_edit_hook: RwLock>, + applied_edits: tokio::sync::broadcast::Sender<(ide_core::ServerId, WorkspaceEdit)>, config_hook: RwLock>, prepare_hook: RwLock>>, servers_hook: RwLock>>, @@ -302,6 +303,7 @@ impl LspManager { servers: Mutex::new(Vec::new()), docs: Mutex::new(HashMap::new()), apply_edit_hook: RwLock::new(None), + applied_edits: tokio::sync::broadcast::channel(64).0, config_hook: RwLock::new(None), prepare_hook: RwLock::new(None), servers_hook: RwLock::new(None), @@ -701,6 +703,9 @@ impl LspManager { "capabilities": caps::client_capabilities(), "trace": "off", }); + if server.name == "gopls" { + params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] }); + } if !init_options.is_null() { params["initializationOptions"] = init_options; } @@ -1550,6 +1555,92 @@ impl LspManager { Ok(()) } + async fn command_target(&self, doc: DocId, server: ide_core::ServerId, name: &str) -> IdeResult { + let snap = self.snapshot(doc)?; + self.ready_targets(&snap.servers, |c| c.commands().iter().any(|c| c == name)) + .await + .into_iter() + .find(|t| t.server.id == server) + .ok_or_else(|| IdeError::unavailable(format!("no language server provides command `{name}`"))) + } + + /// Resolve only after explicit invocation; previews must never ask for input. + pub async fn resolve_command( + &self, + doc: DocId, + server: ide_core::ServerId, + command: Command, + answers: Vec, + cancel: CancellationToken, + ) -> IdeResult> { + let t = self.command_target(doc, server, &command.command).await?; + let interactive = t.server.name == "gopls" + && t.caps() + .raw + .pointer("/experimental/interactiveResolveProvider/kinds") + .and_then(Value::as_array) + .is_some_and(|k| k.iter().any(|v| v == "command")); + if !interactive { + return Ok(None); + } + let params = json!({ "command": command.command, "arguments": command.arguments, "formAnswers": answers }); + let response: Value = self.call(&t, "command/resolve", params, Some(&cancel), LONG_TIMEOUT).await?; + let parse_error = |e: serde_json::Error| IdeError::internal(format!("invalid command form: {e}")); + let fields = response.get("formFields").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])); + for field in fields.as_array().into_iter().flatten() { + if field.pointer("/type/kind").and_then(Value::as_str) != Some("string") { + return Err(IdeError::unavailable("unsupported command input type")); + } + } + Ok(Some(CommandForm { + command: Command { + title: command.title, + command: response + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| IdeError::internal("command form omitted command"))? + .to_owned(), + arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([]))) + .map_err(parse_error)?, + }, + form_fields: serde_json::from_value(fields).map_err(parse_error)?, + form_answers: serde_json::from_value( + response.get("formAnswers").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])), + ) + .map_err(parse_error)?, + })) + } + + /// Execute on the originating server and return files from successfully applied edits. + pub async fn execute_action_command( + &self, + doc: DocId, + server: ide_core::ServerId, + command: Command, + answers: Vec, + cancel: CancellationToken, + ) -> IdeResult> { + let t = self.command_target(doc, server, &command.command).await?; + let open_created = t.server.name == "gopls" && command.command == "gopls.add_test"; + let mut edits = self.applied_edits.subscribe(); + let mut params = json!({ "command": command.command, "arguments": command.arguments }); + if !answers.is_empty() { + params["formAnswers"] = json!(answers); + } + let _: Value = self.call(&t, "workspace/executeCommand", params, Some(&cancel), LONG_TIMEOUT).await?; + let mut created = Vec::new(); + while let Ok((origin, edit)) = edits.try_recv() { + if open_created && origin == server { + for file in edit.files { + if let ide_core::FileEdit::Create { path, .. } = file { + created.push(path.into()); + } + } + } + } + Ok(created) + } + pub async fn prepare_rename(&self, doc: DocId, pos: Pos) -> IdeResult> { let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?; let mut out = Outcome::new(); diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 599460a00..45be5d0c3 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -145,6 +145,34 @@ pub struct Command { pub arguments: Vec, } +/// The string form supported by gopls's experimental interactive command protocol. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FormAnswer { + pub id: String, + pub value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FormField { + pub id: String, + pub description: String, + pub required: bool, + #[serde(default)] + pub default: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CommandForm { + pub command: Command, + pub form_fields: Vec, + pub form_answers: Vec, +} + /// One `textDocument/inlineCompletion` suggestion (ghost text at the caret). #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 8d3074e02..e909af815 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2547,3 +2547,138 @@ async fn external_projects_are_capability_gated_and_follow_lifecycle() { h.mgr.shutdown_all().await; } } + +#[tokio::test(flavor = "multi_thread")] +async fn gopls_interactive_command_validates_before_execution_and_applies_edits() { + let mut caps = incremental_caps(); + caps["experimental"] = json!({"interactiveResolveProvider": {"kinds": ["command"]}}); + caps["executeCommandProvider"] = json!({"commands": ["gopls.implement_interface", "gopls.add_test"]}); + let h = harness(&[("gopls", caps)]); + let (doc, path) = h.open("probe.mk", "type T struct{}\n"); + h.wait_status("gopls", ServerStatus::Ready).await; + let s = h.launcher.state("gopls"); + let server = h.mgr.servers()[0].id; + assert_eq!( + s.requested("initialize")[0]["capabilities"]["experimental"]["interactiveResolve"], + json!({"inputTypes": ["string"]}) + ); + let new_file = h.root.join("probe_test.go"); + let edit = json!({"documentChanges": [{"kind": "create", "uri": uri_of(&new_file)}, { + "textDocument": {"uri": uri_of(&path), "version": 1}, + "edits": [{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, "newText": "// generated\n"}] + }]}); + *s.responder.lock() = Some(Arc::new(move |s, method, p| { + if method == "command/resolve" { + let valid = p["formAnswers"][0]["value"] == "pkg.I"; + return Some(if valid { + json!({"command": p["command"], "arguments": p["arguments"], "formAnswers": p["formAnswers"]}) + } else { + json!({"command": p["command"], "arguments": p["arguments"], "formFields": [{"id": "interface", "description": "Enter interface", "required": true, "type": {"kind": "string"}, "error": if p["formAnswers"].as_array().is_some_and(|a| !a.is_empty()) { "Unknown interface" } else { "" }}]}) + }); + } + if method == "workspace/executeCommand" { + if p["command"] != "gopls.add_test" { + assert_eq!(p["formAnswers"], json!([{"id": "interface", "value": "pkg.I"}])); + } + let reply: Value = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(s.peer().request( + "workspace/applyEdit", + json!({"edit": edit}), + None, + )) + }) + .unwrap(); + assert_eq!(reply, json!({"applied": true})); + return Some(Value::Null); + } + None + })); + let applied = Arc::new(Mutex::new(Vec::new())); + let received = applied.clone(); + h.mgr.set_apply_edit_hook(Box::new(move |edit| { + received.lock().push(edit); + true + })); + let command = Command { + title: "Implement".into(), + command: "gopls.implement_interface".into(), + arguments: vec![json!({"location": 1})], + }; + let token = ide_core::CancellationToken::new; + let initial = h.mgr.resolve_command(doc, server, command.clone(), vec![], token()).await.unwrap().unwrap(); + assert_eq!(initial.form_fields[0].description, "Enter interface"); + let invalid = h + .mgr + .resolve_command( + doc, + server, + command.clone(), + vec![FormAnswer { id: "interface".into(), value: "bad".into() }], + token(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(invalid.form_fields[0].error.as_deref(), Some("Unknown interface")); + assert!(applied.lock().is_empty()); + assert!(s.requested("workspace/executeCommand").is_empty()); + let cancelled = token(); + cancelled.cancel(); + let err = h.mgr.resolve_command(doc, server, command.clone(), vec![], cancelled).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + let cancelled = token(); + cancelled.cancel(); + let err = h.mgr.execute_action_command(doc, server, command.clone(), vec![], cancelled).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + assert!(applied.lock().is_empty()); + assert!(s.requested("workspace/executeCommand").is_empty()); + let validated = h + .mgr + .resolve_command( + doc, + server, + command, + vec![FormAnswer { id: "interface".into(), value: "pkg.I".into() }], + token(), + ) + .await + .unwrap() + .unwrap(); + assert!(validated.form_fields.is_empty()); + h.mgr.execute_action_command(doc, server, validated.command, validated.form_answers, token()).await.unwrap(); + assert_eq!(applied.lock().len(), 1); + let created = h + .mgr + .execute_action_command( + doc, + server, + Command { title: "Test".into(), command: "gopls.add_test".into(), arguments: vec![] }, + vec![], + token(), + ) + .await + .unwrap(); + assert_eq!(created, vec![new_file]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn interactive_capability_is_scoped_to_gopls() { + let h = harness(&[("mock", incremental_caps())]); + let (doc, _) = h.open("probe.mk", "x\n"); + h.wait_status("mock", ServerStatus::Ready).await; + let s = h.launcher.state("mock"); + assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"], json!({})); + let form = h + .mgr + .resolve_command( + doc, + h.mgr.servers()[0].id, + Command { title: "Run".into(), command: "mock.run".into(), arguments: vec![json!(1)] }, + vec![], + ide_core::CancellationToken::new(), + ) + .await + .unwrap(); + assert!(form.is_none()); + assert!(s.requested("command/resolve").is_empty()); +} diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 08c0b4c42..c311cb8eb 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -343,3 +343,30 @@ pub async fn lsp_execute_command( ) -> IdeResult<()> { ide_host::ops::lsp::lsp_execute_command(&state.host, window.label(), doc, command).await } + +#[tauri::command] +#[specta::specta] +pub async fn lsp_resolve_command( + window: Window, + state: State<'_, AppState>, + request: RequestId, + doc: DocId, + server: ServerId, + command: Command, + answers: Vec, +) -> IdeResult> { + ide_host::ops::lsp::lsp_resolve_command(&state.host, window.label(), request, doc, server, command, answers).await +} + +#[tauri::command] +#[specta::specta] +pub async fn lsp_execute_code_action( + window: Window, + state: State<'_, AppState>, + request: RequestId, + doc: DocId, + action: CodeAction, + answers: Vec, +) -> IdeResult> { + ide_host::ops::lsp::lsp_execute_code_action(&state.host, window.label(), request, doc, action, answers).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index cb3785ba1..5111d421b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -222,6 +222,8 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_restart, lsp::apply_workspace_edit, lsp::lsp_execute_command, + lsp::lsp_resolve_command, + lsp::lsp_execute_code_action, vcs::vcs_status, vcs::vcs_roots, vcs::vcs_branch_info, diff --git a/src/bindings.ts b/src/bindings.ts index 9bed62739..a70080cf7 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -424,6 +424,12 @@ export const commands = { * follow-up command. Edits the server requests arrive via `workspace/applyEdit`. */ lspExecuteCommand: (doc: DocId, command: Command) => typedError(__TAURI_INVOKE("lsp_execute_command", { doc, command })), + lspResolveCommand: (request: RequestId, doc: DocId, server: ServerId, command: Command, answers: FormAnswer[]) => typedError<{ + command: Command, + formFields: FormField[], + formAnswers: FormAnswer[], +} | null, IdeError>(__TAURI_INVOKE("lsp_resolve_command", { request, doc, server, command, answers })), + lspExecuteCodeAction: (request: RequestId, doc: DocId, action: CodeAction, answers: FormAnswer[]) => typedError(__TAURI_INVOKE("lsp_execute_code_action", { request, doc, action, answers })), /** Changes of every repository of the project. */ vcsStatus: () => typedError(__TAURI_INVOKE("vcs_status")), /** The project's repositories, the primary one first. */ @@ -1846,6 +1852,12 @@ export type CommandDecl = { category?: string | null, }; +export type CommandForm = { + command: Command, + formFields: FormField[], + formAnswers: FormAnswer[], +}; + export type Commit = { id: string, shortId: string, @@ -3152,6 +3164,20 @@ export type FontFamily = { monospaced: boolean, }; +/** The string form supported by gopls's experimental interactive command protocol. */ +export type FormAnswer = { + id: string, + value: string, +}; + +export type FormField = { + id: string, + description: string, + required: boolean, + default?: string | null, + error?: string | null, +}; + export type FormatOptions = { tabSize: number, insertSpaces: boolean, diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index e114a9b57..44ca77656 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -1,4 +1,5 @@ /** Actions of the LSP editor layer (ids from keymaps/intellij-macos.ts). */ +import { isGenerateAction } from "./generate"; import type { EditorView } from "@codemirror/view"; import { executeAction, registerActions, type Action } from "@/platform/actions"; import { activeEditor, editorsFor, registerSaveParticipant, type TextEditorHandle } from "@/editor/api"; @@ -281,8 +282,7 @@ export function registerLspActions(): () => void { "Code", (v) => runKindAction(v, "", { - title: - /^(generate|implement|override|add (missing|derive|impl|getter|setter|constructor)|create (constructor|getter|setter))/i, + predicate: isGenerateAction, none: "Nothing to generate here", label: "Generate", }), diff --git a/src/editor/lsp/generate.test.ts b/src/editor/lsp/generate.test.ts new file mode 100644 index 000000000..5a214ac86 --- /dev/null +++ b/src/editor/lsp/generate.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { CodeAction } from "@/bindings"; +import { isGenerateAction } from "./generate"; + +const action = (title: string, kind: string | null = null) => ({ title, kind }) as CodeAction; + +describe("Generate action filter", () => { + it.each([ + "Add test for Add", + "Declare missing methods of I", + "Add methods to T to implement an interface...", + "Generate toString()", + "Generate constructor", + "Implement interface 'I'", + "Override methods", + "Add missing members", + "Add derive", + "Add impl", + "Add getter", + "Create constructor", + ])("includes %s", (title) => expect(isGenerateAction(action(title))).toBe(true)); + + it("accepts source.addTest independently of its title and advertised kinds", () => { + expect(isGenerateAction(action("Localized test action", "source.addTest"))).toBe(true); + }); + + it.each([ + "Browse documentation for type T", + "Extract declarations to new file", + "Add import", + "Add test framework", + "Add methods to documentation", + "Declare variable", + "Split package", + ])("excludes %s", (title) => expect(isGenerateAction(action(title))).toBe(false)); +}); diff --git a/src/editor/lsp/generate.ts b/src/editor/lsp/generate.ts new file mode 100644 index 000000000..d170115c5 --- /dev/null +++ b/src/editor/lsp/generate.ts @@ -0,0 +1,9 @@ +import type { CodeAction } from "@/bindings"; + +const GENERATE_TITLE = + /^(generate|implement|override|declare missing methods\b|add (test for\b|methods to .+ implement\b|missing|derive|impl|getter|setter|constructor)|create (constructor|getter|setter))/i; + +/** Query all actions: gopls offers source.addTest without advertising that kind. */ +export function isGenerateAction(action: CodeAction): boolean { + return action.kind === "source.addTest" || GENERATE_TITLE.test(action.title); +} diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index fa39cf0af..07cb7d6dd 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -25,6 +25,7 @@ import { spellingIntentions } from "./spelling"; import { inspectionsAt } from "./diagnostics"; import { SPELLING_INSPECTION } from "@/platform/inspections"; import { editInspectionSettings, intentionControls } from "@/platform/inspectionControls"; +import { runInteractiveCodeAction } from "./interactiveCommand"; import { tx } from "@/platform/i18n"; const codeActionBackoff = featureBackoff(); @@ -152,7 +153,7 @@ export async function applyCodeAction(view: EditorView, action: CodeAction) { } const before = view.state.doc; try { - await lspRequest(() => commands.lspApplyCodeAction(doc.doc, action)); + if (!(await runInteractiveCodeAction(doc.doc, action))) return; } catch (e) { reportLspError(view, e, true); return; @@ -403,6 +404,7 @@ export async function runKindAction( kind: string, opts: { title?: RegExp; + predicate?: (action: CodeAction) => boolean; whole?: boolean; none: string; label: string; @@ -425,6 +427,7 @@ export async function runKindAction( try { if (kind) actions = actions.filter((a) => a.kind?.startsWith(kind) ?? false); if (opts.title) actions = actions.filter((a) => opts.title!.test(a.title)); + if (opts.predicate) actions = actions.filter(opts.predicate); const enabled = actions.filter((a) => !a.disabledReason); if (!enabled.length) { if (opts.fallback && (await opts.fallback("empty"))) return; diff --git a/src/editor/lsp/interactiveCommand.test.tsx b/src/editor/lsp/interactiveCommand.test.tsx new file mode 100644 index 000000000..288fc2ae7 --- /dev/null +++ b/src/editor/lsp/interactiveCommand.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { CodeAction, CommandForm } from "@/bindings"; + +const { resolve, execute, cancel, openFile } = vi.hoisted(() => ({ + resolve: vi.fn(), + execute: vi.fn(), + cancel: vi.fn(async () => {}), + openFile: vi.fn(async () => {}), +})); +vi.mock("@/platform/ipc", async (orig) => { + const actual = await orig(); + return { + ...actual, + isTauri: () => true, + commands: { ...actual.commands, lspResolveCommand: resolve, lspExecuteCodeAction: execute, cancelRequest: cancel }, + }; +}); +vi.mock("@/workbench/contributions", async (orig) => ({ + ...(await orig()), + editorService: () => ({ openFile }), +})); +import { runInteractiveCodeAction } from "./interactiveCommand"; +import { InputDialogsHost } from "@/workbench/inputDialogs"; + +const action: CodeAction = { + title: "Add methods to T to implement an interface...", + kind: "refactor.rewrite.implementInterface", + server: 1, + isPreferred: false, + disabledReason: null, + edit: null, + data: null, + command: { title: "Implement", command: "gopls.implement_interface", arguments: [] }, +}; +const form = (error: string | null = null): CommandForm => ({ + command: action.command!, + formAnswers: [], + formFields: [{ id: "interface", description: "Enter interface name", required: true, default: null, error }], +}); +const ok = (data: T) => ({ status: "ok" as const, data }); +const submit = async (value: string) => { + fireEvent.change(screen.getByRole("textbox"), { target: { value } }); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "OK" }))); +}; + +beforeEach(() => { + vi.clearAllMocks(); + resolve.mockResolvedValue(ok(form())); + execute.mockResolvedValue(ok([])); + render(); +}); +afterEach(cleanup); + +describe("interactive code actions", () => { + it("keeps server errors in the prompt, retries, and executes only validated answers", async () => { + resolve + .mockResolvedValueOnce(ok(form())) + .mockResolvedValueOnce(ok(form("Unknown interface"))) + .mockResolvedValueOnce(ok({ ...form(), formFields: [], formAnswers: [{ id: "interface", value: "pkg.I" }] })); + const result = runInteractiveCodeAction(7, action); + await screen.findByRole("textbox", { name: "Enter interface name" }); + const dialog = screen.getByRole("dialog"); + await submit("bad"); + expect(screen.getByText("Unknown interface")).toBeTruthy(); + expect(screen.getByRole("dialog")).toBe(dialog); + expect(execute).not.toHaveBeenCalled(); + await submit("pkg.I"); + expect(await result).toBe(true); + expect(resolve.mock.calls[1]!.slice(1)).toEqual([7, 1, action.command, [{ id: "interface", value: "bad" }]]); + expect(execute.mock.calls[0]!.slice(1)).toEqual([7, action, [{ id: "interface", value: "pkg.I" }]]); + }); + + it("Cancel executes nothing", async () => { + const result = runInteractiveCodeAction(7, action); + await screen.findByRole("textbox"); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Cancel" }))); + expect(await result).toBe(false); + expect(execute).not.toHaveBeenCalled(); + }); + + it("cancels pending validation and ignores its late response", async () => { + let finish!: (v: ReturnType>) => void; + resolve.mockResolvedValueOnce(ok(form())).mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ); + const result = runInteractiveCodeAction(7, action); + await screen.findByRole("textbox"); + await submit("pkg.I"); + expect(screen.getByRole("button", { name: "OK" }).hasAttribute("disabled")).toBe(true); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Cancel" }))); + expect(await result).toBe(false); + expect(cancel).toHaveBeenCalledWith(resolve.mock.calls[1]![0]); + await act(async () => finish(ok({ ...form(), formFields: [] }))); + expect(execute).not.toHaveBeenCalled(); + }); + + it("opens the created test returned by the applied edit", async () => { + resolve.mockResolvedValue(ok(null)); + execute.mockResolvedValue(ok(["/p/probe_test.go"])); + expect(await runInteractiveCodeAction(7, { ...action, kind: "source.addTest" })).toBe(true); + expect(openFile).toHaveBeenCalledWith("/p/probe_test.go"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("executes other servers without a prompt or form answers", async () => { + resolve.mockResolvedValue(ok(null)); + expect(await runInteractiveCodeAction(7, action)).toBe(true); + expect(execute.mock.calls[0]!.slice(1)).toEqual([7, action, []]); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); +}); diff --git a/src/editor/lsp/interactiveCommand.ts b/src/editor/lsp/interactiveCommand.ts new file mode 100644 index 000000000..71ff9b3e4 --- /dev/null +++ b/src/editor/lsp/interactiveCommand.ts @@ -0,0 +1,84 @@ +import type { CodeAction, CommandForm, FormAnswer, IdeError } from "@/bindings"; +import { commands, IpcError, nextRequestId } from "@/platform/ipc"; +import { editorService } from "@/workbench/contributions"; +import { promptInput } from "@/workbench/inputDialogs"; +import { ask } from "@/workbench/messages"; +import { tx } from "@/platform/i18n"; +import { lspRequest } from "./lsp"; + +type Result = { status: "ok"; data: T } | { status: "error"; error: IdeError }; + +async function request(title: string, run: (id: number) => Promise>, signal?: AbortSignal): Promise { + const id = nextRequestId(); + const dismiss = new AbortController(); + let cancelled = false; + const cancel = () => { + cancelled = true; + void commands.cancelRequest(id).catch(() => {}); + }; + if (signal?.aborted) throw new IpcError({ kind: "cancelled", message: "cancelled" }); + signal?.addEventListener("abort", cancel, { once: true }); + // The input dialog already provides Cancel while validating an answer. + const timer = signal + ? undefined + : setTimeout(() => { + void ask({ title, body: tx("Waiting for language server…"), buttons: [], signal: dismiss.signal }).then((r) => { + if (r.button === "cancel") cancel(); + }); + }, 400); + try { + const result = await lspRequest( + () => run(id), + () => !cancelled, + ); + if (cancelled) throw new IpcError({ kind: "cancelled", message: "cancelled" }); + return result; + } finally { + clearTimeout(timer); + dismiss.abort(); + signal?.removeEventListener("abort", cancel); + } +} + +/** Interactive resolution starts on invocation only, never while rendering a preview. */ +export async function runInteractiveCodeAction(doc: number, selected: CodeAction): Promise { + let action = selected; + if (!action.edit && !action.command) action = await lspRequest(() => commands.lspResolveCodeAction(doc, action)); + let answers: FormAnswer[] = []; + if (action.command) { + let form = await request(action.title, (r) => + commands.lspResolveCommand(r, doc, action.server, action.command!, answers), + ); + while (form?.formFields.length) { + const current: CommandForm = form; + const field = current.formFields[0]!; + let next: CommandForm | null = current; + const value = await promptInput({ + title: field.description, + label: field.description, + initial: current.formAnswers.find((a) => a.id === field.id)?.value ?? field.default ?? "", + error: field.error ?? undefined, + validate: (v) => (field.required && !v.trim() ? tx("Value must not be empty") : null), + submit: async (value, signal) => { + const submitted = current.formAnswers.filter((a) => a.id !== field.id); + submitted.push({ id: field.id, value }); + next = await request( + action.title, + (r) => commands.lspResolveCommand(r, doc, action.server, current.command, submitted), + signal, + ); + return next?.formFields.find((f) => f.id === field.id)?.error ?? null; + }, + }); + if (value === null) return false; + form = next; + } + if (form) { + answers = form.formAnswers; + action = { ...action, command: form.command }; + } + } + const created = await request(action.title, (r) => commands.lspExecuteCodeAction(r, doc, action, answers)); + if (created[0]) await editorService().openFile(created[0]); + return true; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 1b0c2c239..9d644f86e 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5374,5 +5374,6 @@ "Skipped content outside Git repositories: {skippedLabel}": "已跳过 Git 仓库之外的内容:{skippedLabel}", "in {length} repositories": "在 {length} 个仓库中", "No repository in the selection has revision {label}": "所选内容中的仓库均无版本 {label}", - "{repoName} has no revision {label}": "{repoName} 没有版本 {label}" + "{repoName} has no revision {label}": "{repoName} 没有版本 {label}", + "Waiting for language server…": "正在等待语言服务器…" } diff --git a/src/workbench/inputDialogs.tsx b/src/workbench/inputDialogs.tsx index 1afc40a63..6b5b311fa 100644 --- a/src/workbench/inputDialogs.tsx +++ b/src/workbench/inputDialogs.tsx @@ -7,7 +7,7 @@ * * Rendered by `InputDialogsHost` (in `OverlayHost`). */ -import { useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { create } from "zustand"; import { t } from "@/platform/i18n"; import { Dialog, Input, List, T } from "@/ui"; @@ -21,6 +21,10 @@ export interface PromptInputOptions { okLabel?: string; /** An error message for `value`, or null when valid (default: must not be blank). */ validate?: (value: string) => string | null; + /** Validate on submission without closing the dialog; cancelling aborts pending validation. */ + submit?: (value: string, signal: AbortSignal) => Promise; + /** Initial server validation error. */ + error?: string; /** Extra content under the field (hints). */ hint?: ReactNode; } @@ -80,19 +84,50 @@ const notBlank = (v: string) => (v.trim() ? null : "Value must not be empty"); function PromptDialog({ options, done }: { options: PromptInputOptions; done: (v: string | null) => void }) { const [value, setValue] = useState(options.initial ?? ""); const [touched, setTouched] = useState(false); - const error = (options.validate ?? notBlank)(value); - const submit = () => { + const [serverError, setServerError] = useState(options.error ?? null); + const [pending, setPending] = useState(false); + const validation = useRef(null); + useEffect(() => () => validation.current?.abort(), []); + const localError = (options.validate ?? notBlank)(value); + const error = localError ?? serverError; + const close = () => { + validation.current?.abort(); + done(null); + }; + const submit = async () => { setTouched(true); - if (!error) done(value.trim()); + if (localError || validation.current) return; + if (!options.submit) return done(value.trim()); + const controller = new AbortController(); + validation.current = controller; + setPending(true); + try { + const message = await options.submit(value.trim(), controller.signal); + if (controller.signal.aborted) return; + setServerError(message); + if (!message) done(value.trim()); + } catch (e) { + if (!controller.signal.aborted) setServerError(e instanceof Error ? e.message : String(e)); + } finally { + if (!controller.signal.aborted) { + validation.current = null; + setPending(false); + } + } }; return ( done(null)} + onClose={close} width={420} buttons={[ - { label: options.okLabel ?? "OK", role: "default", disabled: !!error, onClick: submit }, + { + label: options.okLabel ?? "OK", + role: "default", + disabled: !!localError || pending, + onClick: () => void submit(), + }, { label: t("common.cancel"), role: "cancel" }, ]} > @@ -100,7 +135,7 @@ function PromptDialog({ options, done }: { options: PromptInputOptions; done: (v className={styles.form} onSubmit={(e) => { e.preventDefault(); - submit(); + void submit(); }} > {options.label && ( @@ -113,13 +148,15 @@ function PromptDialog({ options, done }: { options: PromptInputOptions; done: (v aria-label={options.label ?? options.title} value={value} placeholder={options.placeholder} - invalid={touched && !!error} + disabled={pending} + invalid={(touched || !!serverError) && !!error} onChange={(e) => { setTouched(true); setValue(e.target.value); + setServerError(null); }} /> - {touched && error && ( + {(touched || !!serverError) && error && (
{error}