feat(plugins): add command and menu contributions with bottom dock
This commit is contained in:
1 parent
67dd9a6229
commit
aefecd3841
37 files changed
+2911
-34
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'] }, '*');
|
||||
})();`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user