дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
0b88599463
коміт
9e6cb7b93e
70 files changed
+559
-82
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))
|
||||
|
||||
Reference in new issue
Заблокувати користувача