feat(plugins): add command and menu contributions with bottom dock

This commit is contained in:
Jinpy authored and GitHub committed 2026-09-23 19:52:11 +08:00
1 parent 67dd9a6229
commit aefecd3841
37 files changed
+2911 -34

No files matched your search

+95 -3
View File
@@ -1,6 +1,9 @@
import type {
ConnectionConfig,
InstalledPlugin,
PluginCommandContribution,
PluginConditionClause,
PluginConditionContextKeys,
PluginConnectionAction,
PluginConnectionProviderContribution,
PluginContribution,
@@ -72,6 +75,68 @@ export class FrontendPluginRegistry {
return this.listWorkbenches().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId);
}
listCommands(): PluginContributionEntry<PluginCommandContribution>[] {
return this.listContributions("command");
}
findCommand(pluginId: string, contributionId: string): PluginContributionEntry<PluginCommandContribution> | undefined {
return this.listCommands().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId);
}
/** First command of the plugin whose action opens the given workbench (§4.1). */
findCommandTargetingWorkbench(pluginId: string, workbenchContributionId: string): PluginCommandContribution | undefined {
for (const contribution of this.findPlugin(pluginId)?.contributions ?? []) {
if (contribution.type !== "command") continue;
const action = contribution.action;
if (action.type === "open-workbench" && action.workbench === workbenchContributionId) return contribution;
}
return undefined;
}
/**
* PR-A4 appToolbar placements (HOST_PLUGIN_UI_SPEC §5.1): one icon entry
* per visible toolbar placement, ordered by `order` then the full command
* id so the result never depends on manifest or install order. Toolbar
* entries stay hidden unless the manifest sets `default_visible: true`
* (§5.2: toolbar items default to hidden).
*/
listToolbarMenuCommands(): Array<{ plugin: InstalledPlugin; command: PluginCommandContribution; order: number }> {
const result: Array<{ plugin: InstalledPlugin; command: PluginCommandContribution; order: number }> = [];
for (const definition of this.definitions) {
for (const contribution of definition.contributions) {
if (contribution.type !== "menus") continue;
const placements = contribution.items.filter((item) => item.location === "appToolbar" && item.default_visible === true && evaluateWhen(item.when, "appToolbar")).sort((a, b) => a.order - b.order || a.command.localeCompare(b.command));
for (const item of placements) {
const command = definition.contributions.find((candidate): candidate is PluginCommandContribution => candidate.type === "command" && candidate.id === item.command);
if (command) result.push({ plugin: definition.plugin, command, order: item.order });
}
}
}
return result;
}
/**
* commandPalette placements (HOST_PLUGIN_UI_SPEC §5): palette declarations have no
* a default_visible gate (§5.2 only mandates hidden-by-default for toolbar items). Commands sort by `order`
* ascending, then by the fully qualified command id (`${pluginId}.${commandId}`) for a stable order independent of
* manifest or install order.
*/
listPaletteMenuCommands(): Array<{ plugin: InstalledPlugin; command: PluginCommandContribution; order: number }> {
const result: Array<{ plugin: InstalledPlugin; command: PluginCommandContribution; order: number }> = [];
for (const definition of this.definitions) {
for (const contribution of definition.contributions) {
if (contribution.type !== "menus") continue;
for (const item of contribution.items) {
if (item.location !== "commandPalette") continue;
if (!evaluateWhen(item.when, "commandPalette")) continue;
const command = definition.contributions.find((candidate): candidate is PluginCommandContribution => candidate.type === "command" && candidate.id === item.command);
if (command) result.push({ plugin: definition.plugin, command, order: item.order });
}
}
}
return result.sort((left, right) => left.order - right.order || `${left.plugin.manifest.id}.${left.command.id}`.localeCompare(`${right.plugin.manifest.id}.${right.command.id}`));
}
/**
* Resolve any contribution the plugin UI entrypoint can render, whichever host
* surface opened the tab — a `workbench` opened from the sidebar or a
@@ -87,10 +152,34 @@ export class FrontendPluginRegistry {
return this.definitions
.filter((definition) => definition.plugin.compatibility.compatible)
.flatMap((definition) => definition.contributions.filter((contribution): contribution is Extract<PluginContribution, { type: T }> => contribution.type === type).map((contribution) => ({ plugin: definition.plugin, contribution })))
.sort((left, right) => `${left.plugin.manifest.name}:${left.contribution.label || left.contribution.id}`.localeCompare(`${right.plugin.manifest.name}:${right.contribution.label || right.contribution.id}`));
.sort((left, right) => `${left.plugin.manifest.name}:${pluginContributionSortLabel(left.contribution)}`.localeCompare(`${right.plugin.manifest.name}:${pluginContributionSortLabel(right.contribution)}`));
}
}
/** menus contributions carry no label of their own (copy comes from the referenced command); the sort key degrades to the id. */
function pluginContributionSortLabel(contribution: PluginContribution): string {
return (contribution as { label?: string }).label || contribution.id;
}
/**
* §5.3/§5.4 condition evaluation (pure): implicit AND within `all`; clauses referencing a missing key evaluate to false for all
* operators. contextKeys are snapshots provided by the host per scenario.
*/
export function evaluatePluginCommandConditions(clauses: PluginConditionClause[] | undefined, contextKeys: PluginConditionContextKeys): boolean {
return (clauses ?? []).every((clause) => {
const actual = contextKeys[clause.key];
if (actual === undefined) return false;
if (clause.operator === "equals") return String(actual) === String(clause.value);
if (clause.operator === "notEquals") return String(actual) !== String(clause.value);
return Array.isArray(clause.value) && clause.value.map(String).includes(String(actual));
});
}
/** Placement render gate: when defaults to visible; evaluated against the placement surface snapshot. */
function evaluateWhen(when: { all: PluginConditionClause[] } | undefined, surface: string): boolean {
return evaluatePluginCommandConditions(when?.all, { surface });
}
export function createFrontendPluginRegistry(plugins: readonly InstalledPlugin[], locale = "en"): FrontendPluginRegistry {
return new FrontendPluginRegistry(plugins, locale);
}
@@ -276,6 +365,9 @@ function localizePluginMetadata(plugin: InstalledPlugin, localization?: PluginMa
}
function localizeContribution(contribution: PluginContribution, localization: PluginContributionLocalization | undefined, pluginName: string): PluginContribution {
// Menus entries carry no display text of their own — labels come from the
// referenced commands, so they pass through localization untouched.
if (contribution.type === "menus") return contribution;
const fallbackLabel = contribution.type === "connection-provider" ? optionalTrimmed(contribution.label) || optionalTrimmed(pluginName) || contribution.id : contribution.label;
const localized = {
...contribution,
@@ -290,8 +382,8 @@ function localizeContribution(contribution: PluginContribution, localization: Pl
label: localizedRequiredText(action.label, localization?.actions?.[action.id]?.label),
description: localizedOptionalText(action.description, localization?.actions?.[action.id]?.description),
}));
} else if (localized.type === "workbench" || localized.type === "result-view") {
// Both render through the plugin UI entrypoint, so both resolve their icon
} else if (localized.type === "workbench" || localized.type === "command" || localized.type === "result-view") {
// Workbench, command and result-view contributions all resolve their icon
// asset path the same way.
localized.icon = optionalPluginAssetPath(localized.icon);
}
+125
View File
@@ -0,0 +1,125 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import type * as dockModule from "@/lib/plugins/pluginBottomDock";
async function loadModule(): Promise<typeof dockModule> {
vi.resetModules();
return await import("@/lib/plugins/pluginBottomDock");
}
describe("pluginBottomDock", () => {
afterEach(() => {
vi.resetModules();
});
it("numbers same-command entries beyond the highest live suffix, never duplicating after a close", async () => {
const dock = await loadModule();
const first = dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
const second = dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
expect(dock.usePluginBottomDock().entries.value.map((entry) => entry.title)).toEqual(["Local terminal", "Local terminal 2", "Local terminal 3"]);
// Close the middle entry, then open another: the new title must not
// collide with the surviving "Local terminal 3".
dock.closePluginDockEntry(second);
const fourth = dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
expect(dock.usePluginBottomDock().entries.value.map((entry) => entry.title)).toEqual(["Local terminal", "Local terminal 3", "Local terminal 4"]);
// Different commands number independently.
dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "other.command", title: "Other" });
const titles = dock.usePluginBottomDock().entries.value.map((entry) => entry.title);
expect(titles[titles.length - 1]).toBe("Other");
dock.closePluginDockEntry(first);
dock.closePluginDockEntry(fourth);
});
it("finds singleton reuse candidates only inside the same plugin+command+instance-key group (§4.1)", async () => {
const dock = await loadModule();
const first = dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "open-local-terminal", title: "Local terminal", instanceKey: "local-terminal" });
expect(dock.findReusableDockEntry("io.dbx.ssh", "open-local-terminal", "local-terminal")?.id).toBe(first);
// a different instance_key is a different singleton group.
expect(dock.findReusableDockEntry("io.dbx.ssh", "open-local-terminal", "other")).toBeUndefined();
// an undefined instance_key only matches entries created without one.
expect(dock.findReusableDockEntry("io.dbx.ssh", "open-local-terminal")).toBeUndefined();
// connection-target entries (dock "+" menu) are never command reuse candidates.
dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "connection", commandId: "open-local-terminal", title: "Prod" });
expect(dock.findReusableDockEntry("io.dbx.ssh", "open-local-terminal", "local-terminal")?.id).toBe(first);
});
it("restores the persisted dock height clamped to the current viewport (chrome only, §8.4)", async () => {
const dock = await loadModule();
localStorage.setItem("dbx-plugin-dock-height", "560");
expect(dock.restoreDockHeight(1000)).toBe(560);
// an oversized stored height clamps to the shared drag/maximize bound.
localStorage.setItem("dbx-plugin-dock-height", "5000");
expect(dock.restoreDockHeight(1000)).toBe(800);
// a tiny viewport still honors the minimum height.
localStorage.setItem("dbx-plugin-dock-height", "560");
expect(dock.restoreDockHeight(100)).toBe(140);
// nothing persisted, or garbage, falls back to the default height.
localStorage.removeItem("dbx-plugin-dock-height");
expect(dock.restoreDockHeight(1000)).toBe(320);
localStorage.setItem("dbx-plugin-dock-height", "not-a-number");
expect(dock.restoreDockHeight(1000)).toBe(320);
localStorage.removeItem("dbx-plugin-dock-height");
});
it("keeps entries alive across hide/show and only clears the dock with its last entry", async () => {
const dock = await loadModule();
const state = dock.usePluginBottomDock();
dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
expect(state.visible.value).toBe(true);
dock.setDockVisible(false);
expect(state.visible.value).toBe(false);
expect(state.entries.value).toHaveLength(1);
dock.setDockVisible(true);
expect(state.visible.value).toBe(true);
expect(state.entries.value).toHaveLength(1);
dock.closePluginDockEntry(state.entries.value[0]!.id);
expect(state.entries.value).toHaveLength(0);
expect(state.visible.value).toBe(false);
expect(state.activeEntryId.value).toBeNull();
});
it("activates a neighboring entry after closing the active one", async () => {
const dock = await loadModule();
const state = dock.usePluginBottomDock();
const first = dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
const second = dock.addPluginDockEntry({ pluginId: "io.dbx.ssh", workbenchContributionId: "local", kind: "command", commandId: "local.terminal", title: "Local terminal" });
expect(state.activeEntryId.value).toBe(second);
dock.closePluginDockEntry(second);
expect(state.activeEntryId.value).toBe(first);
dock.closePluginDockEntry(first);
expect(state.activeEntryId.value).toBeNull();
});
});
describe("dock tab order and titles", () => {
it("moves entries by drag target index, clamped", async () => {
const dock = await loadModule();
const ids = ["a", "b", "c"].map((title, index) => dock.addPluginDockEntry({ pluginId: "p", workbenchContributionId: "w", kind: "command", commandId: `cmd-${index}`, title }));
dock.movePluginDockEntry(ids[0]!, 2);
expect(dock.usePluginBottomDock().entries.value.map((entry) => entry.title)).toEqual(["b", "c", "a"]);
dock.movePluginDockEntry(ids[0]!, 99);
expect(dock.usePluginBottomDock().entries.value.map((entry) => entry.title)).toEqual(["b", "c", "a"]);
dock.movePluginDockEntry(ids[0]!, -5);
expect(dock.usePluginBottomDock().entries.value.map((entry) => entry.title)).toEqual(["a", "b", "c"]);
dock.movePluginDockEntry("missing", 0);
});
it("renames a tab and ignores blank titles", async () => {
const dock = await loadModule();
const id = dock.addPluginDockEntry({ pluginId: "p", workbenchContributionId: "w", kind: "command", commandId: "cmd", title: "Local terminal" });
dock.renamePluginDockEntry(id, " build shell ");
expect(dock.usePluginBottomDock().entries.value[0]!.title).toBe("build shell");
dock.renamePluginDockEntry(id, " ");
expect(dock.usePluginBottomDock().entries.value[0]!.title).toBe("build shell");
dock.renamePluginDockEntry("missing", "x");
});
});
@@ -0,0 +1,163 @@
// PR-A4/P2 global bottom dock (HOST_PLUGIN_UI_SPEC §8.3) — VS Code-style
// terminal panel: N docked terminal entries (local shells + SSH connection
// sessions of plugin-backed connections), switchable from the tab strip,
// alive across pages. Each entry owns a host-generated workbenchId (= entry
// id) that stays stable for the entry's lifetime, so switching tabs keeps
// sessions alive and reopening never leaks PTYs. The reuse key includes the
// presentation: dock (panel) instances and tab instances coexist.
import { ref } from "vue";
import { uuid } from "@/lib/common/utils";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
export const DOCK_HEIGHT_PX = 320;
export const DOCK_MIN_HEIGHT_PX = 140;
/** One bound for the drag ceiling and the maximize height: a dragged dock must never shrink when the maximize button is pressed. */
export const DOCK_MAX_VIEWPORT_RATIO = 0.8;
const DOCK_HEIGHT_STORAGE_KEY = "dbx-plugin-dock-height";
export interface PluginDockEntry {
/** Host-generated stable instance id; doubles as the entry workbench's workbenchId. */
id: string;
pluginId: string;
workbenchContributionId: string;
kind: "command" | "connection";
/** Source command short id (present for kind=command), replayed by the generic "+". */
commandId?: string;
/** §4.1 instance_key of the source command; groups the entry into the command's singleton reuse key. */
instanceKey?: string;
title: string;
icon?: string;
/** Host-authored context: workbenchId=id, restored=false, surface="panel". */
context: Record<string, unknown>;
}
const dockEntries = ref<PluginDockEntry[]>([]);
const activeEntryId = ref<string | null>(null);
const dockVisible = ref(false);
const dockMaximized = ref(false);
export interface AddPluginDockEntryPayload {
pluginId: string;
workbenchContributionId: string;
kind: PluginDockEntry["kind"];
commandId?: string;
/** §4.1 instance_key of the source command. */
instanceKey?: string;
title: string;
icon?: string;
commandContext?: Record<string, unknown>;
}
/** Creates (and activates) a dock terminal entry; returns its stable id. */
export function addPluginDockEntry(payload: AddPluginDockEntryPayload): string {
const id = uuid();
// Same-command entries get a sequence suffix in the title (generic: "Local terminal 1 / 2 ..."),
// keeps multi-instance panels distinguishable in the tab strip. Numbers are assigned at creation
// and advance beyond the highest live suffix — never backfilled after a close, so titles stay
// unique and stable (same convention as the plugin tabs in queryStore).
const siblings = dockEntries.value.filter((entry) => entry.pluginId === payload.pluginId && entry.commandId === payload.commandId);
const nextNumber =
siblings.reduce((highest, entry) => {
const suffix = / (\d+)$/.exec(entry.title);
return suffix ? Math.max(highest, Number(suffix[1])) : highest;
}, 1) + 1;
const title = siblings.length ? `${payload.title} ${nextNumber}` : payload.title;
dockEntries.value.push({
id,
pluginId: payload.pluginId,
workbenchContributionId: payload.workbenchContributionId,
kind: payload.kind,
commandId: payload.commandId,
instanceKey: payload.instanceKey,
title,
icon: payload.icon,
context: {
...(payload.commandContext ?? {}),
workbenchId: id,
restored: false,
surface: "panel",
},
});
activeEntryId.value = id;
dockVisible.value = true;
return id;
}
/**
* Singleton reuse lookup for command executions (§4.1): the reuse key is
* pluginId + commandId + presentation + instance_key, and the presentation is
* fixed to "panel" here — dock entries and workbench tabs never reuse across
* surfaces. Dock "+"-menu entries (kind "connection") are deliberate
* multi-open instances and are never reuse candidates.
*/
export function findReusableDockEntry(pluginId: string, commandId: string, instanceKey?: string): PluginDockEntry | undefined {
return dockEntries.value.find((entry) => entry.kind === "command" && entry.pluginId === pluginId && entry.commandId === commandId && (entry.instanceKey ?? undefined) === (instanceKey ?? undefined));
}
export function activatePluginDockEntry(id: string): void {
if (dockEntries.value.some((entry) => entry.id === id)) {
activeEntryId.value = id;
dockVisible.value = true;
}
}
/** Removes one terminal entry (its scope is reclaimed with the host bridge teardown). */
export function closePluginDockEntry(id: string): void {
const index = dockEntries.value.findIndex((entry) => entry.id === id);
if (index < 0) return;
dockEntries.value.splice(index, 1);
if (activeEntryId.value === id) {
const neighbor = dockEntries.value[index - 1] ?? dockEntries.value[index] ?? null;
activeEntryId.value = neighbor?.id ?? null;
}
if (!dockEntries.value.length) dockVisible.value = false;
}
/** Drag & drop reorder: moves the entry to the target index (clamped). */
export function movePluginDockEntry(id: string, toIndex: number): void {
const from = dockEntries.value.findIndex((entry) => entry.id === id);
if (from < 0) return;
const to = Math.max(0, Math.min(toIndex, dockEntries.value.length - 1));
if (to === from) return;
const [entry] = dockEntries.value.splice(from, 1);
dockEntries.value.splice(to, 0, entry);
}
/** Tab rename (double-click): blank titles keep the current one (stable titles). */
export function renamePluginDockEntry(id: string, title: string): void {
const entry = dockEntries.value.find((candidate) => candidate.id === id);
const next = title.trim();
if (!entry || !next) return;
entry.title = next;
}
/** Hides/shows the panel without killing the terminal sessions. */
export function setDockVisible(visible: boolean): void {
if (visible && !dockEntries.value.length) return;
dockVisible.value = visible;
}
export function setDockMaximized(maximized: boolean): void {
dockMaximized.value = maximized;
}
export function usePluginBottomDock() {
return { entries: dockEntries, activeEntryId, visible: dockVisible, maximized: dockMaximized };
}
/**
* UI-chrome persistence only: the dragged dock height survives restarts. Dock
* entries themselves are never persisted or restored (§8.4 restore:"none" —
* no shell may come back with the app). The stored height is clamped to the
* current viewport, so a smaller window cannot inherit an oversized dock.
*/
export function restoreDockHeight(viewportHeight: number): number {
const stored = Number(safeLocalStorageGet(DOCK_HEIGHT_STORAGE_KEY));
if (!Number.isFinite(stored) || stored <= 0) return DOCK_HEIGHT_PX;
const max = Math.max(DOCK_MIN_HEIGHT_PX, Math.floor(viewportHeight * DOCK_MAX_VIEWPORT_RATIO));
return Math.min(Math.max(stored, DOCK_MIN_HEIGHT_PX), max);
}
export function persistDockHeight(height: number): void {
safeLocalStorageSet(DOCK_HEIGHT_STORAGE_KEY, String(Math.round(height)));
}
@@ -0,0 +1,180 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createFrontendPluginRegistry } from "./frontendPlugin";
import { pluginCommandPaletteItems, usePluginCommandPalette, type PluginPaletteCommandEntry } from "./pluginCommandPalette";
import type { InstalledPlugin, PluginCommandContribution, PluginMenusContribution, PluginWorkbenchContribution } from "@/types/database";
// commandPalette surface (HOST_PLUGIN_UI_SPEC §5): the registry derives palette commands by order + fully
// fully qualified command id for a stable order; quick-open entries carry provenance and execution goes through
// the host-authoritative executePluginCommand chain; plugin list changes refresh via dbx:plugins-changed.
function installedPlugin(id: string, contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
return {
compatibility: { compatible: true },
manifest: {
id,
name: id,
version: "1.0.0",
drivers: [],
contributions,
},
};
}
function sshWorkbench(): PluginWorkbenchContribution {
return { type: "workbench", id: "io.dbx.test.workbench", label: "Test" };
}
function testCommand(id: string, label: string, description?: string): PluginCommandContribution {
return { type: "command", id, label, description, action: { type: "open-workbench", workbench: "io.dbx.test.workbench" } };
}
function menusContribution(items: PluginMenusContribution["items"]): PluginMenusContribution {
return { type: "menus", id: "entrypoints", items };
}
const apiMock = vi.hoisted(() => ({
listPlugins: vi.fn<() => Promise<InstalledPlugin[]>>(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
const queryStoreMock = vi.hoisted(() => ({
useQueryStore: vi.fn(),
}));
vi.mock("@/stores/queryStore", () => queryStoreMock);
describe("FrontendPluginRegistry.listPaletteMenuCommands", () => {
it("collects commandPalette items ordered by order then full command id, ignoring other locations", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("io.dbx.alpha", [
sshWorkbench(),
testCommand("open-console", "Alpha console"),
menusContribution([
{ location: "appToolbar", command: "open-console", group: "navigation", order: 1, default_visible: true },
{ location: "appSidebar", command: "open-console", group: "primary", order: 2 },
{ location: "commandPalette", command: "open-console", group: "primary", order: 200 },
]),
]),
installedPlugin("io.dbx.beta", [sshWorkbench(), testCommand("open-probe", "Beta probe"), menusContribution([{ location: "commandPalette", command: "open-probe", group: "primary", order: 100 }])]),
]);
const entries = registry.listPaletteMenuCommands();
expect(entries.map((entry) => `${entry.plugin.manifest.id}.${entry.command.id}`)).toEqual(["io.dbx.beta.open-probe", "io.dbx.alpha.open-console"]);
expect(entries.map((entry) => entry.order)).toEqual([100, 200]);
});
it("applies no default_visible gate and breaks order ties by full command id", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("io.dbx.test", [
sshWorkbench(),
testCommand("zeta", "Zeta"),
testCommand("alpha", "Alpha"),
menusContribution([
// palette items have no default_visible (§5.2 hidden-by-default only constrains toolbar items).
{ location: "commandPalette", command: "zeta", group: "primary", order: 10 },
{ location: "commandPalette", command: "alpha", group: "primary", order: 10 },
]),
]),
]);
expect(registry.listPaletteMenuCommands().map((entry) => entry.command.id)).toEqual(["alpha", "zeta"]);
});
it("skips palette items referencing missing commands", () => {
const registry = createFrontendPluginRegistry([installedPlugin("io.dbx.test", [menusContribution([{ location: "commandPalette", command: "ghost", group: "primary", order: 1 }])])]);
expect(registry.listPaletteMenuCommands()).toHaveLength(0);
});
});
describe("pluginCommandPaletteItems", () => {
it("derives quick-open entries with plugin source hints and combined search text", () => {
const registry = createFrontendPluginRegistry([installedPlugin("io.dbx.test", [sshWorkbench(), testCommand("open-console", "Open console", "Do things"), menusContribution([{ location: "commandPalette", command: "open-console", group: "primary", order: 1 }])])]);
const entries: PluginPaletteCommandEntry[] = registry.listPaletteMenuCommands();
const items = pluginCommandPaletteItems(entries);
expect(items).toHaveLength(1);
const item = items[0];
expect(item?.type).toBe("plugin_command");
expect(item?.id).toBe("plugin-command-io.dbx.test-open-console");
expect(item?.label).toBe("Open console");
expect(item?.description).toBe("Do things");
expect(item?.pluginId).toBe("io.dbx.test");
expect(item?.commandId).toBe("open-console");
expect(item?.pluginName).toBe("io.dbx.test");
expect(item?.searchText).toBe("Open console Do things io.dbx.test");
});
it("falls back the description to the plugin name when the command has none", () => {
const registry = createFrontendPluginRegistry([installedPlugin("io.dbx.test", [sshWorkbench(), testCommand("open-console", "Open console"), menusContribution([{ location: "commandPalette", command: "open-console", group: "primary", order: 1 }])])]);
const items = pluginCommandPaletteItems(registry.listPaletteMenuCommands());
expect(items[0]?.description).toBe("io.dbx.test");
expect(items[0]?.searchText).toBe("Open console io.dbx.test");
});
});
describe("usePluginCommandPalette", () => {
beforeEach(() => {
apiMock.listPlugins.mockReset();
queryStoreMock.useQueryStore.mockReset();
});
it("derives entries from installed plugins and executes through executePluginCommand", async () => {
const openPluginWorkbench = vi.fn();
queryStoreMock.useQueryStore.mockReturnValue({ openPluginWorkbench });
apiMock.listPlugins.mockResolvedValue([installedPlugin("io.dbx.test", [sshWorkbench(), testCommand("open-console", "Open console"), menusContribution([{ location: "commandPalette", command: "open-console", group: "primary", order: 1 }])])]);
const palette = usePluginCommandPalette();
await palette.refresh();
expect(apiMock.listPlugins).toHaveBeenCalled();
expect(palette.items.value).toHaveLength(1);
const item = palette.items.value[0];
expect(item?.pluginId).toBe("io.dbx.test");
const result = palette.open(item!);
expect(result.error).toBeUndefined();
expect(queryStoreMock.useQueryStore).toHaveBeenCalledTimes(1);
expect(openPluginWorkbench).toHaveBeenCalledTimes(1);
const [pluginId, workbenchId] = openPluginWorkbench.mock.calls[0];
expect(pluginId).toBe("io.dbx.test");
expect(workbenchId).toBe("io.dbx.test.workbench");
});
it("refreshes the command list when the plugin center broadcasts dbx:plugins-changed", async () => {
apiMock.listPlugins.mockResolvedValue([]);
const palette = usePluginCommandPalette();
await palette.refresh();
expect(palette.items.value).toHaveLength(0);
apiMock.listPlugins.mockResolvedValue([installedPlugin("io.dbx.test", [sshWorkbench(), testCommand("open-console", "Open console"), menusContribution([{ location: "commandPalette", command: "open-console", group: "primary", order: 1 }])])]);
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
await vi.waitFor(() => expect(palette.items.value).toHaveLength(1));
});
it("clears entries and keeps the registry unusable when listing plugins fails", async () => {
apiMock.listPlugins.mockResolvedValue([installedPlugin("io.dbx.test", [sshWorkbench(), testCommand("open-console", "Open console"), menusContribution([{ location: "commandPalette", command: "open-console", group: "primary", order: 1 }])])]);
const palette = usePluginCommandPalette();
await palette.refresh();
expect(palette.items.value).toHaveLength(1);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
apiMock.listPlugins.mockRejectedValue(new Error("backend unavailable"));
await palette.refresh();
expect(palette.items.value).toHaveLength(0);
expect(palette.registry.value).toBeNull();
const item = { type: "plugin_command", pluginId: "io.dbx.test", commandId: "open-console" } as never;
expect(palette.open(item).error).toContain("registry");
warn.mockRestore();
});
it("rejects items that are not plugin commands", async () => {
apiMock.listPlugins.mockResolvedValue([]);
const palette = usePluginCommandPalette();
await palette.refresh();
const item = { type: "connection", connectionId: "conn1" } as never;
expect(palette.open(item).error).toBeTruthy();
});
});
@@ -0,0 +1,81 @@
// commandPalette surface (HOST_PLUGIN_UI_SPEC §5): map the plugin's `menus` contribution —
// commands with location === "commandPalette" derive into quick-open entries; execution goes through
// the host-authoritative executePluginCommand chain (command/workbench re-validation and context injection live there).
// Purely generic logic: label/description/provenance all come from registry declarations — no business-specific fields.
import { onScopeDispose, ref, shallowRef, watch } from "vue";
import * as api from "@/lib/backend/api";
import i18n from "@/i18n";
import { createFrontendPluginRegistry, type FrontendPluginRegistry } from "./frontendPlugin";
import { executePluginCommand, type PluginCommandExecutionResult } from "./pluginCommandRegistry";
import { useQueryStore } from "@/stores/queryStore";
import type { InstalledPlugin, PluginCommandContribution } from "@/types/database";
import type { QuickOpenItem } from "@/composables/useQuickOpen";
/** Entry shape returned by FrontendPluginRegistry.listPaletteMenuCommands(). */
export interface PluginPaletteCommandEntry {
plugin: InstalledPlugin;
command: PluginCommandContribution;
order: number;
}
/** Derives quick-open entries from palette declarations: label = command copy, provenance via description and the right-side plugin-name badge. */
export function pluginCommandPaletteItems(entries: readonly PluginPaletteCommandEntry[]): QuickOpenItem[] {
return entries.map(({ plugin, command }) => ({
id: `plugin-command-${plugin.manifest.id}-${command.id}`,
type: "plugin_command" as const,
label: command.label,
description: command.description || plugin.manifest.name,
connectionId: "",
pluginId: plugin.manifest.id,
commandId: command.id,
pluginName: plugin.manifest.name,
// Search matching: command label, description and source plugin name all participate in quick-open matching/pinyin.
searchText: [command.label, command.description, plugin.manifest.name].filter(Boolean).join(" "),
}));
}
/**
* Plugin command palette data source: derived from installed plugins; on install/uninstall/replace (plugin center broadcasts
* dbx:plugins-changed), window focus or locale change. Execution lazily resolves
* queryStore (same approach as usePluginToolbarCommands; plain host tests need no Pinia).
*/
export function usePluginCommandPalette() {
const items = ref<QuickOpenItem[]>([]);
// shallowRef: the registry is a class instance with private fields — a deep ref's UnwrapRef would break its nominal type.
const registry = shallowRef<FrontendPluginRegistry | null>(null);
async function refresh(): Promise<void> {
try {
const installedPlugins = await api.listPlugins();
const nextRegistry = createFrontendPluginRegistry(installedPlugins, i18n.global.locale.value);
registry.value = nextRegistry;
items.value = pluginCommandPaletteItems(nextRegistry.listPaletteMenuCommands());
} catch (cause) {
console.warn("[DBX][plugin:palette-commands]", cause);
registry.value = null;
items.value = [];
}
}
/** Executes one plugin command from quick-open; errors are returned to the caller for display. */
function open(item: QuickOpenItem): PluginCommandExecutionResult {
if (item.type !== "plugin_command" || !item.pluginId || !item.commandId) return { error: "Item is not a plugin command" };
if (!registry.value) return { error: "Plugin registry is not ready" };
return executePluginCommand(registry.value, useQueryStore(), item.pluginId, item.commandId);
}
watch(
() => i18n.global.locale.value,
() => void refresh(),
);
const onPluginsChanged = () => void refresh();
window.addEventListener("dbx:plugins-changed", onPluginsChanged);
window.addEventListener("focus", onPluginsChanged);
onScopeDispose(() => {
window.removeEventListener("dbx:plugins-changed", onPluginsChanged);
window.removeEventListener("focus", onPluginsChanged);
});
void refresh();
return { items, registry, refresh, open };
}
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createFrontendPluginRegistry, evaluatePluginCommandConditions } from "./frontendPlugin";
import { executePluginCommand } from "./pluginCommandRegistry";
import { closePluginDockEntry, usePluginBottomDock } from "./pluginBottomDock";
import type { InstalledPlugin, PluginMenusContribution } from "@/types/database";
// PR-A4 command/menus host side (HOST_PLUGIN_UI_SPEC §4/§5/§8.3/§11): the command
// the command registry derives toolbar entries from installed-plugin declarations; execution opens the workbench with a host-authored context —
// presentation: tab (default) opens a workbench tab, panel opens the global bottom dock; the plugin payload rides under
// context.plugin, while workbenchId/restored/surface are host-injected and never taken from the plugin.
function installedPlugin(id: string, contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
return {
compatibility: { compatible: true },
manifest: {
id,
name: id,
version: "1.0.0",
drivers: [],
contributions,
},
};
}
function localTerminalCommand(actionOverrides: Record<string, unknown> = {}) {
return {
type: "command",
id: "open-local-terminal",
label: "Local terminal",
action: {
type: "open-workbench",
workbench: "io.dbx.ssh.workbench",
reuse: "singleton",
context: { plugin: { mode: "local-terminal" } },
...actionOverrides,
},
};
}
function menusContribution(items: PluginMenusContribution["items"]): PluginMenusContribution {
return { type: "menus", id: "entrypoints", items };
}
describe("plugin command registry (PR-A4)", () => {
beforeEach(() => {
const { entries, visible } = usePluginBottomDock();
entries.value.splice(0);
visible.value = false;
});
it("derives visible appToolbar entries ordered by order then command id", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("io.dbx.ssh", [
{ type: "workbench", id: "io.dbx.ssh.workbench", label: "SSH" },
localTerminalCommand(),
menusContribution([
{ location: "commandPalette", command: "open-local-terminal", group: "primary", order: 100 },
{ location: "appToolbar", command: "open-local-terminal", group: "navigation", order: 100, default_visible: true },
]),
] as unknown as InstalledPlugin["manifest"]["contributions"]),
]);
const entries = registry.listToolbarMenuCommands();
expect(entries).toHaveLength(1);
expect(entries[0].plugin.manifest.id).toBe("io.dbx.ssh");
expect(entries[0].command.id).toBe("open-local-terminal");
// §5.2: toolbar items default to hidden — anything without default_visible: true never renders.
const hidden = createFrontendPluginRegistry([installedPlugin("io.dbx.ssh", [localTerminalCommand(), menusContribution([{ location: "appToolbar", command: "open-local-terminal", group: "navigation", order: 100 }])] as unknown as InstalledPlugin["manifest"]["contributions"])]);
expect(hidden.listToolbarMenuCommands()).toHaveLength(0);
});
it("executes a tab command with a host-authored context and singleton reuse", () => {
const registry = createFrontendPluginRegistry([installedPlugin("io.dbx.ssh", [{ type: "workbench", id: "io.dbx.ssh.workbench", label: "SSH" }, localTerminalCommand()] as unknown as InstalledPlugin["manifest"]["contributions"])]);
const openPluginWorkbench = vi.fn();
const result = executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ssh", "open-local-terminal");
expect(result.error).toBeUndefined();
expect(openPluginWorkbench).toHaveBeenCalledTimes(1);
const [pluginId, workbenchId, options] = openPluginWorkbench.mock.calls[0];
expect(pluginId).toBe("io.dbx.ssh");
expect(workbenchId).toBe("io.dbx.ssh.workbench");
expect(options.forceNew).toBe(false);
// plugin payload under context.plugin; reserved fields are host-injected and override forged values.
expect(options.context.plugin).toEqual({ mode: "local-terminal" });
expect(typeof options.context.workbenchId).toBe("string");
expect(options.context.restored).toBe(false);
expect(options.context.surface).toBe("tab");
expect(usePluginBottomDock().visible.value).toBe(false);
});
it("singleton (default) reuses the dock entry per panel execution; the + menu stays the multi-open affordance", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("io.dbx.ssh", [
{ type: "workbench", id: "io.dbx.ssh.workbench", label: "SSH" },
localTerminalCommand({ presentation: "panel", instance_key: "local-terminal", context: { plugin: { mode: "local-terminal" }, workbenchId: "plugin-forged", surface: "tab" } }),
] as unknown as InstalledPlugin["manifest"]["contributions"]),
]);
const openPluginWorkbench = vi.fn();
const { entries, activeEntryId, visible } = usePluginBottomDock();
const first = executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ssh", "open-local-terminal");
expect(first.error).toBeUndefined();
expect(entries.value).toHaveLength(1);
expect(visible.value).toBe(true);
const firstEntry = entries.value[0];
expect(firstEntry.pluginId).toBe("io.dbx.ssh");
expect(firstEntry.workbenchContributionId).toBe("io.dbx.ssh.workbench");
expect(firstEntry.context.plugin).toEqual({ mode: "local-terminal" });
// plugin-forged reserved fields are overridden by host-authoritative values; workbenchId = entry id and stable.
expect(firstEntry.context.workbenchId).toBe(firstEntry.id);
expect(firstEntry.context.workbenchId).not.toBe("plugin-forged");
expect(firstEntry.context.restored).toBe(false);
expect(firstEntry.context.surface).toBe("panel");
expect(activeEntryId.value).toBe(firstEntry.id);
// §4.1 reuse key = pluginId + commandId + presentation + instance_key.
expect(firstEntry.instanceKey).toBe("local-terminal");
// a second singleton execution re-activates the existing instance instead of spawning one;
// repeated multi-open goes through the dock "+" menu (options_action/connection targets), not here.
executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ssh", "open-local-terminal");
expect(entries.value).toHaveLength(1);
expect(activeEntryId.value).toBe(firstEntry.id);
expect(openPluginWorkbench).not.toHaveBeenCalled();
// closing an entry removes its tab; the panel hides once all entries are gone.
closePluginDockEntry(entries.value[0].id);
expect(visible.value).toBe(false);
});
it("reuse:new spawns one dock entry per execution", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("io.dbx.ssh", [
{ type: "workbench", id: "io.dbx.ssh.workbench", label: "SSH" },
localTerminalCommand({ presentation: "panel", reuse: "new", instance_key: "local-terminal", context: { plugin: { mode: "local-terminal" } } }),
] as unknown as InstalledPlugin["manifest"]["contributions"]),
]);
const { entries, activeEntryId } = usePluginBottomDock();
executePluginCommand(registry, { openPluginWorkbench: vi.fn() } as never, "io.dbx.ssh", "open-local-terminal");
executePluginCommand(registry, { openPluginWorkbench: vi.fn() } as never, "io.dbx.ssh", "open-local-terminal");
expect(entries.value).toHaveLength(2);
expect(activeEntryId.value).toBe(entries.value[1]!.id);
});
it("drops plugin-forged reserved fields and honors reuse:new with forceNew", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("io.dbx.ssh", [
{ type: "workbench", id: "io.dbx.ssh.workbench", label: "SSH" },
localTerminalCommand({ reuse: "new", context: { plugin: { mode: "local-terminal" }, workbenchId: "plugin-forged", restored: true, surface: "panel" } }),
] as unknown as InstalledPlugin["manifest"]["contributions"]),
]);
const openPluginWorkbench = vi.fn();
const result = executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ssh", "open-local-terminal");
expect(result.error).toBeUndefined();
const [, , options] = openPluginWorkbench.mock.calls[0];
expect(options.forceNew).toBe(true);
expect(options.context.workbenchId).not.toBe("plugin-forged");
expect(options.context.restored).toBe(false);
expect(options.context.surface).toBe("tab");
});
it("reports unknown commands and dangling workbench references instead of opening a tab", () => {
const registry = createFrontendPluginRegistry([installedPlugin("io.dbx.ssh", [localTerminalCommand({ workbench: "io.dbx.ssh.missing" })] as unknown as InstalledPlugin["manifest"]["contributions"])]);
const openPluginWorkbench = vi.fn();
expect(executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ssh", "missing-command").error).toBeTruthy();
expect(executePluginCommand(registry, { openPluginWorkbench } as never, "io.dbx.ssh", "open-local-terminal").error).toContain("missing workbench");
expect(openPluginWorkbench).not.toHaveBeenCalled();
});
it("evaluates §5.3 conditions (equals/notEquals/oneOf, missing key = false)", () => {
expect(evaluatePluginCommandConditions([{ key: "surface", operator: "equals", value: "tab" }], { surface: "tab" })).toBe(true);
expect(evaluatePluginCommandConditions([{ key: "surface", operator: "notEquals", value: "tab" }], { surface: "tab" })).toBe(false);
expect(evaluatePluginCommandConditions([{ key: "connection.state", operator: "oneOf", value: ["connected", "reconnecting"] }], { "connection.state": "connected" })).toBe(true);
// empty condition group defaults to true; a missing key is always false (§5.3).
expect(evaluatePluginCommandConditions([], {})).toBe(true);
expect(evaluatePluginCommandConditions([{ key: "object.type", operator: "equals", value: "table" }], {})).toBe(false);
// implicit AND within all.
expect(
evaluatePluginCommandConditions(
[
{ key: "surface", operator: "equals", value: "tab" },
{ key: "readOnly", operator: "equals", value: false },
],
{ surface: "tab", readOnly: false },
),
).toBe(true);
expect(
evaluatePluginCommandConditions(
[
{ key: "surface", operator: "equals", value: "tab" },
{ key: "readOnly", operator: "equals", value: true },
],
{ surface: "tab", readOnly: false },
),
).toBe(false);
});
it("gates execution by enablement against the context snapshot", () => {
const command = (enablement: unknown) =>
[
{ type: "workbench", id: "io.dbx.ssh.workbench", label: "SSH" },
localTerminalCommand(),
{ type: "command", id: "gated", label: "Gated", enablement, action: { type: "open-workbench", workbench: "io.dbx.ssh.workbench", context: { plugin: { mode: "local-terminal" } } } },
] as unknown as InstalledPlugin["manifest"]["contributions"];
const openPluginWorkbench = vi.fn();
const pass = createFrontendPluginRegistry([installedPlugin("io.dbx.ssh", command({ all: [{ key: "surface", operator: "equals", value: "tab" }] } as unknown as InstalledPlugin["manifest"]["contributions"]))]);
expect(executePluginCommand(pass, { openPluginWorkbench: vi.fn() } as never, "io.dbx.ssh", "gated").error).toBeUndefined();
const blocked = createFrontendPluginRegistry([installedPlugin("io.dbx.ssh", command({ all: [{ key: "surface", operator: "equals", value: "panel" }] } as unknown as InstalledPlugin["manifest"]["contributions"]))]);
const result = executePluginCommand(blocked, { openPluginWorkbench } as never, "io.dbx.ssh", "gated");
expect(result.error).toContain("disabled by enablement");
expect(openPluginWorkbench).not.toHaveBeenCalled();
});
it("listToolbarMenuCommands applies when visibility against the placement surface", () => {
const menus = (value: string) => menusContribution([{ location: "appToolbar", command: "open-local-terminal", group: "navigation", order: 100, default_visible: true, when: { all: [{ key: "surface", operator: "equals", value }] } }]);
const show = createFrontendPluginRegistry([installedPlugin("io.dbx.ssh", [localTerminalCommand(), menus("appToolbar")] as unknown as InstalledPlugin["manifest"]["contributions"])]);
expect(show.listToolbarMenuCommands()).toHaveLength(1);
const hide = createFrontendPluginRegistry([installedPlugin("io.dbx.ssh", [localTerminalCommand(), menus("panel")] as unknown as InstalledPlugin["manifest"]["contributions"])]);
expect(hide.listToolbarMenuCommands()).toHaveLength(0);
});
it("finds the declared command targeting a workbench for entry routing", () => {
const registry = createFrontendPluginRegistry([installedPlugin("io.dbx.ssh", [{ type: "workbench", id: "io.dbx.ssh.workbench", label: "SSH" }, localTerminalCommand()] as unknown as InstalledPlugin["manifest"]["contributions"])]);
const command = registry.findCommandTargetingWorkbench("io.dbx.ssh", "io.dbx.ssh.workbench");
expect(command?.id).toBe("open-local-terminal");
expect(registry.findCommandTargetingWorkbench("io.dbx.ssh", "other.workbench")).toBeUndefined();
expect(registry.findCommandTargetingWorkbench("other.plugin", "io.dbx.ssh.workbench")).toBeUndefined();
});
});
@@ -0,0 +1,137 @@
// PR-A4 command registry + appSidebar placements (HOST_PLUGIN_UI_SPEC §4/§5).
// The host keeps a static command registry per installed plugin and renders
// placements from declared `menus` data only — it never scans workbenches to
// invent entries. Executing a command re-resolves the workbench reference and
// opens the plugin workbench with a host-authored context: the plugin payload
// rides under `context.plugin`, while workbenchId/restored/surface are
// injected here and can never be supplied by the plugin (§11 ownership).
import { onScopeDispose, ref, shallowRef, watch } from "vue";
import { useI18n } from "vue-i18n";
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 { useQueryStore } from "@/stores/queryStore";
import type { PluginCommandContribution, PluginConditionContextKeys } from "@/types/database";
export interface PluginToolbarCommandEntry {
pluginId: string;
pluginName: string;
commandId: string;
label: string;
description?: string;
icon?: string;
}
export interface PluginCommandExecutionResult {
error?: string;
}
/**
* §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 {
const command = registry.findCommand(pluginId, commandId)?.contribution;
if (!command) return { error: `Unknown command '${pluginId}.${commandId}'` };
const action = command.action;
if (action.type !== "open-workbench") return { error: `Command '${pluginId}.${commandId}' has an unsupported action` };
const workbench = registry.findWorkbench(pluginId, action.workbench);
if (!workbench) return { error: `Command '${pluginId}.${commandId}' references missing workbench '${action.workbench}'` };
// §5.4 re-validate enablement right before execution (against the current context snapshot; the surface key follows
// presentation).
const commandContextKeys: PluginConditionContextKeys = { surface: action.presentation === "panel" ? "panel" : "tab", "connection.state": "none", readOnly: false };
if (!evaluatePluginCommandConditions(command.enablement?.all, commandContextKeys)) {
return { error: `Command '${pluginId}.${commandId}' is disabled by enablement` };
}
// 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.
if (action.reuse !== "new") {
const existing = findReusableDockEntry(pluginId, command.id, action.instance_key);
if (existing) {
activatePluginDockEntry(existing.id);
return {};
}
}
addPluginDockEntry({
pluginId,
workbenchContributionId: action.workbench,
kind: "command",
commandId: command.id,
instanceKey: action.instance_key,
title: command.label,
icon: command.icon,
commandContext: action.context ?? {},
});
return {};
}
// Host-authored context: rebuilt from action.context — plugin-supplied reserved fields
// (workbenchId/restored/surface) never pass through; the workbenchId is generated by the host.
const context = {
...(action.context ?? {}),
workbenchId: uuid(),
restored: false,
surface: "tab",
};
// singleton (default) reuses the existing instance tab; new forces a fresh one.
queryStore.openPluginWorkbench(pluginId, action.workbench, {
title: command.label,
context,
forceNew: action.reuse === "new",
});
return {};
}
export function usePluginToolbarCommands() {
const { locale } = useI18n();
const entries = ref<PluginToolbarCommandEntry[]>([]);
// shallowRef: the registry is a class instance with private fields — a deep ref's UnwrapRef would break
// nominal type (TS expands it into a plain structural object, making it unassignable).
const registry = shallowRef<FrontendPluginRegistry | null>(null);
async function refresh() {
try {
const installedPlugins = await api.listPlugins();
const nextRegistry = createFrontendPluginRegistry(installedPlugins, locale.value);
entries.value = nextRegistry.listToolbarMenuCommands().map(({ plugin, command }: { plugin: { manifest: { id: string; name: string } }; command: PluginCommandContribution }) => ({
pluginId: plugin.manifest.id,
pluginName: plugin.manifest.name,
commandId: command.id,
label: command.label,
description: command.description,
icon: command.icon,
}));
registry.value = nextRegistry;
} catch (cause) {
console.warn("[DBX][plugin:toolbar-entries]", cause);
entries.value = [];
registry.value = null;
}
}
function open(entry: PluginToolbarCommandEntry): PluginCommandExecutionResult {
if (!registry.value) return { error: "Plugin registry is not ready" };
// Lazy store access: pinia is only needed when an entry is actually clicked (mount-time has no store dependency;
// plain host tests need no Pinia).
return executePluginCommand(registry.value, useQueryStore(), entry.pluginId, entry.commandId);
}
watch(locale, () => void refresh());
// Refresh entries on plugin install/uninstall/replace (plugin center broadcasts dbx:plugins-changed) or window focus so they match the installed-plugin list.
// refresh entries so they match the installed-plugin list.
const onPluginsChanged = () => void refresh();
window.addEventListener("dbx:plugins-changed", onPluginsChanged);
window.addEventListener("focus", onPluginsChanged);
onScopeDispose(() => {
window.removeEventListener("dbx:plugins-changed", onPluginsChanged);
window.removeEventListener("focus", onPluginsChanged);
});
void refresh();
return { entries, refresh, open };
}
+68 -2
View File
@@ -3,9 +3,9 @@ import { reactive, readonly } from "vue";
import { PluginHostBridge, pluginSandboxDocument, pluginSdkSource } from "./pluginHostBridge";
import type { InstalledPlugin, PluginResultViewContribution, PluginWorkbenchContribution } from "@/types/database";
function plugin(permissions: string[] = []): InstalledPlugin {
function plugin(permissions: string[] = [], contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
return {
manifest: { id: "sample", name: "Sample", version: "1.0.0", permissions, drivers: [], contributions: [] },
manifest: { id: "sample", name: "Sample", version: "1.0.0", permissions, drivers: [], contributions },
compatibility: { compatible: true },
};
}
@@ -14,6 +14,30 @@ const workbench: PluginWorkbenchContribution = { type: "workbench", id: "sample.
const resultView: PluginResultViewContribution = { type: "result-view", id: "sample.graph", label: "Graph" };
describe("PluginHostBridge", () => {
it("host.listConnections returns a read-only secret-free list scoped to the calling plugin", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const listConnections = vi.fn(() => [{ id: "conn-1", name: "Prod", providerId: "sample.connection", connectionType: "sample", readOnly: true }]);
const contributions = [
{ type: "connection-provider", id: "sample.connection", label: "Sample", database_type: "sample", fields: [] },
{ type: "workbench", id: "sample.main", label: "Sample" },
] as InstalledPlugin["manifest"]["contributions"];
const bridge = new PluginHostBridge(plugin(["host.workbench"], contributions), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
listConnections,
});
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "9", method: "host.listConnections", params: {} },
} as MessageEvent);
await vi.waitFor(() => expect(listConnections).toHaveBeenCalledWith("sample"));
expect(messages[0]).toMatchObject({ source: "dbx-host", type: "response", id: "9", result: [{ id: "conn-1", name: "Prod", providerId: "sample.connection", readOnly: true }] });
});
it("streams downloads under the owning plugin, scopes cancellation and reports native capability", async () => {
const messages: any[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
@@ -1297,4 +1321,46 @@ describe("plugin SDK source", () => {
expect(source).toContain("type === 'filedrop'");
expect(source).toContain("type === 'dragstate'");
});
it("sdk wires the workbench/close handshake: onClose listeners, ack and ready feature flag", () => {
const source = pluginSdkSource();
expect(source).toContain("workbench.close");
expect(source).toContain("onClose");
expect(source).toContain("type === 'workbench/close'");
expect(source).toContain("'workbench/close-ack'");
});
it("requestWorkbenchClose posts the close message and resolves true on the plugin ack", async () => {
const messages: any[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, { workbenchId: "wb-1" }, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() });
// The new SDK advertises the handshake on ready.
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "ready", features: ["workbench.close"] } } as MessageEvent);
const pending = bridge.requestWorkbenchClose(200);
expect(messages.some((message) => message.type === "workbench/close" && message.workbenchId === "wb-1")).toBe(true);
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "workbench/close-ack", workbenchId: "wb-1" } } as MessageEvent);
await expect(pending).resolves.toBe(true);
});
it("requestWorkbenchClose resolves false after the deadline when the plugin never acks", async () => {
const messages: any[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() });
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "ready", features: ["workbench.close"] } } as MessageEvent);
await expect(bridge.requestWorkbenchClose(5)).resolves.toBe(false);
// A late ack after the deadline is ignored and must not throw.
expect(() => bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "workbench/close-ack" } } as MessageEvent)).not.toThrow();
});
it("requestWorkbenchClose skips the wait for SDKs without the handshake feature", async () => {
const messages: any[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() });
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "ready" } } as MessageEvent);
await expect(bridge.requestWorkbenchClose(5000)).resolves.toBe(false);
expect(messages.some((message) => message.type === "workbench/close")).toBe(true);
});
});
@@ -99,6 +99,12 @@ export interface PluginHostBridgeApi {
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>;
/**
* PR-A4 generic extension point: a read-only, secret-free connection list scoped to the calling plugin's own
* connection-providers, so plugins can implement their own connection switching inside panels/workbenches and
* their own business (the host stays unaware of the purpose).
*/
listConnections?(pluginId: string): Array<{ id: string; name: string; providerId: string; connectionType?: string; readOnly: boolean }>;
/**
* Estimated plan capability metadata for one connection. Requires the plugin
* to declare `host.plans:read`. The host only reads the stored connection
@@ -176,9 +182,18 @@ export class PluginHostBridge {
if (!target || event.source !== target || !isRecord(event.data)) return false;
if (event.data.source !== PLUGIN_MESSAGE_SOURCE || event.data.version !== BRIDGE_VERSION) return false;
if (event.data.type === "ready") {
// Feature flags the SDK advertises at boot; unknown flags are ignored so
// host/plugin can evolve independently.
this.advertisedFeatures = new Set(Array.isArray(event.data.features) ? event.data.features.filter((feature): feature is string => typeof feature === "string") : []);
void this.handleReady();
return true;
}
if (event.data.type === "workbench/close-ack") {
// Two-phase close handshake (§8.3): the plugin released its workbench scope.
this.pendingCloseAck?.();
this.pendingCloseAck = undefined;
return true;
}
if (event.data.type === "shortcut" && event.data.shortcut === "closeTab") {
void this.api.closeTab?.();
return true;
@@ -236,6 +251,39 @@ export class PluginHostBridge {
private initGeneration = 0;
private initSignals = { load: false, ready: false };
private initStarted = false;
private advertisedFeatures = new Set<string>();
private pendingCloseAck?: () => void;
/**
* §8.3/§7.4 two-phase workbench close: give the plugin a chance to release
* its workbench scope (PTY sessions, subscriptions, temporary state) before
* the webview is torn down. Resolves true when the plugin acked; false when
* the handshake is unsupported (legacy SDK advertises no "workbench.close"
* feature) or the ack did not arrive inside the deadline. Either way the
* caller proceeds with teardown.
*/
requestWorkbenchClose(timeoutMs = 400): Promise<boolean> {
if (this.disposed || !this.targetWindow()) return Promise.resolve(false);
const supported = this.advertisedFeatures.has("workbench.close");
if (!supported) {
// Legacy SDK: the message is inert, so do not stall the close on it.
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "workbench/close", workbenchId: "" });
return Promise.resolve(false);
}
const workbenchId = typeof this.context.workbenchId === "string" ? this.context.workbenchId : "";
return new Promise((resolve) => {
let settled = false;
const settle = (acked: boolean) => {
if (settled) return;
settled = true;
this.pendingCloseAck = undefined;
resolve(acked);
};
this.pendingCloseAck = () => settle(true);
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "workbench/close", workbenchId });
setTimeout(() => settle(false), timeoutMs);
});
}
private postInit(): void {
this.post({
@@ -385,6 +433,11 @@ export class PluginHostBridge {
await this.api.reopenConnection(this.plugin.manifest.id, requireProtocolName(input.connectionId, "connectionId"));
return { ok: true };
}
if (method === "host.listConnections") {
this.requirePermission("host.workbench");
if (!this.api.listConnections) throw new Error("Connection enumeration is unavailable on this host");
return this.api.listConnections(this.plugin.manifest.id);
}
if (method === "host.openFilesystem") {
this.requirePermission("host.filesystem");
if (!this.api.openFilesystem) throw new Error("Host filesystem navigation is unavailable");
@@ -695,7 +748,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
.replace(/\u2029/g, "\\u2029");
return `(() => {
const pending = new Map();
const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set(), filedrop: new Set(), dragstate: new Set() };
const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set(), filedrop: new Set(), dragstate: new Set(), close: new Set() };
let sequence = 0;
let context;
let locale = 'en';
@@ -863,6 +916,13 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
onBinary: (listener) => { listeners.binary.add(listener); return () => listeners.binary.delete(listener); },
onContext: (listener) => { listeners.context.add(listener); return () => listeners.context.delete(listener); },
onInit: (listener) => { listeners.init.add(listener); if (context !== undefined) listener(context); return () => listeners.init.delete(listener); },
// §8.3 workbench/close handshake: the host sends workbench/close before
// tearing the webview down; the plugin releases its workbench scope (PTY
// sessions, subscriptions) in the listeners and the SDK acks once every
// listener has settled. The host bounds the wait on its side.
workbench: Object.freeze({
onClose: (listener) => { listeners.close.add(listener); return () => listeners.close.delete(listener); },
}),
decodeBase64: decode,
encodeBase64: encode,
});
@@ -909,6 +969,12 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
const active = message.active === true;
listeners.dragstate.forEach((listener) => listener(active));
document.dispatchEvent(new CustomEvent('dbx-plugin-dragstate', { detail: active }));
} else if (message.type === 'workbench/close') {
const notifyClose = (listener) => Promise.resolve().then(listener).catch(() => undefined);
Promise.allSettled([...listeners.close].map(notifyClose)).finally(() => {
const workbenchId = context && typeof context.workbenchId === 'string' ? context.workbenchId : '';
parent.postMessage({ source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'workbench/close-ack', workbenchId }, '*');
});
}
});
addEventListener('keydown', (event) => {
@@ -918,7 +984,7 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
event.stopPropagation();
parent.postMessage({ source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'shortcut', shortcut: 'closeTab' }, '*');
}, true);
parent.postMessage({ source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'ready' }, '*');
parent.postMessage({ source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'ready', features: ['workbench.close'] }, '*');
})();`;
}
+36
View File
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearPluginUiHtmlCache, getCachedPluginUiHtml, setCachedPluginUiHtml } from "./pluginUiHtmlCache";
describe("pluginUiHtmlCache", () => {
beforeEach(() => {
clearPluginUiHtmlCache();
});
it("returns the stored document for the same plugin id+version key", () => {
setCachedPluginUiHtml("io.dbx.ssh@0.4.88", { html: "<html>a</html>", entryDirectory: "" });
expect(getCachedPluginUiHtml("io.dbx.ssh@0.4.88")).toEqual({ html: "<html>a</html>", entryDirectory: "" });
});
it("evicts oldest entries beyond the limit and refreshes on read", () => {
setCachedPluginUiHtml("k1", { html: "1", entryDirectory: "" });
setCachedPluginUiHtml("k2", { html: "2", entryDirectory: "" });
setCachedPluginUiHtml("k3", { html: "3", entryDirectory: "" });
setCachedPluginUiHtml("k4", { html: "4", entryDirectory: "" });
// Reading k1 makes it the newest entry.
expect(getCachedPluginUiHtml("k1")?.html).toBe("1");
setCachedPluginUiHtml("k5", { html: "5", entryDirectory: "" });
setCachedPluginUiHtml("k6", { html: "6", entryDirectory: "" });
// k2/k3 (oldest, untouched) are evicted; k1 survived via the refresh.
expect(getCachedPluginUiHtml("k1")?.html).toBe("1");
expect(getCachedPluginUiHtml("k2")).toBeUndefined();
expect(getCachedPluginUiHtml("k3")).toBeUndefined();
expect(getCachedPluginUiHtml("k4")?.html).toBe("4");
expect(getCachedPluginUiHtml("k5")?.html).toBe("5");
expect(getCachedPluginUiHtml("k6")?.html).toBe("6");
});
it("treats a version change as a cache miss", () => {
setCachedPluginUiHtml("io.dbx.ssh@0.4.88", { html: "old", entryDirectory: "" });
expect(getCachedPluginUiHtml("io.dbx.ssh@0.4.89")).toBeUndefined();
});
});
@@ -0,0 +1,28 @@
// The inlined plugin ui document is multiple megabytes; rebuilding it per
// workbench instance (every dock panel, every tab) re-reads the entry through
// the bridge and re-parses it each time. Module scope is the point: the cache
// is shared by every PluginWorkbenchHost instance, so only the first boot of a
// plugin version pays the read/decode/inline pipeline.
const cache = new Map<string, { html: string; entryDirectory: string }>();
const LIMIT = 4;
export function getCachedPluginUiHtml(key: string): { html: string; entryDirectory: string } | undefined {
const hit = cache.get(key);
if (!hit) return undefined;
// Map iterates oldest-first; re-inserting makes the just-read entry the
// newest survivor of an eviction.
cache.delete(key);
cache.set(key, hit);
return hit;
}
export function setCachedPluginUiHtml(key: string, value: { html: string; entryDirectory: string }): void {
cache.delete(key);
cache.set(key, value);
while (cache.size > LIMIT) cache.delete(cache.keys().next().value as string);
}
/** Test and plugin-uninstall escape hatch. */
export function clearPluginUiHtmlCache(): void {
cache.clear();
}