feat(plugins): allow prompts while resolving code actions (#2025)

Closes #1945

Resolving a lazy code action or file action dropped the invoking window, so a plugin couldn't ask the user anything (input, pick, confirm) while building its edit. Resolution now runs with that window, so prompts work during resolution; previews and Actions on Save never prompt, cancellation keeps the text unchanged, and stale edits are rejected. WIT and SDK docs describe the new behaviour; the released API surface is unchanged.

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-08 02:26:26 +08:00
1 джерело 0b88599463
коміт 9e6cb7b93e
70 files changed
+559 -82

No files matched your search

+50 -1
Переглянути файл
@@ -23,13 +23,20 @@ const resolved: CodeAction = {
data: null,
server: 1,
};
const lspCodeActions = vi.fn();
const lspApplyCodeAction = vi.fn();
const lspResolveCodeAction = vi.fn(async () => ({ status: "ok" as const, data: resolved }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspResolveCodeAction } };
return {
...actual,
isTauri: () => true,
commands: { ...actual.commands, lspResolveCodeAction, lspCodeActions, lspApplyCodeAction },
};
});
const {
applyKindSilently,
bulbLine,
changedLines,
indentUnderBrace,
@@ -115,7 +122,49 @@ describe("inspection controls in ⌥↵ (issue #42)", () => {
);
});
describe("silent code actions", () => {
it("does not run lazy plugin actions that could prompt during Actions on Save", async () => {
const view = new EditorView({
state: EditorState.create({
doc: "f(1);",
extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })],
}),
});
views.push(view);
lspCodeActions.mockResolvedValueOnce({
status: "ok",
data: [{ ...resolved, kind: "source.fixAll", server: 0, edit: null }],
});
expect(await applyKindSilently(view, "source.fixAll")).toBe(false);
expect(lspApplyCodeAction).not.toHaveBeenCalled();
expect(lspResolveCodeAction).not.toHaveBeenCalled();
});
});
describe("intention preview", () => {
it("does not resolve lazy plugin actions while previewing", async () => {
const view = new EditorView({
state: EditorState.create({
doc: "f(1);",
extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })],
}),
});
views.push(view);
const cache = new Map<CodeAction, Promise<CodeAction>>();
const preview = await intentionPreview(
view,
{ ...resolved, server: 0, edit: null },
cache,
new AbortController().signal,
);
expect(preview).toEqual({ kind: "text", text: "Preview isn't available for this action" });
expect(lspResolveCodeAction).not.toHaveBeenCalled();
expect(cache.size).toBe(0);
expect(await intentionPreview(view, { ...resolved, server: 0 }, cache, new AbortController().signal)).toMatchObject(
{ kind: "diff" },
);
});
it("resolves the action once and shows its diff for this file", async () => {
const view = new EditorView({
state: EditorState.create({
+11 -1
Переглянути файл
@@ -281,7 +281,9 @@ export async function applyKindSilently(view: EditorView, kind: string): Promise
if (!doc || doc.readOnly) return false;
let actions: CodeAction[];
try {
actions = (await fetchActions(view, [kind], true)).filter((a) => a.kind?.startsWith(kind) && !a.disabledReason);
actions = (await fetchActions(view, [kind], true)).filter(
(a) => a.kind?.startsWith(kind) && !a.disabledReason && !isLazyPluginAction(a),
);
} catch {
return false;
}
@@ -323,6 +325,10 @@ function actionItems(
}));
}
function isLazyPluginAction(action: CodeAction): boolean {
return action.server === 0 && !action.edit && !action.command;
}
/** The action with its edit (resolved once per popup; servers may compute edits lazily). */
function resolveOnce(
doc: number,
@@ -350,6 +356,10 @@ export async function intentionPreview(
): Promise<ListPreview | null> {
const ctx = docOf(view);
if (!ctx) return null;
// Lazy plugin actions can ask the user; resolve only after invocation.
if (isLazyPluginAction(action)) {
return { kind: "text", text: "Preview isn't available for this action" };
}
const doc = view.state.doc;
const full = await resolveOnce(ctx.doc, action, cache);
if (signal.aborted || view.state.doc !== doc) return null;
+36 -2
Переглянути файл
@@ -2,8 +2,9 @@ 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(() => ({
const { resolve, resolveAction, execute, cancel, openFile } = vi.hoisted(() => ({
resolve: vi.fn(),
resolveAction: vi.fn(),
execute: vi.fn(),
cancel: vi.fn(async () => {}),
openFile: vi.fn(async () => {}),
@@ -13,7 +14,13 @@ vi.mock("@/platform/ipc", async (orig) => {
return {
...actual,
isTauri: () => true,
commands: { ...actual.commands, lspResolveCommand: resolve, lspExecuteCodeAction: execute, cancelRequest: cancel },
commands: {
...actual.commands,
lspResolveCommand: resolve,
lspResolveCodeAction: resolveAction,
lspExecuteCodeAction: execute,
cancelRequest: cancel,
},
};
});
vi.mock("@/workbench/contributions", async (orig) => ({
@@ -53,6 +60,33 @@ beforeEach(() => {
afterEach(cleanup);
describe("interactive code actions", () => {
it("waits for a lazy plugin action to finish prompting before applying its resolved edit", async () => {
let finish!: (value: ReturnType<typeof ok<CodeAction>>) => void;
resolveAction.mockImplementationOnce(
() =>
new Promise((r) => {
finish = r;
}),
);
const lazy: CodeAction = { ...action, server: 0, command: null, data: "input" };
const result = runInteractiveCodeAction(7, lazy);
await waitFor(() => expect(resolveAction).toHaveBeenCalledWith(7, lazy));
expect(execute).not.toHaveBeenCalled();
const full: CodeAction = { ...lazy, edit: { files: [] } };
await act(async () => finish(ok(full)));
expect(await result).toBe(true);
expect(execute.mock.calls[0]!.slice(1)).toEqual([7, full, []]);
expect(resolve).not.toHaveBeenCalled();
});
it("does not execute an action whose plugin prompt was dismissed", async () => {
resolveAction.mockResolvedValueOnce({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
await expect(runInteractiveCodeAction(7, { ...action, server: 0, command: null })).rejects.toMatchObject({
kind: "cancelled",
});
expect(execute).not.toHaveBeenCalled();
});
it("keeps server errors in the prompt, retries, and executes only validated answers", async () => {
resolve
.mockResolvedValueOnce(ok(form()))