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

+25 -1
Переглянути файл
@@ -1034,6 +1034,11 @@ export const commands = {
pluginContextKeys: () => __TAURI_INVOKE<PluginContextKey[]>("plugin_context_keys"),
/** Cancels a plugin task (`taskStarted` with `cancellable`). */
pluginCancelTask: (taskId: string) => __TAURI_INVOKE<boolean>("plugin_cancel_task", { taskId }),
/**
* Reports the user's answer to a plugin's `promptRequested`: the chosen keys
* (pick), `[text]` (input), anything (confirm: OK), or `None` when dismissed.
*/
pluginAnswerPrompt: (promptId: string, answer: string[] | null) => __TAURI_INVOKE<boolean>("plugin_answer_prompt", { promptId, answer }),
/**
* Content of a plugin view (`doc`: the document of a custom editor; `editor`: for
* a tool window, the window's active editor, as commands get it).
@@ -5661,6 +5666,13 @@ export type PhpSettings = {
phpstanConfig?: string,
};
/** An entry of a plugin's `pick` or `pick-many`. */
export type PickItem = {
key: string,
label: string,
description: string | null,
};
/** A plugin's context key for `when` clauses (`value` is JSON text). */
export type PluginContextKey = {
pluginId: string,
@@ -5719,7 +5731,16 @@ export type PluginEvent =
/** Copy `text` to the system clipboard. */
{ type: "copyToClipboard"; pluginId: string; text: string } |
/** Open `url` (`http(s)://`) in the system browser. */
{ type: "openUrl"; pluginId: string; url: string };
{ type: "openUrl"; pluginId: string; url: string } |
/**
* A command waits for the user's answer: show `prompt`, then report it with
* `plugin_answer_prompt(prompt_id, …)`.
*/
{ type: "promptRequested"; pluginId: string; promptId: string; prompt: Prompt } |
/** The call that asked gave up (cancelled, or its plugin stopped): close the prompt. */
{ type: "promptClosed"; promptId: string } |
/** Bring the tool window of the plugin's view `view` to the front. */
{ type: "revealView"; pluginId: string; view: string };
/** A plugin's own icon (`icon`, `icon-dark` of its manifest): sanitised SVG markup. */
export type PluginIcon = {
@@ -6056,6 +6077,9 @@ export type ProjectStructureSettings = {
excludeFiles?: string,
};
/** What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`). */
export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null } | { kind: "confirm"; title: string; message: string };
export type ProxyKind = "http" | "socks";
export type ProxyMode =
+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. */
+104 -11
Переглянути файл
@@ -10,7 +10,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { create } from "zustand";
import { t } from "@/platform/i18n";
import { Dialog, Input, List, T } from "@/ui";
import { Checkbox, Dialog, Input, List, T } from "@/ui";
import styles from "./inputDialogs.module.css";
export interface PromptInputOptions {
@@ -27,6 +27,10 @@ export interface PromptInputOptions {
error?: string;
/** Extra content under the field (hints). */
hint?: ReactNode;
/** Closes the dialog as cancelled when aborted. */
signal?: AbortSignal;
/** Resolve with the text as typed instead of trimmed. */
keepWhitespace?: boolean;
}
export interface ChooseItemOptions {
@@ -36,32 +40,53 @@ export interface ChooseItemOptions {
empty?: string;
}
export interface PickItemsOptions {
title: string;
items: readonly { key: string; label: string; description?: string | null }[];
multiple: boolean;
/** Closes the dialog as cancelled when aborted. */
signal?: AbortSignal;
}
type Request =
| { kind: "prompt"; id: number; options: PromptInputOptions; resolve: (v: string | null) => void }
| { kind: "choose"; id: number; options: ChooseItemOptions; resolve: (v: string | null) => void };
| { kind: "choose"; id: number; options: ChooseItemOptions; resolve: (v: string | null) => void }
| { kind: "pick"; id: number; options: PickItemsOptions; resolve: (v: string[] | null) => void };
const useInputDialogs = create<{ stack: Request[] }>(() => ({ stack: [] }));
let seq = 0;
function open(make: (id: number, finish: (v: string | null) => void) => Request): Promise<string | null> {
function open<T>(
make: (id: number, finish: (v: T | null) => void) => Request,
signal?: AbortSignal,
): Promise<T | null> {
return new Promise((resolve) => {
if (signal?.aborted) return resolve(null);
const id = ++seq;
const finish = (v: string | null) => {
const finish = (v: T | null) => {
signal?.removeEventListener("abort", cancel);
useInputDialogs.setState((s) => ({ stack: s.stack.filter((r) => r.id !== id) }));
resolve(v);
};
useInputDialogs.setState((s) => ({ stack: [...s.stack, make(id, finish)] }));
const cancel = () => finish(null);
signal?.addEventListener("abort", cancel);
useInputDialogs.setState((s) => ({ stack: [...s.stack, make(id, finish as never)] }));
});
}
/** A text field dialog; resolves with the trimmed value, or null when cancelled. */
export function promptInput(options: PromptInputOptions): Promise<string | null> {
return open((id, resolve) => ({ kind: "prompt", id, options, resolve }));
return open<string>((id, resolve) => ({ kind: "prompt", id, options, resolve }), options.signal);
}
/** A list to pick one entry from; resolves with it, or null when cancelled. */
export function chooseItem(options: ChooseItemOptions): Promise<string | null> {
return open((id, resolve) => ({ kind: "choose", id, options, resolve }));
return open<string>((id, resolve) => ({ kind: "choose", id, options, resolve }));
}
/** A list to pick one or more entries from; resolves with their keys, or null when cancelled. */
export function pickItems(options: PickItemsOptions): Promise<string[] | null> {
return open<string[]>((id, resolve) => ({ kind: "pick", id, options, resolve }), options.signal);
}
export function InputDialogsHost() {
@@ -71,8 +96,10 @@ export function InputDialogsHost() {
{stack.map((r) =>
r.kind === "prompt" ? (
<PromptDialog key={r.id} options={r.options} done={r.resolve} />
) : (
) : r.kind === "choose" ? (
<ChooseDialog key={r.id} options={r.options} done={r.resolve} />
) : (
<PickDialog key={r.id} options={r.options} done={r.resolve} />
),
)}
</>
@@ -97,15 +124,16 @@ function PromptDialog({ options, done }: { options: PromptInputOptions; done: (v
const submit = async () => {
setTouched(true);
if (localError || validation.current) return;
if (!options.submit) return done(value.trim());
const result = options.keepWhitespace ? value : value.trim();
if (!options.submit) return done(result);
const controller = new AbortController();
validation.current = controller;
setPending(true);
try {
const message = await options.submit(value.trim(), controller.signal);
const message = await options.submit(result, controller.signal);
if (controller.signal.aborted) return;
setServerError(message);
if (!message) done(value.trim());
if (!message) done(result);
} catch (e) {
if (!controller.signal.aborted) setServerError(e instanceof Error ? e.message : String(e));
} finally {
@@ -202,3 +230,68 @@ function ChooseDialog({ options, done }: { options: ChooseItemOptions; done: (v:
</Dialog>
);
}
function PickDialog({ options, done }: { options: PickItemsOptions; done: (v: string[] | null) => void }) {
const [active, setActive] = useState(0);
const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
const toggle = (key: string) =>
setChecked((prev) => {
const next = new Set(prev);
if (!next.delete(key)) next.add(key);
return next;
});
const picked = options.multiple ? options.items.filter((i) => checked.has(i.key)).map((i) => i.key) : [];
const activeKey = options.items[active]?.key;
const confirm = () => {
if (options.multiple) return done(picked);
if (activeKey !== undefined) done([activeKey]);
};
return (
<Dialog
open
title={options.title}
onClose={() => done(null)}
width={420}
buttons={[
{
label: "OK",
role: "default",
disabled: options.multiple ? false : activeKey === undefined,
onClick: confirm,
},
{ label: t("common.cancel"), role: "cancel" },
]}
>
<div className={styles.form}>
<List
items={options.items}
getKey={(item) => item.key}
activeIndex={active}
onActiveChange={setActive}
onActivate={(item, _index, e) => {
if (!options.multiple) return done([item.key]);
if (e.type === "keydown") toggle(item.key);
}}
rowHeight={options.items.some((i) => i.description) ? 36 : 24}
className={styles.list}
aria-label={options.title}
empty={
<div className={styles.hint}>
<T>Nothing to choose from</T>
</div>
}
renderItem={(item, state) => (
<div className={state.active ? styles.activeRow : styles.row}>
{options.multiple ? (
<Checkbox checked={checked.has(item.key)} onChange={() => toggle(item.key)} label={item.label} />
) : (
item.label
)}
{item.description && <div className={styles.hint}>{item.description}</div>}
</div>
)}
/>
</div>
</Dialog>
);
}
+3
Переглянути файл
@@ -81,6 +81,8 @@ export interface ConfirmOptions {
checkbox?: string;
/** Initial state of `checkbox`. */
checked?: boolean;
/** Closes the dialog as cancelled when aborted. */
signal?: AbortSignal;
}
/** OK / Cancel question; resolves `null` when cancelled, else the checkbox state. */
@@ -92,6 +94,7 @@ export async function confirmChecked(opts: ConfirmOptions): Promise<{ checked: b
checkbox: opts.checkbox,
checked: opts.checked,
buttons: [{ id: "ok", label: opts.ok, primary: true }],
signal: opts.signal,
});
return r.button === "ok" ? { checked: r.checked } : null;
}