feat(plugins): let a command ask the user with a picker, an input box or a confirmation (#1779)

Plugin API 1.2 adds `ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm` and `views.reveal`. The dialogs show in the window whose command or event asked and block that call until the user answers; the wait is added to the call's deadline, so it doesn't use up the time budget. They need no capability and return `invalid` without a window (events without a client, rendering, editor providers). Prompts are withdrawn when the call is cancelled or the plugin stops, and reach remote windows through the dispatcher (`plugin_answer_prompt`). `views.reveal` brings one of the plugin's declared views to the front. The Rust SDK and its mock gain `pick`, `pick_many`, `input`, `confirm` and `reveal_view`. All components are rebuilt for the WIT change.

Closes #1768

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-06 23:18:30 +08:00
1 джерело 52eed40fc8
коміт abdbc39d0d
43 files changed
+880 -42

No files matched your search

+75
Переглянути файл
@@ -504,6 +504,81 @@ describe("bridge events", () => {
});
});
describe("plugin prompts", () => {
it("shows a plugin's question and reports the answer or the dismissal", async () => {
const user = userEvent.setup();
const answerPrompt = vi.fn().mockResolvedValue(undefined);
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
render(<InputDialogsHost />);
act(() => {
handlePluginEvent({
type: "promptRequested",
pluginId: "p",
promptId: "1",
prompt: {
kind: "quickPick",
title: "Pick targets",
multiple: true,
items: [
{ key: "a", label: "Alpha", description: null },
{ key: "b", label: "Beta", description: "second" },
],
},
});
});
await user.click(await screen.findByRole("checkbox", { name: "Beta" }));
await user.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("1", ["b"]));
act(() => {
handlePluginEvent({
type: "promptRequested",
pluginId: "p",
promptId: "2",
prompt: { kind: "input", prompt: "Name", value: "Main" },
});
});
await user.click(await screen.findByRole("button", { name: "Cancel" }));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("2", null));
});
it("returns the typed text untrimmed", async () => {
const user = userEvent.setup();
const answerPrompt = vi.fn().mockResolvedValue(undefined);
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
render(<InputDialogsHost />);
act(() => {
handlePluginEvent({
type: "promptRequested",
pluginId: "p",
promptId: "4",
prompt: { kind: "input", prompt: "Name", value: " Main " },
});
});
await user.click(await screen.findByRole("button", { name: "OK" }));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("4", [" Main "]));
});
it("closes a question the plugin gave up on without answering", async () => {
const answerPrompt = vi.fn().mockResolvedValue(undefined);
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
render(<InputDialogsHost />);
act(() => {
handlePluginEvent({
type: "promptRequested",
pluginId: "p",
promptId: "3",
prompt: { kind: "input", prompt: "Name", value: null },
});
});
await screen.findByRole("dialog");
act(() => handlePluginEvent({ type: "promptClosed", promptId: "3" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(answerPrompt).not.toHaveBeenCalled();
});
});
describe("PluginsPage", () => {
it("lists plugins, shows details, toggles and applies themes", async () => {
const user = userEvent.setup();
+6
Переглянути файл
@@ -64,6 +64,8 @@ export interface PluginsBackend {
statusItems(): Promise<PluginStatusItem[]>;
contextKeys(): Promise<PluginContextKey[]>;
cancelTask(taskId: string): Promise<void>;
/** Reports the answer to a plugin's `promptRequested`: keys, `[text]`, anything for OK, `null` when dismissed. */
answerPrompt(promptId: string, answer: string[] | null): Promise<void>;
/** Marketplace plugins: the index read again (`refresh`) or the last one read. */
market(refresh: boolean): Promise<MarketPlugin[]>;
marketUrl(): Promise<string | null>;
@@ -164,6 +166,9 @@ export const tauriPluginsBackend: PluginsBackend = {
cancelTask: async (taskId) => {
await commands.pluginCancelTask(taskId);
},
answerPrompt: async (promptId, answer) => {
await commands.pluginAnswerPrompt(promptId, answer);
},
market: (refresh) => call(commands.pluginMarket(refresh)),
marketUrl: () => commands.pluginMarketUrl(),
setMarketUrl: async (url) => {
@@ -505,6 +510,7 @@ export function createMemoryPluginsBackend(
statusItems: async () => [],
contextKeys: async () => [],
cancelTask: async () => {},
answerPrompt: async () => {},
market: async () => [],
marketUrl: async () => marketUrl,
setMarketUrl: async (url) => {
+48 -3
Переглянути файл
@@ -5,7 +5,7 @@
* batches.
*/
import { create } from "zustand";
import type { EventKind, PluginContextKey, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindings";
import type { EventKind, PluginContextKey, PluginEvent, Prompt, StatusItem, WorkbenchEvent } from "@/bindings";
import { onEditorEvent } from "@/editor/api";
import { executeAction, getAction, onActionExecuted } from "@/platform/actions";
import { clearContext, setContext, type ContextValue } from "@/platform/context";
@@ -14,10 +14,12 @@ import { openExternalUrl } from "@/platform/openUrl";
import { useProject } from "@/platform/project";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
import { maybeEditorService, registerStatusItem } from "@/workbench/contributions";
import { maybeEditorService, registerStatusItem, toolWindowService } from "@/workbench/contributions";
import { pickItems, promptInput } from "@/workbench/inputDialogs";
import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { pluginsBackend } from "./backend";
import { bumpView, deliverViewMessage } from "./views/viewStore";
import { bumpView, deliverViewMessage, pluginToolWindowId } from "./views/viewStore";
import { runPluginCommand, usePlugins } from "./store";
// --- status bar items ---------------------------------------------------------
@@ -292,6 +294,39 @@ function trackWorkbenchEvents(): () => void {
};
}
const openPrompts = new Map<string, AbortController>();
/** The user's answer in the shape `plugin_answer_prompt` takes: `null` when dismissed. */
async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] | null> {
switch (prompt.kind) {
case "quickPick":
return pickItems({ title: prompt.title, items: prompt.items, multiple: prompt.multiple, signal });
case "input": {
const text = await promptInput({
title: prompt.prompt,
initial: prompt.value ?? "",
validate: () => null,
keepWhitespace: true,
signal,
});
return text === null ? null : [text];
}
case "confirm":
return (await confirm({ title: prompt.title, message: prompt.message, ok: "OK", signal })) ? [] : null;
}
}
async function answerPrompt(promptId: string, prompt: Prompt) {
const controller = new AbortController();
openPrompts.set(promptId, controller);
const answer = await askUser(prompt, controller.signal);
openPrompts.delete(promptId);
if (controller.signal.aborted) return;
await pluginsBackend()
.answerPrompt(promptId, answer)
.catch(() => {});
}
/** Handles the runtime UI events; returns whether `e` was one. */
export function handleRuntimeEvent(e: PluginEvent): boolean {
switch (e.type) {
@@ -328,6 +363,16 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
case "openUrl":
void openExternalUrl(e.url);
return true;
case "promptRequested":
void answerPrompt(e.promptId, e.prompt);
return true;
case "promptClosed":
openPrompts.get(e.promptId)?.abort();
openPrompts.delete(e.promptId);
return true;
case "revealView":
toolWindowService().show(pluginToolWindowId(e.pluginId, e.view), true);
return true;
default:
return false;
}
+1 -2
Переглянути файл
@@ -46,6 +46,7 @@ import { usePlugins } from "./store";
import { pluginActionId } from "./points";
import { defineUiPoint } from "./projection";
import { declareStatusItem } from "./runtimeUi";
import { pluginToolWindowId } from "./views/viewStore";
import {
CUSTOM_EDITOR_KIND,
CustomEditor,
@@ -307,8 +308,6 @@ function projectFileIcon(pluginId: string, decl: FileIconDecl): () => void {
};
}
export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`;
function registerView(pluginId: string, decl: ViewDecl, icon: string): () => void {
return registerToolWindow({
id: pluginToolWindowId(pluginId, decl.id),
+3
Переглянути файл
@@ -7,6 +7,9 @@ import { create } from "zustand";
export const viewKey = (pluginId: string, view: string) => `${pluginId}/${view}`;
/** The tool window a plugin's declared view is shown in. */
export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`;
export const useViewRevisions = create<{ revisions: Record<string, number> }>(() => ({ revisions: {} }));
/** The plugin's view content changed: rendered views fetch it again. */