дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +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
@@ -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. */
|
||||
|
||||
Reference in new issue
Заблокувати користувача