feat(go): support interactive Generate actions (#632)

Go's test generation and interface stubs were reachable only through context actions, because Generate filtered their titles, and implementing an interface on a plain struct wasn't available since the client didn't support gopls's interactive commands.

Generate and the editor's context menu now list `Add test for ...`, `Declare missing methods ...` and `Add methods to ... implement ...`, and a generated test file opens in the editor. For gopls only, the client advertises `interactiveResolve` and runs its `command/resolve` handshake: a prompt titled from the server asks for the interface, shows validation errors in place, and Cancel writes nothing. Requests are cancellable and other servers and existing commands are unchanged.

Fixes #620
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 10:27:36 +08:00
1 джерело c938d712b5
коміт 2c3eabb786
16 files changed
+686 -14

No files matched your search

+75
Переглянути файл
@@ -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<FormAnswer>,
) -> IdeResult<Option<CommandForm>> {
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<FormAnswer>,
) -> IdeResult<Vec<ide_core::IpcPath>> {
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
});
+4 -1
Переглянути файл
@@ -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");
+91
Переглянути файл
@@ -169,6 +169,7 @@ pub struct LspManager {
servers: Mutex<Vec<Arc<Server>>>,
docs: Mutex<HashMap<DocId, DocState>>,
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
applied_edits: tokio::sync::broadcast::Sender<(ide_core::ServerId, WorkspaceEdit)>,
config_hook: RwLock<Option<ConfigHook>>,
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
servers_hook: RwLock<Option<Arc<ServersFn>>>,
@@ -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<Target> {
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<FormAnswer>,
cancel: CancellationToken,
) -> IdeResult<Option<CommandForm>> {
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<FormAnswer>,
cancel: CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
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<Option<Range>> {
let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?;
let mut out = Outcome::new();
+28
Переглянути файл
@@ -145,6 +145,34 @@ pub struct Command {
pub arguments: Vec<serde_json::Value>,
}
/// 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<String>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CommandForm {
pub command: Command,
pub form_fields: Vec<FormField>,
pub form_answers: Vec<FormAnswer>,
}
/// One `textDocument/inlineCompletion` suggestion (ghost text at the caret).
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
+135
Переглянути файл
@@ -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());
}
+27
Переглянути файл
@@ -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<FormAnswer>,
) -> IdeResult<Option<CommandForm>> {
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<FormAnswer>,
) -> IdeResult<Vec<ide_core::IpcPath>> {
ide_host::ops::lsp::lsp_execute_code_action(&state.host, window.label(), request, doc, action, answers).await
}
+2
Переглянути файл
@@ -222,6 +222,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+26
Переглянути файл
@@ -424,6 +424,12 @@ export const commands = {
* follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
*/
lspExecuteCommand: (doc: DocId, command: Command) => typedError<null, IdeError>(__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<IpcPath[], IdeError>(__TAURI_INVOKE("lsp_execute_code_action", { request, doc, action, answers })),
/** Changes of every repository of the project. */
vcsStatus: () => typedError<FileStatus[], IdeError>(__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,
+2 -2
Переглянути файл
@@ -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",
}),
+36
Переглянути файл
@@ -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));
});
+9
Переглянути файл
@@ -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);
}
+4 -1
Переглянути файл
@@ -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;
+115
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
return {
...actual,
isTauri: () => true,
commands: { ...actual.commands, lspResolveCommand: resolve, lspExecuteCodeAction: execute, cancelRequest: cancel },
};
});
vi.mock("@/workbench/contributions", async (orig) => ({
...(await orig<typeof import("@/workbench/contributions")>()),
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 = <T,>(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(<InputDialogsHost />);
});
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<typeof ok<CommandForm>>) => 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());
});
});
+84
Переглянути файл
@@ -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<T> = { status: "ok"; data: T } | { status: "error"; error: IdeError };
async function request<T>(title: string, run: (id: number) => Promise<Result<T>>, signal?: AbortSignal): Promise<T> {
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<boolean> {
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;
}
+2 -1
Переглянути файл
@@ -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…": "正在等待语言服务器…"
}
+46 -9
Переглянути файл
@@ -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<string | null>;
/** 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<AbortController | null>(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 (
<Dialog
open
title={options.title}
onClose={() => 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 && (
<div className={styles.error}>
<T>{error}</T>
</div>