дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
Цей коміт міститься в:
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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Заблокувати користувача