дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
1 джерело
c938d712b5
коміт
2c3eabb786
16 files changed
+686
-14
No files matched your search
@@ -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
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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…": "正在等待语言服务器…"
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Заблокувати користувача