feat(plugins): run declared commands from plugin workbench UI

This commit is contained in:
Jinpy
2026-09-30 01:21:11 +08:00
committed by GitHub
parent 7bc85dfcb0
commit 21102f4810
10 changed files with 190 additions and 7 deletions
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
readPluginLocalFileChunk: vi.fn(),
writePluginLocalFileChunk: vi.fn(),
closePluginLocalFile: vi.fn(),
openPluginWorkbench: vi.fn(),
}));
vi.mock("@/lib/backend/tauri", () => ({
@@ -41,6 +42,7 @@ vi.mock("@/stores/connectionStore", () => ({
reopenPluginConnection: mocks.reopenPluginConnection,
}),
}));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openPluginWorkbench: mocks.openPluginWorkbench }) }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) }));
import PluginWorkbenchHost from "./PluginWorkbenchHost.vue";
@@ -20,6 +20,8 @@ import {
} from "@/lib/plugins/pluginHostBridge";
import { getCachedPluginUiHtml, getOrLoadPluginUiHtml } from "@/lib/plugins/pluginUiHtmlCache";
import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
import { downloadPluginFile, cancelPluginDownload } from "@/lib/plugins/pluginFileDownload";
import type { InstalledPlugin, PluginUiContribution } from "@/types/database";
import { useI18n } from "vue-i18n";
@@ -27,6 +29,7 @@ import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { createPluginAiCompletion } from "@/lib/plugins/pluginAiCompletion";
import { useQueryStore } from "@/stores/queryStore";
import { OPEN_PLUGIN_AI_CONVERSATION } from "@/lib/ai/aiPluginConversation";
const props = withDefaults(
@@ -400,6 +403,12 @@ function createBridge() {
...(isTauriRuntime() ? aiCompletion : {}),
setAiRecommendations: (update) => emit("recommendations", update),
openWorkbench: async (pluginId, contributionId, context, options) => emit("openWorkbench", pluginId, contributionId, context, options),
// §4/§5 bridge command execution, scoped to this plugin's own manifest:
// the single-plugin registry is equivalent here because findCommand /
// findWorkbench / enablement never cross plugins. Panel commands dock,
// tab commands open tabs, §4.1 reuse with `instance_key` placeholder
// scoping — identical to menu execution.
executeCommand: (pluginId, commandId, context) => executePluginCommand(createFrontendPluginRegistry([props.plugin], appLocale.value), useQueryStore(), pluginId, commandId, context),
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId),
// PR-A4 generic extension point: a read-only, secret-free, plugin-scoped connection list (for in-panel connection switching).
@@ -77,6 +77,26 @@ export function interpolatePluginAiRecommendation(item: PluginAiRecommendation,
};
}
/**
* Shared `{{path}}` interpolation (same prototype-safe resolution as
* recommendations) for non-recommendation templates such as command
* `instance_key` scoping. Returns null when any placeholder cannot be
* resolved to a scalar — callers decide the fallback (recommendations drop
* the item; instance keys fall back to the literal template).
*/
export function interpolatePluginTemplate(value: string, context: Record<string, unknown>): string | null {
let unresolved = false;
const interpolated = value.replace(PLACEHOLDER, (_match, path: string) => {
const resolved = contextValueText(readContextPath(context, path));
if (resolved === undefined) {
unresolved = true;
return "";
}
return resolved;
});
return unresolved ? null : interpolated;
}
/**
* Resolve, order, and cap a list of recommendations for a context.
* The input is never mutated and the result contains fresh objects.
@@ -225,4 +225,51 @@ describe("plugin command registry (PR-A4)", () => {
expect(registry.findCommandTargetingWorkbench("io.dbx.ssh", "other.workbench")).toBeUndefined();
expect(registry.findCommandTargetingWorkbench("other.plugin", "io.dbx.ssh.workbench")).toBeUndefined();
});
// §4.1 instance_key placeholder scoping: one panel instance per resolved
// connection, re-activation per key, literal fallback when unresolvable.
it("scopes panel instances by instance_key placeholders from the execution context", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("io.dbx.ldap", [
{ type: "workbench", id: "io.dbx.ldap.logpanel", label: "Logs" },
{ type: "command", id: "open-conn-logs", label: "Connection logs", action: { type: "open-workbench", workbench: "io.dbx.ldap.logpanel", presentation: "panel", reuse: "singleton", instance_key: "logs:{{connectionId}}" } },
] as unknown as InstalledPlugin["manifest"]["contributions"]),
]);
const openPluginWorkbench = vi.fn();
const { entries, activeEntryId } = usePluginBottomDock();
executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ldap", "open-conn-logs", { connectionId: "conn-a" });
executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ldap", "open-conn-logs", { connectionId: "conn-b" });
expect(entries.value.map((entry) => entry.instanceKey)).toEqual(["logs:conn-a", "logs:conn-b"]);
// the same connection re-activates its own instance; caller-only extras do not fork it
executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ldap", "open-conn-logs", { connectionId: "conn-a", caller: "only" });
expect(entries.value).toHaveLength(2);
expect(activeEntryId.value).toBe(entries.value[0]!.id);
// unresolvable template (no context) falls back to the literal key
executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ldap", "open-conn-logs");
expect(entries.value).toHaveLength(3);
expect(entries.value[2]!.instanceKey).toBe("logs:{{connectionId}}");
expect(openPluginWorkbench).not.toHaveBeenCalled();
});
it("merges caller context over the command context and keeps reserved fields host-owned", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("io.dbx.ldap", [
{ type: "workbench", id: "io.dbx.ldap.logpanel", label: "Logs" },
{ type: "command", id: "open-logs", label: "Logs", action: { type: "open-workbench", workbench: "io.dbx.ldap.logpanel", presentation: "panel", context: { plugin: { mode: "logs" }, from: "command" } } },
] as unknown as InstalledPlugin["manifest"]["contributions"]),
]);
const { entries } = usePluginBottomDock();
executePluginCommand(registry, { openPluginWorkbench: vi.fn() } as never, "io.dbx.ldap", "open-logs", { plugin: { mode: "override" }, connectionId: "c1", workbenchId: "plugin-forged", restored: true, surface: "tab" });
const entry = entries.value[0]!;
expect(entry.context.plugin).toEqual({ mode: "override" });
expect(entry.context.from).toBe("command");
expect(entry.context.connectionId).toBe("c1");
// §11: reserved identity fields are host-authored — forged values never pass.
expect(entry.context.workbenchId).toBe(entry.id);
expect(entry.context.restored).toBe(false);
expect(entry.context.surface).toBe("panel");
});
});
@@ -11,6 +11,7 @@ import * as api from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import { activatePluginDockEntry, addPluginDockEntry, findReusableDockEntry } from "@/lib/plugins/pluginBottomDock";
import { createFrontendPluginRegistry, evaluatePluginCommandConditions, type FrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import { interpolatePluginTemplate } from "@/lib/plugins/pluginAiRecommendations";
import { useQueryStore } from "@/stores/queryStore";
import type { PluginCommandContribution, PluginConditionContextKeys } from "@/types/database";
@@ -31,7 +32,7 @@ export interface PluginCommandExecutionResult {
* §5.4 re-validate enablement right before execution (against the current context snapshot; the surface key follows
* presentation).
*/
export function executePluginCommand(registry: FrontendPluginRegistry, queryStore: ReturnType<typeof useQueryStore>, pluginId: string, commandId: string): PluginCommandExecutionResult {
export function executePluginCommand(registry: FrontendPluginRegistry, queryStore: ReturnType<typeof useQueryStore>, pluginId: string, commandId: string, callerContext?: Record<string, unknown>): PluginCommandExecutionResult {
const command = registry.findCommand(pluginId, commandId)?.contribution;
if (!command) return { error: `Unknown command '${pluginId}.${commandId}'` };
const action = command.action;
@@ -44,15 +45,28 @@ export function executePluginCommand(registry: FrontendPluginRegistry, queryStor
if (!evaluatePluginCommandConditions(command.enablement?.all, commandContextKeys)) {
return { error: `Command '${pluginId}.${commandId}' is disabled by enablement` };
}
// Execution context: command-declared payload first, caller context (bridge
// executeCommand) over it; reserved identity fields (§11) are stripped from
// the caller — the host owns workbenchId/restored/surface on every surface.
const callerPayload = callerContext && typeof callerContext === "object" && !Array.isArray(callerContext) ? { ...callerContext } : {};
delete callerPayload.workbenchId;
delete callerPayload.restored;
delete callerPayload.surface;
const executionContext = { ...(action.context ?? {}), ...callerPayload };
// presentation: panel -> adds an entry to the global bottom dock (§8.3); tab (default)
// -> opens a workbench tab.
if (action.presentation === "panel") {
// §4.1: singleton (default) re-activates the existing panel instance
// (reuse key = pluginId + commandId + presentation + instance_key);
// reuse:"new" always spawns one. The dock "+" menu bypasses this path by
// design — it is the explicit multi-open affordance.
// design — it is the explicit multi-open affordance. `instance_key`
// supports the shared `{{path}}` placeholders resolved against the
// execution context, so `logs:{{connectionId}}` scopes one panel instance
// per connection; an unresolvable template falls back to its literal (a
// manifest without placeholders behaves exactly as before).
const instanceKey = action.instance_key ? (interpolatePluginTemplate(action.instance_key, executionContext) ?? action.instance_key) : undefined;
if (action.reuse !== "new") {
const existing = findReusableDockEntry(pluginId, command.id, action.instance_key);
const existing = findReusableDockEntry(pluginId, command.id, instanceKey);
if (existing) {
activatePluginDockEntry(existing.id);
return {};
@@ -63,17 +77,17 @@ export function executePluginCommand(registry: FrontendPluginRegistry, queryStor
workbenchContributionId: action.workbench,
kind: "command",
commandId: command.id,
instanceKey: action.instance_key,
instanceKey,
title: command.label,
icon: command.icon,
commandContext: action.context ?? {},
commandContext: executionContext,
});
return {};
}
// Host-authored context: rebuilt from action.context — plugin-supplied reserved fields
// Host-authored context: built from the execution context — plugin-supplied reserved fields
// (workbenchId/restored/surface) never pass through; the workbenchId is generated by the host.
const context = {
...(action.context ?? {}),
...executionContext,
workbenchId: uuid(),
restored: false,
surface: "tab",
+62
View File
@@ -437,6 +437,68 @@ describe("PluginHostBridge", () => {
expect(openWorkbench).toHaveBeenNthCalledWith(2, "sample", "sample.other", undefined, { forceNew: false });
});
it("routes host.executeCommand to the surface handler scoped to the calling plugin", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const executeCommand = vi.fn(() => ({ error: "Command 'sample.open-panel' is disabled by enablement" }));
const bridge = new PluginHostBridge(plugin(["host.workbench"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
executeCommand,
});
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "3", method: "host.executeCommand", params: { commandId: "open-panel", context: { connectionId: "c1" } } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(executeCommand).toHaveBeenCalledWith("sample", "open-panel", { connectionId: "c1" });
// Expected business outcomes resolve as a result (no rejection) so webviews
// can surface a notice instead of catching.
expect(messages[0]).toMatchObject({ id: "3", result: { error: "Command 'sample.open-panel' is disabled by enablement" } });
});
it("normalizes a void executeCommand handler to an empty result object", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const executeCommand = vi.fn();
const bridge = new PluginHostBridge(plugin(["host.workbench"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
executeCommand,
});
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "4", method: "host.executeCommand", params: { commandId: "open-panel" } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(executeCommand).toHaveBeenCalledWith("sample", "open-panel", undefined);
expect(messages[0]).toMatchObject({ id: "4", result: {} });
});
it("host.executeCommand requires the host.workbench permission", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const executeCommand = vi.fn();
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
executeCommand,
});
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "5", method: "host.executeCommand", params: { commandId: "open-panel" } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(messages[0]).toMatchObject({ id: "5", error: "Plugin has not declared permission 'host.workbench'" });
expect(executeCommand).not.toHaveBeenCalled();
});
it("reopens a plugin connection through host.reopenConnection", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
@@ -162,6 +162,17 @@ export interface PluginHostBridgeApi {
openAiConversation?(request: AiPluginConversationRequest): Promise<void>;
setAiRecommendations?(update: PluginAiRecommendationHostUpdate): void;
openWorkbench?(pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }): Promise<void> | void;
/**
* §4/§5 command execution asked from a plugin webview: the same registry
* path as menu execution — enablement (§5.4), §4.1 singleton reuse,
* host-authored context — scoped to the calling plugin's own declared
* commands. The optional context merges over the command-declared context
* (caller wins; reserved identity fields are host-owned) and feeds
* `instance_key` `{{path}}` placeholders, so per-connection instances are
* one `logs:{{connectionId}}` declaration away. Resolves `{ error }` for
* expected business outcomes; only bridge or permission failures reject.
*/
executeCommand?(pluginId: string, commandId: string, context?: Record<string, unknown>): Promise<{ error?: string } | null | void> | { error?: string } | null | void;
openFilesystem?(pluginId: string, providerId: string, context?: PluginWorkbenchContext): Promise<void> | void;
/** Explicit user-triggered reconnect of an owned plugin connection (full flow, interactive password prompt allowed). */
reopenConnection?(pluginId: string, connectionId: string): Promise<void>;
@@ -663,6 +674,12 @@ export class PluginHostBridge {
await this.api.openWorkbench(this.plugin.manifest.id, requireProtocolName(input.contributionId, "workbench contribution"), isRecord(input.context) ? input.context : undefined, { forceNew: input.forceNew === true });
return null;
}
if (method === "host.executeCommand") {
this.requirePermission("host.workbench");
if (!this.api.executeCommand) throw new Error("Host command execution is unavailable");
const input = requireRecord(params, "host.executeCommand params");
return (await this.api.executeCommand(this.plugin.manifest.id, requireProtocolName(input.commandId, "command id"), isRecord(input.context) ? input.context : undefined)) ?? {};
}
if (method === "host.reopenConnection") {
if (!this.api.reopenConnection) throw new Error("Connection reopen is unavailable");
const input = requireRecord(params, "host.reopenConnection params");
@@ -1209,6 +1226,15 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
return URL.createObjectURL(new Blob([decode(asset.dataBase64)], { type: asset.contentType }));
},
openWorkbench: (contributionId, childContext, options) => request('host.openWorkbench', { contributionId, context: childContext, forceNew: !!(options && options.forceNew) }),
// §4/§5 command execution from a webview — the same registry path a menu
// placement takes (enablement, §4.1 reuse, host-authored context),
// scoped to the plugin's own declared commands. The optional context
// merges over the command context and scopes instance_key placeholders
// (e.g. one panel per connection). Resolves { error } for expected
// business outcomes so callers can surface a notice without try/catch.
// NOTE: this block is the sandbox bootstrap template source — comments
// here must not contain backticks or dollar-brace interpolation.
executeCommand: (commandId, context) => request('host.executeCommand', { commandId, context }),
openFilesystem: (providerId, childContext) => request('host.openFilesystem', { providerId, context: childContext }),
reopenConnection: (connectionId) => request('host.reopenConnection', { connectionId }),
// Estimated plans only: mode must be sent explicitly so a plugin states
@@ -350,6 +350,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| `sendBinary(channel, data)` / `onBinary(fn)` | 发送或接收二进制数据;需要 `stdio-framed` 和 `host.binary`。 |
| `readAsset(path)` / `readAssetUrl(path)` | 读取包内资源;路径不能越出插件资源根目录。 |
| `openWorkbench(id, context)` | 打开本插件的另一个工作台;需要 `host.workbench`。 |
| `executeCommand(commandId, context?)` | 执行本插件自己声明的命令,与菜单执行完全同径——重新检查 `enablement`、按单例复用,并遵循声明的 `presentation`(panel 停靠底部面板,tab 打开工作台标签页)。`context` 合并到命令上下文之上,并解析 `instance_key` 的 `{{path}}` 占位符(如 `instance_key: "logs:{{connectionId}}"` 即每个连接一个面板实例)。预期结果通过 `{ error }` 返回;需要 `host.workbench`。 |
| `openFilesystem(id, context)` | 打开本插件的文件系统入口;需要 `host.filesystem`。 |
| `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 |
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 |
+1
View File
@@ -350,6 +350,7 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
| `sendBinary(channel, data)` / `onBinary(fn)` | Sends or receives binary data; requires framed transport and `host.binary`. |
| `readAsset(path)` / `readAssetUrl(path)` | Reads a packaged asset within the plugin resource root. |
| `openWorkbench(id, context)` | Opens another workbench from this plugin; requires `host.workbench`. |
| `executeCommand(commandId, context?)` | Executes one of this plugin's own declared commands through the same path as a menu placement — re-checks `enablement`, applies singleton reuse, and follows the declared `presentation` (panel commands dock, tab commands open tabs). `context` merges over the command context and scopes `instance_key` `{{path}}` placeholders, so `instance_key: "logs:{{connectionId}}"` yields one panel instance per connection. Resolves `{ error }` for expected outcomes; requires `host.workbench`. |
| `openFilesystem(id, context)` | Opens a filesystem entry; requires `host.filesystem`. |
| `getPlanCapabilities(connectionId)` | Reads what the host and this connection can plan; requires `host.plans:read`. |
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. |
+1
View File
@@ -369,6 +369,7 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san
- `sendBinary(channel, data)` — requires `host.binary`
- `readAsset(path)` / `readAssetUrl(path)`
- `openWorkbench(contributionId, context)` — requires `host.workbench`
- `executeCommand(commandId, context?)` — executes one of this plugin's own declared commands through the same path a menu placement takes: `enablement` is re-checked, `presentation: "panel"` commands dock in the bottom panel and `presentation: "tab"` (default) commands open a workbench tab, with §4.1 singleton reuse. `context` merges over the command-declared context (reserved identity fields stay host-owned) and scopes `instance_key` `{{path}}` placeholders — declare `instance_key: "logs:{{connectionId}}"` to get one panel instance per connection; a template that cannot resolve falls back to its literal. Resolves `{ error }` for expected outcomes (unknown command, enablement-blocked); requires `host.workbench`
- `openFilesystem(providerId, context)` — requires `host.filesystem`
- `getPlanCapabilities(connectionId)` / `explainPlan(request)` — reads an estimated execution plan for one connection; requires `host.plans:read`, see [Estimated execution plans](#estimated-execution-plans)
- `getTableMetadata({ connectionId, database?, schema?, table })` — reads narrow schema metadata for one table on an already-open connection; requires `host.schema:read`, see [Table Schema Metadata](#table-schema-metadata)