mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(plugins): add command and menu contributions with bottom dock
This commit is contained in:
@@ -187,6 +187,7 @@ const SqlLibraryPanel = defineAsyncComponent(() => import("@/components/layout/S
|
||||
const SqlFilePanel = defineAsyncComponent(() => import("@/components/layout/SqlFilePanel.vue"));
|
||||
const DriverStorePage = defineAsyncComponent(() => import("@/components/config/DriverStoreDialog.vue"));
|
||||
const PluginCenterPage = defineAsyncComponent(() => import("@/components/plugins/PluginContributionsPanel.vue"));
|
||||
const PluginBottomDock = defineAsyncComponent(() => import("@/components/plugins/PluginBottomDock.vue"));
|
||||
const EditorSettingsPage = defineAsyncComponent(() => import("@/components/editor/EditorSettingsDialog.vue"));
|
||||
const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue"));
|
||||
const CloseActionPromptDialog = defineAsyncComponent(() => import("@/components/layout/CloseActionPromptDialog.vue"));
|
||||
@@ -4429,6 +4430,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PluginBottomDock v-if="!isDetachedWindowContext" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+5
-2
@@ -8,8 +8,11 @@ const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
// PR-A4: AppSidebar now pulls useQueryStore/useI18n chains through
|
||||
// chains, so partially mock vue-i18n here while keeping the other exports (e.g. createI18n).
|
||||
vi.mock("vue-i18n", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("vue-i18n")>()),
|
||||
useI18n: () => ({ t: (key: string) => key, locale: { value: "en" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
|
||||
@@ -11,6 +11,9 @@ import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue
|
||||
import ToolbarUpdateIcon from "@/components/layout/ToolbarUpdateIcon.vue";
|
||||
import { MAC_TRAFFIC_LIGHT_X, macTrafficLightInsetPaddingForScale, shouldReserveMacTrafficLightInset, useWindowControls } from "@/composables/useWindowControls";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import { setDockVisible, usePluginBottomDock } from "@/lib/plugins/pluginBottomDock";
|
||||
import { usePluginToolbarCommands, type PluginToolbarCommandEntry } from "@/lib/plugins/pluginCommandRegistry";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { isSystemAppThemeMode, type AppThemeMode } from "@/lib/app/appTheme";
|
||||
|
||||
@@ -76,6 +79,28 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
// PR-A4 appToolbar surface (HOST_PLUGIN_UI_SPEC §5.1): plugin commands render as icons
|
||||
// surface (next to Settings/AI); clicking runs the command (presentation: panel -> the global bottom dock),
|
||||
// clicking again collapses an open dock command.
|
||||
const { entries: pluginCommandEntries, open: openPluginCommand } = usePluginToolbarCommands();
|
||||
const { entries: pluginDockEntries, visible: dockVisible } = usePluginBottomDock();
|
||||
// The toolbar icon toggles panel visibility (panel hide keeps the webviews
|
||||
// mounted, so sessions and height survive); with a hidden-but-populated dock
|
||||
// the first click just restores it, and only an empty dock runs the command
|
||||
// (otherwise "getting the panel back" would keep spawning new terminals).
|
||||
function togglePluginCommand(entry: PluginToolbarCommandEntry) {
|
||||
if (dockVisible.value) {
|
||||
setDockVisible(false);
|
||||
return;
|
||||
}
|
||||
if (pluginDockEntries.value.length) {
|
||||
setDockVisible(true);
|
||||
return;
|
||||
}
|
||||
const result = openPluginCommand(entry);
|
||||
if (result.error) toast(result.error, 5000);
|
||||
}
|
||||
const settingsStore = useSettingsStore();
|
||||
const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems);
|
||||
const showToolbarUpdateEntry = computed(() => toolbarItems.value.checkUpdates || props.hasUpdateAvailable);
|
||||
@@ -771,6 +796,16 @@ const toolbarStyle = computed(() => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>GitHub</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-for="entry in pluginCommandEntries" :key="`${entry.pluginId}.${entry.commandId}`">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="toolbar-action-button relative h-8 w-8 shrink-0" :class="{ 'toolbar-action-button--active bg-accent': dockVisible }" :aria-label="entry.label" @click="togglePluginCommand(entry)">
|
||||
<PluginIcon :plugin-id="entry.pluginId" :icon="entry.icon" class="toolbar-action-icon h-4 w-4" />
|
||||
<span v-if="dockVisible" class="toolbar-panel-status" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ entry.label }} · {{ entry.pluginName }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<!-- /rightWrapper -->
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, ref, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ listPlugins: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({ listPlugins: mocks.listPlugins }));
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) }));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [] }) }));
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: defineComponent(
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("button", slots.default?.()),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: defineComponent(
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
),
|
||||
TooltipTrigger: defineComponent(
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
),
|
||||
TooltipContent: defineComponent(
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
),
|
||||
}));
|
||||
vi.mock("./PluginIcon.vue", () => ({ default: defineComponent(() => () => h("span")) }));
|
||||
// Mirrors the real PluginWorkbenchHost, whose title computed dereferences
|
||||
// props.contribution.label unconditionally — an undefined contribution must
|
||||
// never reach it, otherwise the whole dock subtree crashes during render.
|
||||
vi.mock("./PluginWorkbenchHost.vue", () => ({
|
||||
default: defineComponent({
|
||||
props: {
|
||||
plugin: { type: Object, required: true },
|
||||
contribution: { type: Object, required: true },
|
||||
context: { type: Object, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
return () => h("div", { "data-workbench-host-stub": "" }, `${props.plugin.manifest.name} · ${props.contribution.label}`);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import PluginBottomDock from "./PluginBottomDock.vue";
|
||||
import * as dock from "@/lib/plugins/pluginBottomDock";
|
||||
|
||||
function installedPlugin(contributionId: string): InstalledPlugin {
|
||||
return {
|
||||
manifest: { id: "io.dbx.sample", name: "Sample", version: "1.0.0", permissions: [], drivers: [], contributions: [{ type: "workbench", id: contributionId, label: "Sample panel" }] },
|
||||
compatibility: { compatible: true },
|
||||
};
|
||||
}
|
||||
|
||||
describe("PluginBottomDock workbench contribution guard", () => {
|
||||
let app: App<Element> | undefined;
|
||||
let root: HTMLDivElement;
|
||||
let renderError: unknown;
|
||||
const entryIds: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root.remove();
|
||||
for (const id of entryIds.splice(0)) dock.closePluginDockEntry(id);
|
||||
dock.setDockVisible(false);
|
||||
});
|
||||
|
||||
async function mountDock() {
|
||||
dock.setDockVisible(true);
|
||||
const application = createApp(PluginBottomDock);
|
||||
renderError = undefined;
|
||||
application.config.errorHandler = (error) => {
|
||||
renderError = error;
|
||||
};
|
||||
app = application;
|
||||
application.mount(root);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
it("renders the workbench host when the entry's contribution is still live", async () => {
|
||||
mocks.listPlugins.mockResolvedValue([installedPlugin("sample.panel")]);
|
||||
entryIds.push(dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", title: "Sample terminal" }));
|
||||
|
||||
await mountDock();
|
||||
|
||||
expect(renderError).toBeUndefined();
|
||||
expect(root.querySelector("[data-workbench-host-stub]")?.textContent).toBe("Sample · Sample panel");
|
||||
});
|
||||
|
||||
it("degrades to an empty frame instead of crashing when the plugin no longer declares the entry's workbench contribution", async () => {
|
||||
// Plugin upgrade/downgrade replaced the workbench id the entry was opened with.
|
||||
mocks.listPlugins.mockResolvedValue([installedPlugin("sample.renamed")]);
|
||||
entryIds.push(dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", title: "Sample terminal" }));
|
||||
|
||||
await mountDock();
|
||||
|
||||
expect(renderError).toBeUndefined();
|
||||
expect(root.querySelector("[data-workbench-host-stub]")).toBeNull();
|
||||
// The tab strip (and its close affordance) survives so the stale entry stays closable.
|
||||
expect(root.querySelector("[data-plugin-dock-tabs]")?.textContent).toContain("Sample terminal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,443 @@
|
||||
<script setup lang="ts">
|
||||
// PR-A4/P2 global bottom panel dock (HOST_PLUGIN_UI_SPEC §8.3) — a generic host container:
|
||||
// it only provides the panel frame (tab strip, drag-resize height, collapse/maximize/hide) and hosts any plugin's
|
||||
// panel webviews, with zero plugin business inside; multi-terminal/shell selection/connection switching all live in the plugin
|
||||
// the plugin's own panel page via the bridge openWorkbench, which adds another dock entry).
|
||||
// Each entry owns a host-stable workbenchId; v-show keeps sessions alive while switching tabs.
|
||||
import { computed, nextTick, onScopeDispose, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ChevronDown, ChevronUp, Maximize2, Minimize2, Plus, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import {
|
||||
activatePluginDockEntry,
|
||||
addPluginDockEntry,
|
||||
closePluginDockEntry,
|
||||
DOCK_MAX_VIEWPORT_RATIO,
|
||||
DOCK_MIN_HEIGHT_PX,
|
||||
movePluginDockEntry,
|
||||
persistDockHeight,
|
||||
renamePluginDockEntry,
|
||||
restoreDockHeight,
|
||||
setDockMaximized,
|
||||
setDockVisible,
|
||||
usePluginBottomDock,
|
||||
type PluginDockEntry,
|
||||
} from "@/lib/plugins/pluginBottomDock";
|
||||
import { useDockResize } from "@/composables/useDockResize";
|
||||
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
|
||||
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
// Tab strip interactions (drag reorder + double-click rename).
|
||||
const dragEntryId = ref<string | null>(null);
|
||||
const renamingEntryId = ref<string | null>(null);
|
||||
const renameDraft = ref("");
|
||||
const renameInput = ref<HTMLInputElement | null>(null);
|
||||
function setRenameInputRef(element: unknown) {
|
||||
renameInput.value = element as HTMLInputElement | null;
|
||||
}
|
||||
watch(renamingEntryId, async (id) => {
|
||||
if (!id) return;
|
||||
await nextTick();
|
||||
renameInput.value?.select();
|
||||
});
|
||||
function startRename(entry: PluginDockEntry) {
|
||||
renamingEntryId.value = entry.id;
|
||||
renameDraft.value = entry.title;
|
||||
}
|
||||
function commitRename() {
|
||||
if (!renamingEntryId.value) return;
|
||||
renamePluginDockEntry(renamingEntryId.value, renameDraft.value);
|
||||
renamingEntryId.value = null;
|
||||
}
|
||||
function cancelRename() {
|
||||
renamingEntryId.value = null;
|
||||
}
|
||||
function onTabDragStart(entry: PluginDockEntry, event: DragEvent) {
|
||||
dragEntryId.value = entry.id;
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData("text/plain", entry.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
}
|
||||
// Live reorder while hovering the target tab: the dragged entry jumps to the
|
||||
// target's slot immediately, which keeps the gesture simple and predictable.
|
||||
function onTabDragOver(entry: PluginDockEntry) {
|
||||
if (!dragEntryId.value || dragEntryId.value === entry.id) return;
|
||||
const toIndex = entries.value.findIndex((candidate) => candidate.id === entry.id);
|
||||
if (toIndex < 0) return;
|
||||
movePluginDockEntry(dragEntryId.value, toIndex);
|
||||
}
|
||||
function onTabDragEnd() {
|
||||
dragEntryId.value = null;
|
||||
}
|
||||
const queryStore = useQueryStore();
|
||||
const { entries, activeEntryId, visible, maximized } = usePluginBottomDock();
|
||||
const collapsed = ref(false);
|
||||
// The dragged height persists as UI chrome (§8.4 is untouched: dock entries
|
||||
// themselves are never restored across restarts).
|
||||
const dockHeight = ref(restoreDockHeight(window.innerHeight));
|
||||
watch(dockHeight, (height) => persistDockHeight(height));
|
||||
const plugins = ref<InstalledPlugin[]>([]);
|
||||
|
||||
const activeEntry = computed(() => entries.value.find((entry) => entry.id === activeEntryId.value) ?? null);
|
||||
const activeCommand = computed(() => {
|
||||
const entry = activeEntry.value;
|
||||
// Connection entries carry their source commandId too, so the "+" picker
|
||||
// (replay / launch options / connection targets) is available on every panel.
|
||||
if (!entry || !entry.commandId) return null;
|
||||
return createFrontendPluginRegistry(plugins.value).findCommand(entry.pluginId, entry.commandId)?.contribution ?? null;
|
||||
});
|
||||
|
||||
watch(entries, () => void loadPluginData(), { deep: true, immediate: true });
|
||||
watch([activeEntry, activeCommand], () => void loadLaunchOptions());
|
||||
const onPluginsChanged = () => void loadPluginData();
|
||||
window.addEventListener("dbx:plugins-changed", onPluginsChanged);
|
||||
onScopeDispose(() => window.removeEventListener("dbx:plugins-changed", onPluginsChanged));
|
||||
|
||||
async function loadPluginData() {
|
||||
if (!entries.value.length) {
|
||||
plugins.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
plugins.value = await api.listPlugins();
|
||||
} catch {
|
||||
plugins.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function definitionFor(pluginId: string): InstalledPlugin | undefined {
|
||||
return plugins.value.find((candidate) => candidate.manifest.id === pluginId);
|
||||
}
|
||||
|
||||
// A live entry can outlive its contribution (plugin upgrade/downgrade changed
|
||||
// the workbench id, or dropped the contribution entirely). The host renders
|
||||
// props.contribution.label unconditionally, so return null and let the v-if
|
||||
// degrade the panel to an empty frame instead of crashing — same degradation
|
||||
// as the missing-plugin case; the tab (and its close button) stays available.
|
||||
function workbenchContributionFor(entry: PluginDockEntry): PluginWorkbenchContribution | null {
|
||||
const definition = definitionFor(entry.pluginId);
|
||||
if (!definition) return null;
|
||||
return (definition.manifest.contributions || []).find((candidate): candidate is PluginWorkbenchContribution => candidate.type === "workbench" && candidate.id === entry.workbenchContributionId) ?? null;
|
||||
}
|
||||
|
||||
// Generic "+" picker (extension-point driven, zero business in the host):
|
||||
// - the command itself (replay),
|
||||
// - dynamic entries from the declared sidecar options_action (e.g. shell types),
|
||||
// - when connection_targets is on, the plugin's own saved connections.
|
||||
const connectionStore = useConnectionStore();
|
||||
const launchOptionEntries = ref<Array<{ key: string; label: string; description?: string; context?: Record<string, unknown> }>>([]);
|
||||
const launchOptionsLoading = ref(false);
|
||||
|
||||
const activeAction = computed(() => (activeCommand.value?.action.type === "open-workbench" ? activeCommand.value.action : null));
|
||||
const activePluginProviders = computed(() => {
|
||||
const pluginId = activeEntry.value?.pluginId;
|
||||
const plugin = pluginId ? definitionFor(pluginId) : undefined;
|
||||
return new Set((plugin?.manifest.contributions || []).filter((candidate) => candidate.type === "connection-provider").map((candidate) => candidate.id));
|
||||
});
|
||||
|
||||
async function loadLaunchOptions() {
|
||||
const action = activeAction.value;
|
||||
const pluginId = activeEntry.value?.pluginId;
|
||||
launchOptionEntries.value = [];
|
||||
if (!action || !pluginId || !action.options_action) return;
|
||||
launchOptionsLoading.value = true;
|
||||
try {
|
||||
const result = await api.invokePlugin<{ entries?: Array<{ label: string; description?: string; context?: Record<string, unknown> }> }>(pluginId, action.options_action, {});
|
||||
launchOptionEntries.value = (result?.entries ?? []).map((entry, index) => ({ key: `opt:${index}`, label: entry.label, description: entry.description, context: entry.context }));
|
||||
} catch (cause) {
|
||||
console.warn("[DBX][plugin:dock] launch options unavailable", cause);
|
||||
launchOptionEntries.value = [];
|
||||
} finally {
|
||||
launchOptionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const connectionTargets = computed(() => {
|
||||
if (!activeAction.value?.connection_targets) return [];
|
||||
const providers = activePluginProviders.value;
|
||||
return connectionStore.connections.filter((connection) => providers.has(connection.plugin_connection_provider ?? "")).map((connection) => ({ key: `conn:${connection.id}`, label: connection.name || connection.id, connection }));
|
||||
});
|
||||
|
||||
function onPlusAction(value: string) {
|
||||
if (value === "replay") {
|
||||
rerunActiveCommand();
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("opt:")) {
|
||||
const index = Number(value.slice(4));
|
||||
const option = launchOptionEntries.value[index];
|
||||
if (!option) return;
|
||||
const entry = activeEntry.value;
|
||||
const command = activeCommand.value;
|
||||
if (!entry || !command) return;
|
||||
const id = addPluginDockEntry({
|
||||
pluginId: entry.pluginId,
|
||||
workbenchContributionId: entry.workbenchContributionId,
|
||||
kind: "command",
|
||||
commandId: command.id,
|
||||
title: option.label,
|
||||
icon: command.icon,
|
||||
commandContext: option.context ?? {},
|
||||
});
|
||||
activatePluginDockEntry(id);
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("conn:")) {
|
||||
const connectionId = value.slice("conn:".length);
|
||||
const target = connectionTargets.value.find((candidate) => candidate.key === `conn:${connectionId}`);
|
||||
const connection = target?.connection;
|
||||
const entry = activeEntry.value;
|
||||
const command = activeCommand.value;
|
||||
if (!connection || !entry || !command) return;
|
||||
const id = addPluginDockEntry({
|
||||
pluginId: entry.pluginId,
|
||||
workbenchContributionId: entry.workbenchContributionId,
|
||||
kind: "connection",
|
||||
commandId: command.id,
|
||||
title: connection.name || connection.id,
|
||||
icon: activeCommand.value?.icon,
|
||||
commandContext: {
|
||||
connectionId: connection.id,
|
||||
providerId: connection.plugin_connection_provider,
|
||||
connectionType: connection.plugin_connection_type,
|
||||
// §8.3 面板加载生命周期:标记宿主已在点击时预拨号,插件面板就绪后
|
||||
// 跳过 force 重开、直接开会话。
|
||||
connectionPreconnected: true,
|
||||
connection: {
|
||||
id: connection.id,
|
||||
name: connection.name,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
username: connection.username,
|
||||
readOnly: connection.read_only === true,
|
||||
},
|
||||
},
|
||||
});
|
||||
// 点击即拨号:SSH 握手与面板 webview 引导(数秒)并行,面板就绪即会话就绪。
|
||||
// 无 force:同连接已有存活会话时这是健康检查级 no-op,不会打断它。
|
||||
void useConnectionStore()
|
||||
.ensureConnected(connection.id, { activate: false })
|
||||
.catch(() => undefined);
|
||||
activatePluginDockEntry(id);
|
||||
}
|
||||
}
|
||||
|
||||
function rerunActiveCommand() {
|
||||
const entry = activeEntry.value;
|
||||
const command = activeCommand.value;
|
||||
if (!entry || !command) return;
|
||||
const result = executePluginCommand(createFrontendPluginRegistry(plugins.value), queryStore, entry.pluginId, command.id);
|
||||
if (result.error) console.warn("[DBX][plugin:dock]", result.error);
|
||||
}
|
||||
|
||||
// A dock-hosted webview asking for another panel via the bridge openWorkbench: the host rebuilds the authoritative
|
||||
// context (dropping plugin-supplied reserved fields) and adds one generic panel entry.
|
||||
function onPanelOpenWorkbench(entry: (typeof entries.value)[number], _contributionId: string, childContext?: Record<string, unknown>) {
|
||||
const payload = childContext && typeof childContext === "object" && !Array.isArray(childContext) ? { ...childContext } : {};
|
||||
delete payload.workbenchId;
|
||||
delete payload.restored;
|
||||
delete payload.surface;
|
||||
const id = addPluginDockEntry({
|
||||
pluginId: entry.pluginId,
|
||||
workbenchContributionId: entry.workbenchContributionId,
|
||||
kind: "command",
|
||||
title: entry.title,
|
||||
commandContext: payload,
|
||||
});
|
||||
activatePluginDockEntry(id);
|
||||
}
|
||||
|
||||
// §8.3/§7.4 two-phase close: removing an entry first asks its panel webview to
|
||||
// release the workbench scope (PTY sessions, subscriptions) and waits for the
|
||||
// bridge-bounded handshake before the iframe is unmounted.
|
||||
const workbenchHosts = new Map<string, { requestClose: () => Promise<boolean> }>();
|
||||
function setWorkbenchHostRef(entryId: string) {
|
||||
return (element: unknown) => {
|
||||
const host = element as { requestClose: () => Promise<boolean> } | null;
|
||||
if (host) workbenchHosts.set(entryId, host);
|
||||
else workbenchHosts.delete(entryId);
|
||||
};
|
||||
}
|
||||
|
||||
async function closeEntry(entryId: string) {
|
||||
try {
|
||||
await workbenchHosts.get(entryId)?.requestClose();
|
||||
} catch {
|
||||
// Teardown proceeds regardless of a broken handshake.
|
||||
}
|
||||
closePluginDockEntry(entryId);
|
||||
}
|
||||
|
||||
// Hide the panel: terminal sessions survive (VS Code semantics); the toolbar icon restores it.
|
||||
function hideDock() {
|
||||
collapsed.value = false;
|
||||
setDockMaximized(false);
|
||||
setDockVisible(false);
|
||||
}
|
||||
|
||||
// Drag the top edge to resize the height (min 140px, up to the shared maximize
|
||||
// bound). Dragging always exits maximized/collapsed: the start height is the
|
||||
// currently rendered pixel height, so the transition is seamless. The
|
||||
// composable owns pointer capture, rAF coalescing and listener cleanup — see
|
||||
// useDockResize for why capture is load-bearing over the plugin iframes.
|
||||
const dockRoot = ref<HTMLElement>();
|
||||
const { startResize } = useDockResize({
|
||||
dockHeight,
|
||||
minHeight: DOCK_MIN_HEIGHT_PX,
|
||||
maxHeightRatio: DOCK_MAX_VIEWPORT_RATIO,
|
||||
dockElement: () => dockRoot.value ?? null,
|
||||
});
|
||||
|
||||
function onResizeHandlePointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0) return;
|
||||
// Leave maximized/collapsed before the drag measures the rendered height:
|
||||
// the composable starts from the currently rendered px height, so the
|
||||
// transition stays seamless.
|
||||
setDockMaximized(false);
|
||||
collapsed.value = false;
|
||||
startResize(event);
|
||||
}
|
||||
|
||||
// "+" picker menu: anchored below the + button, bottom-stuck inside the dock,
|
||||
// growing upward with content (capped by the dock body), closed by selecting
|
||||
// an item or clicking anywhere else.
|
||||
const plusOpen = ref(false);
|
||||
// Generic list filter for the "+" picker: purely client-side label matching so
|
||||
// any plugin's long option/target list stays usable without the host knowing
|
||||
// what the entries mean.
|
||||
const plusFilter = ref("");
|
||||
const PLUS_FILTER_THRESHOLD = 8;
|
||||
const plusItemCount = computed(() => 1 + launchOptionEntries.value.length + connectionTargets.value.length);
|
||||
const plusQuery = computed(() => plusFilter.value.trim().toLowerCase());
|
||||
function plusMatches(label: string): boolean {
|
||||
return !plusQuery.value || label.toLowerCase().includes(plusQuery.value);
|
||||
}
|
||||
const visibleLaunchOptions = computed(() => launchOptionEntries.value.filter((option) => plusMatches(option.label)));
|
||||
const visibleConnectionTargets = computed(() => connectionTargets.value.filter((target) => plusMatches(target.label)));
|
||||
const plusRoot = ref<HTMLElement>();
|
||||
function togglePlusMenu() {
|
||||
plusOpen.value = !plusOpen.value;
|
||||
plusFilter.value = "";
|
||||
if (plusOpen.value) void loadLaunchOptions();
|
||||
}
|
||||
function closePlusMenu() {
|
||||
plusOpen.value = false;
|
||||
}
|
||||
const onPlusMenuOutsidePointerDown = (event: PointerEvent) => {
|
||||
const root = plusRoot.value;
|
||||
if (plusOpen.value && root && !root.contains(event.target as Node)) closePlusMenu();
|
||||
};
|
||||
window.addEventListener("pointerdown", onPlusMenuOutsidePointerDown, true);
|
||||
onScopeDispose(() => window.removeEventListener("pointerdown", onPlusMenuOutsidePointerDown, true));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- v-show, not v-if (HOST_PLUGIN_UI_SPEC §8.3): hiding the panel must only
|
||||
hide the UI — the entry webviews (and the user's dragged height) stay
|
||||
mounted and alive across hide/show. -->
|
||||
<div v-show="visible" ref="dockRoot" data-plugin-bottom-dock class="relative z-10 flex shrink-0 flex-col overflow-hidden border-t bg-background" :style="{ height: maximized ? `${DOCK_MAX_VIEWPORT_RATIO * 100}vh` : collapsed ? '2.25rem' : `${dockHeight}px` }">
|
||||
<div data-plugin-dock-resize-handle class="absolute inset-x-0 top-0 z-10 h-1.5 cursor-row-resize hover:bg-primary/30" @pointerdown="onResizeHandlePointerDown" />
|
||||
<div class="flex h-9 shrink-0 items-center gap-1 border-b bg-muted/30 pl-2 pr-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto" data-plugin-dock-tabs>
|
||||
<button
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="group flex h-7 min-w-0 max-w-40 shrink items-center gap-1 overflow-hidden rounded-md px-2 text-xs"
|
||||
:class="[entry.id === activeEntryId ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground', dragEntryId && dragEntryId !== entry.id ? 'opacity-60' : '']"
|
||||
:title="entry.title"
|
||||
:draggable="renamingEntryId !== entry.id"
|
||||
@click="activatePluginDockEntry(entry.id)"
|
||||
@dblclick="startRename(entry)"
|
||||
@dragstart="onTabDragStart(entry, $event)"
|
||||
@dragover.prevent="onTabDragOver(entry)"
|
||||
@dragend="onTabDragEnd"
|
||||
@drop.prevent="onTabDragEnd"
|
||||
>
|
||||
<PluginIcon :plugin-id="entry.pluginId" :icon="entry.icon" class="h-3.5 w-3.5 shrink-0" />
|
||||
<input
|
||||
v-if="renamingEntryId === entry.id"
|
||||
:ref="setRenameInputRef"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 bg-transparent text-xs outline-none"
|
||||
spellcheck="false"
|
||||
@click.stop
|
||||
@dblclick.stop
|
||||
@keydown.enter.prevent="commitRename"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="commitRename"
|
||||
/>
|
||||
<span v-else class="min-w-0 flex-1 truncate text-left">{{ entry.title }}</span>
|
||||
<span v-if="renamingEntryId !== entry.id" class="ml-0.5 shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-background/80 group-hover:opacity-100" role="button" :aria-label="t('pluginDock.close')" @click.stop="closeEntry(entry.id)">
|
||||
<X class="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="activeCommand" ref="plusRoot" class="relative">
|
||||
<Tooltip :delay-duration="200">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :aria-label="t('pluginDock.newTerminal')" :aria-expanded="plusOpen" @click="togglePlusMenu">
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t("pluginDock.newTerminal") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div v-if="plusOpen" data-plugin-dock-plus-menu class="absolute right-0 top-full z-30 mt-1 max-h-[50vh] w-64 overflow-y-auto rounded-md border bg-background p-1 shadow-lg" role="menu">
|
||||
<!-- Generic list filter (appears only for long lists): the host filters
|
||||
by label without knowing what the entries mean. -->
|
||||
<input v-if="plusItemCount > PLUS_FILTER_THRESHOLD" v-model="plusFilter" class="mb-1 w-full rounded-md border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary/40" :placeholder="t('pluginDock.filter')" spellcheck="false" @keydown.stop />
|
||||
<button v-if="plusMatches(t('pluginDock.newTerminal'))" class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted" role="menuitem" @click="onPlusAction('replay')">
|
||||
<Plus class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ t("pluginDock.newTerminal") }}</span>
|
||||
</button>
|
||||
<button v-for="option in visibleLaunchOptions" :key="option.key" class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted" role="menuitem" @click="onPlusAction(option.key)">
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
</button>
|
||||
<button v-for="target in visibleConnectionTargets" :key="target.key" class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted" role="menuitem" :title="target.connection.name" @click="onPlusAction(target.key)">
|
||||
<span class="truncate">{{ t("pluginDock.connectionTerminal") }} · {{ target.label }}</span>
|
||||
</button>
|
||||
<div v-if="plusQuery && !plusMatches(t('pluginDock.newTerminal')) && !visibleLaunchOptions.length && !visibleConnectionTargets.length" class="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{{ t("pluginDock.noMatch") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip :delay-duration="200">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="maximized ? t('pluginDock.restore') : t('pluginDock.maximize')" :aria-label="maximized ? t('pluginDock.restore') : t('pluginDock.maximize')" @click="setDockMaximized(!maximized)">
|
||||
<Minimize2 v-if="maximized" class="h-3.5 w-3.5" />
|
||||
<Maximize2 v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ maximized ? t("pluginDock.restore") : t("pluginDock.maximize") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="collapsed ? t('pluginDock.expand') : t('pluginDock.collapse')" :aria-label="collapsed ? t('pluginDock.expand') : t('pluginDock.collapse')" @click="collapsed = !collapsed">
|
||||
<ChevronUp v-if="collapsed" class="h-3.5 w-3.5" />
|
||||
<ChevronDown v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('pluginDock.hide')" :aria-label="t('pluginDock.hide')" @click="hideDock">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<div v-for="entry in entries" v-show="entry.id === activeEntryId && !collapsed" :key="entry.id" class="h-full w-full">
|
||||
<PluginWorkbenchHost
|
||||
v-if="workbenchContributionFor(entry)"
|
||||
:ref="setWorkbenchHostRef(entry.id)"
|
||||
:plugin="definitionFor(entry.pluginId)!"
|
||||
:contribution="workbenchContributionFor(entry)!"
|
||||
:context="entry.context"
|
||||
@close-tab="closeEntry(entry.id)"
|
||||
@open-workbench="(_pluginId, contributionId, context) => onPanelOpenWorkbench(entry, contributionId, context)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,6 +18,7 @@ import { loadPinnedPluginIds, savePinnedPluginIds, sortPluginsPinnedFirst } from
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
|
||||
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
|
||||
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
|
||||
import {
|
||||
beaconPluginInstall,
|
||||
buildInstalledUpdateIndex,
|
||||
@@ -135,6 +136,8 @@ const selectedEntry = computed(() => connectionProviders.value.find((entry) => e
|
||||
const selectedDefinition = computed(() => definitions.value.find((definition) => definition.plugin.manifest.id === selectedPluginId.value) || null);
|
||||
const selectedWorkbenches = computed(() => registry.value.listWorkbenches().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
const selectedFilesystems = computed(() => registry.value.listFilesystemProviders().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
// PR-A4: plugins declaring commands drive their quick entries via commands (workbench opens route to the command; the SFTP browse entry is retired).
|
||||
const selectedHasCommands = computed(() => registry.value.listCommands().some((entry) => entry.plugin.manifest.id === selectedPluginId.value));
|
||||
const providerConnections = computed(() => {
|
||||
const entry = selectedEntry.value;
|
||||
if (!entry) return [];
|
||||
@@ -294,6 +297,7 @@ async function installMarketplaceListing(listing: MarketplacePluginListing, opti
|
||||
// installed list); notifyComponentUpdatesChanged drives the App-level update-center badge.
|
||||
notifyComponentPluginsUpdated();
|
||||
notifyComponentUpdatesChanged();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
} catch (cause) {
|
||||
toast(translateBackendError(t, cause), 8000);
|
||||
@@ -320,6 +324,7 @@ function reportBatchSummary(outcome: { succeeded: unknown[]; failed: { name: str
|
||||
async function refreshAfterBatch() {
|
||||
try {
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
} catch (cause) {
|
||||
error.value = [error.value, t("pluginPlatform.batchRefreshFailed", { error: cause instanceof Error ? cause.message : String(cause) })].filter(Boolean).join("\n");
|
||||
}
|
||||
@@ -610,6 +615,14 @@ async function openFilesystem(pluginId: string, providerId: string, label: strin
|
||||
}
|
||||
|
||||
function openWorkbench(pluginId: string, contributionId: string, label: string) {
|
||||
// PR-A4: when the plugin declares a command targeting this workbench, the entry opens through it
|
||||
// host-authored context — the SSH plugin lands directly in the local terminal); otherwise the legacy behavior applies.
|
||||
const command = registry.value.findCommandTargetingWorkbench(pluginId, contributionId);
|
||||
if (command) {
|
||||
const result = executePluginCommand(registry.value, queryStore, pluginId, command.id);
|
||||
if (result.error) toast(result.error, 5000);
|
||||
return;
|
||||
}
|
||||
const connection = selectedConnection.value;
|
||||
queryStore.openPluginWorkbench(pluginId, contributionId, {
|
||||
title: connection?.name || label,
|
||||
@@ -666,6 +679,7 @@ async function finishInstall(result: PluginInstallResult) {
|
||||
notifyComponentPluginsUpdated();
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
notifyComponentUpdatesChanged();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
activeSection.value = "installed";
|
||||
}
|
||||
@@ -813,6 +827,7 @@ async function rollbackSelectedPlugin() {
|
||||
clearPluginIconCache();
|
||||
installedPlugins.value = await api.listPlugins();
|
||||
notifyComponentUpdatesChanged();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
selectPlugin(result.plugin.manifest.id);
|
||||
} catch (cause) {
|
||||
toast(translateBackendError(t, cause), 8000);
|
||||
@@ -828,6 +843,7 @@ async function uninstallSelectedPlugin() {
|
||||
try {
|
||||
installedPlugins.value = await api.uninstallPlugin(definition.plugin.manifest.id);
|
||||
notifyComponentUpdatesChanged();
|
||||
window.dispatchEvent(new CustomEvent("dbx:plugins-changed"));
|
||||
clearPluginIconCache();
|
||||
toast(t("pluginPlatform.uninstallSuccess", { name: definition.plugin.manifest.name }));
|
||||
selectFirstProvider();
|
||||
@@ -1341,7 +1357,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedFilesystems.length" class="space-y-2">
|
||||
<div v-if="selectedFilesystems.length && !selectedHasCommands" class="space-y-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ t("pluginPlatform.filesystemProviders") }}</div>
|
||||
<div v-for="entry in selectedFilesystems" :key="entry.contribution.id" class="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance";
|
||||
import { downloadPluginFile, cancelPluginDownload } from "@/lib/plugins/pluginFileDownload";
|
||||
import type { InstalledPlugin, PluginUiContribution } from "@/types/database";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { getCachedPluginUiHtml, setCachedPluginUiHtml } from "@/lib/plugins/pluginUiHtmlCache";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -379,6 +380,20 @@ function createBridge() {
|
||||
openWorkbench: async (pluginId, contributionId, context, options) => emit("openWorkbench", pluginId, contributionId, context, options),
|
||||
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
|
||||
reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId),
|
||||
// PR-A4 generic extension point: a read-only, secret-free, plugin-scoped connection list (for in-panel connection switching).
|
||||
listConnections: (ownerPluginId) => {
|
||||
const providerIds = new Set((props.plugin.manifest.contributions || []).filter((candidate) => candidate.type === "connection-provider").map((candidate) => candidate.id));
|
||||
if (ownerPluginId !== props.plugin.manifest.id) return [];
|
||||
return useConnectionStore()
|
||||
.connections.filter((connection) => providerIds.has(connection.plugin_connection_provider ?? ""))
|
||||
.map((connection) => ({
|
||||
id: connection.id,
|
||||
name: connection.name,
|
||||
providerId: connection.plugin_connection_provider ?? "",
|
||||
connectionType: connection.plugin_connection_type,
|
||||
readOnly: connection.read_only === true,
|
||||
}));
|
||||
},
|
||||
// Both plan calls carry the plugin's declared `host.plans:read` gate in the
|
||||
// bridge; the backend owns EXPLAIN generation, the timeout, and the plan cap.
|
||||
getPlanCapabilities: (connectionId) => api.getPluginPlanCapabilities(connectionId),
|
||||
@@ -468,19 +483,34 @@ function localUiAssetPath(source: string): string | undefined {
|
||||
}
|
||||
|
||||
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<{ html: string; entryDirectory: string }> {
|
||||
// Shipped ui builds usually inline every asset into one HTML document.
|
||||
// Parsing and re-serializing a multi-megabyte document is pure overhead when
|
||||
// there is nothing local to inline — pre-check before touching DOMParser.
|
||||
if (!/<script\b[^>]*\bsrc=/i.test(html) && !/<link\b[^>]*rel=["']?stylesheet/i.test(html)) {
|
||||
return { html, entryDirectory: "" };
|
||||
}
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const resources = [...document.querySelectorAll("script[src], link[rel='stylesheet'][href]")];
|
||||
// Dynamic-import chunks and CSS url() references live next to the entry
|
||||
// script; its directory is the <base> the sandbox document needs to resolve
|
||||
// them through the dbx-plugin scheme.
|
||||
let entryDirectory = "";
|
||||
for (const resource of resources) {
|
||||
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
|
||||
const path = source ? localUiAssetPath(source) : undefined;
|
||||
if (!path) continue;
|
||||
if (!entryDirectory) entryDirectory = path.split("/").slice(0, -1).join("/");
|
||||
const asset = await api.readPluginUiAsset(pluginId, path);
|
||||
const content = new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0)));
|
||||
// Fetch every referenced asset concurrently — these are bridge round-trips
|
||||
// into the sidecar, and panels reopen this path on every workbench (re)load.
|
||||
const fetched = await Promise.all(
|
||||
resources.map((resource) => {
|
||||
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
|
||||
const path = source ? localUiAssetPath(source) : undefined;
|
||||
if (!path) return Promise.resolve({ resource, content: null });
|
||||
if (!entryDirectory) entryDirectory = path.split("/").slice(0, -1).join("/");
|
||||
return api.readPluginUiAsset(pluginId, path).then((asset) => ({
|
||||
resource,
|
||||
content: new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0))),
|
||||
}));
|
||||
}),
|
||||
);
|
||||
for (const { resource, content } of fetched) {
|
||||
if (content === null) continue;
|
||||
if (resource.tagName === "SCRIPT") {
|
||||
const script = document.createElement("script");
|
||||
for (const attribute of [...resource.attributes]) {
|
||||
@@ -509,6 +539,8 @@ function pluginUiBaseUrl(pluginId: string, entryDirectory: string): string | und
|
||||
return entryDirectory ? `${origin}${entryDirectory}/` : origin;
|
||||
}
|
||||
|
||||
// Inlined plugin ui html per `${pluginId}:${version}` (see loadWorkbench).
|
||||
|
||||
async function loadWorkbench() {
|
||||
const generation = ++loadGeneration;
|
||||
bridge?.dispose();
|
||||
@@ -518,11 +550,23 @@ async function loadWorkbench() {
|
||||
error.value = "";
|
||||
try {
|
||||
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
|
||||
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
|
||||
const { html, entryDirectory } = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
// The read/decode/inline pipeline over a multi-megabyte ui build dominates
|
||||
// workbench open time; cache the inlined html per plugin id+version so
|
||||
// reopening panels (new dock entries, workbench reloads) skips it. Theme
|
||||
// is applied per load via the sandbox document, so the cache never pins a
|
||||
// stale appearance.
|
||||
const htmlCacheKey = `${props.plugin.manifest.id}:${props.plugin.manifest.version}`;
|
||||
let cachedHtml = getCachedPluginUiHtml(htmlCacheKey);
|
||||
if (!cachedHtml) {
|
||||
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
|
||||
const inlined = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
|
||||
if (disposed || generation !== loadGeneration) return;
|
||||
cachedHtml = inlined;
|
||||
setCachedPluginUiHtml(htmlCacheKey, cachedHtml);
|
||||
}
|
||||
const { html, entryDirectory } = cachedHtml;
|
||||
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions, currentBridgeTheme(), {
|
||||
baseUrl: pluginUiBaseUrl(props.plugin.manifest.id, entryDirectory),
|
||||
});
|
||||
@@ -596,9 +640,22 @@ watch(
|
||||
() => bridge?.updateTheme(currentBridgeTheme()),
|
||||
);
|
||||
|
||||
/** §8.3/§7.4 two-phase close: parents await this before removing the entry so
|
||||
* the plugin can release its workbench scope (PTY sessions, subscriptions);
|
||||
* the bridge bounds the wait and resolves false on legacy/hung plugins. */
|
||||
function requestClose(): Promise<boolean> {
|
||||
return bridge ? bridge.requestWorkbenchClose() : Promise.resolve(false);
|
||||
}
|
||||
|
||||
defineExpose({ requestClose });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
loadGeneration += 1;
|
||||
// Best-effort §8.3 close notice for teardown paths that never called
|
||||
// requestClose (tab closes, plugin reload): the message still goes out, but
|
||||
// delivery of the plugin's cleanup is not guaranteed once the iframe dies.
|
||||
void bridge?.requestWorkbenchClose(0).catch(() => undefined);
|
||||
bridge?.dispose();
|
||||
bridge = undefined;
|
||||
window.removeEventListener("message", onMessage);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||
import { useQuickOpen, type QuickOpenItem } from "@/composables/useQuickOpen";
|
||||
import { usePluginCommandPalette } from "@/lib/plugins/pluginCommandPalette";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||
import { getGlobalSearchRoots, saveGlobalSearchRoots, getGlobalSearchExtensions, saveGlobalSearchExtensions } from "@/lib/globalSearch/globalSearchSettings";
|
||||
@@ -22,7 +24,11 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery, loadExternalSqlFiles, loadPluginWorkbenches, contentMode, contentGroups, contentSelectedItem, contentSearching, setContentMode } = useQuickOpen();
|
||||
// Plugin commandPalette commands (HOST_PLUGIN_UI_SPEC §5): injected as extra quick-open entries;
|
||||
// list refresh is driven by dbx:plugins-changed / focus / locale change, with a fallback refresh when the dialog opens.
|
||||
const pluginPalette = usePluginCommandPalette();
|
||||
const { toast } = useToast();
|
||||
const { searchQuery, filteredItems, selectedIndex, selectedItem, selectNext, selectPrevious, setQuery, loadExternalSqlFiles, loadPluginWorkbenches, contentMode, contentGroups, contentSelectedItem, contentSearching, setContentMode } = useQuickOpen({ extraItems: pluginPalette.items });
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -129,6 +135,14 @@ function handleKeyDown(e: KeyboardEvent): void {
|
||||
}
|
||||
|
||||
function handleSelect(item: QuickOpenItem): void {
|
||||
// Plugin commands execute inside this component: they are NOT dispatched to App.vue object navigation (that chain
|
||||
// connectionId-based connection/expand logic is meaningless for command entries); execution errors surface via the existing toast.
|
||||
if (item.type === "plugin_command") {
|
||||
const result = pluginPalette.open(item);
|
||||
if (result.error) toast(result.error, 5000);
|
||||
dialogOpen.value = false;
|
||||
return;
|
||||
}
|
||||
emit("select", item);
|
||||
dialogOpen.value = false;
|
||||
}
|
||||
@@ -212,6 +226,12 @@ function getItemIcon(type: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Right badge copy: plugin commands show the source plugin name as provenance; others keep the type label. */
|
||||
function getTypeBadge(item: QuickOpenItem): string {
|
||||
if (item.type === "plugin_command") return item.pluginName || item.pluginId || "";
|
||||
return getTypeLabel(item.type);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(newOpen) => {
|
||||
@@ -220,6 +240,8 @@ watch(
|
||||
setContentMode(props.initialContentMode === true);
|
||||
searchSettingsOpen.value = false;
|
||||
refreshSearchSettings();
|
||||
// Fallback refresh of the plugin command list on open (regular refresh is event-driven via dbx:plugins-changed).
|
||||
void pluginPalette.refresh();
|
||||
// Eagerly load external SQL files so they appear in the initial list
|
||||
void loadExternalSqlFiles();
|
||||
// Refresh plugin workbench entries so installs/uninstalls show up without a restart
|
||||
@@ -384,7 +406,7 @@ watch(selectedIndex, async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs px-2 py-1 rounded bg-muted text-muted-foreground whitespace-nowrap">
|
||||
{{ getTypeLabel(item.type) }}
|
||||
{{ getTypeBadge(item) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useDockResize } from "@/composables/useDockResize";
|
||||
|
||||
function pointerEvent(type: string, clientY: number, options: { pointerId?: number; button?: number; buttons?: number } = {}): MouseEvent {
|
||||
const event = new MouseEvent(type, { bubbles: true, clientY, button: options.button ?? 0 });
|
||||
Object.defineProperty(event, "pointerId", { value: options.pointerId ?? 1 });
|
||||
Object.defineProperty(event, "buttons", { value: options.buttons ?? (type === "pointermove" ? 1 : 0) });
|
||||
return event;
|
||||
}
|
||||
|
||||
function stubWindowHeight(height: number) {
|
||||
vi.spyOn(window, "innerHeight", "get").mockReturnValue(height);
|
||||
}
|
||||
|
||||
describe("useDockResize", () => {
|
||||
beforeEach(() => {
|
||||
stubWindowHeight(1000);
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function setup(rootHeight = 320) {
|
||||
const dockHeight = ref(rootHeight);
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const handle = document.createElement("div");
|
||||
root.append(handle);
|
||||
const { startResize } = useDockResize({
|
||||
dockHeight,
|
||||
minHeight: 140,
|
||||
dockElement: () => root,
|
||||
});
|
||||
handle.addEventListener("pointerdown", startResize);
|
||||
return { dockHeight, root, handle };
|
||||
}
|
||||
|
||||
it("grows when dragging the top edge upward and commits the height on pointerup", () => {
|
||||
const { dockHeight, root, handle } = setup(320);
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 680));
|
||||
document.dispatchEvent(pointerEvent("pointermove", 580));
|
||||
expect(root.style.height).toBe("420px");
|
||||
document.dispatchEvent(pointerEvent("pointerup", 580));
|
||||
expect(dockHeight.value).toBe(420);
|
||||
});
|
||||
|
||||
it("shrinks when dragging downward and clamps at the minimum height", () => {
|
||||
const { dockHeight, root, handle } = setup(320);
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 680));
|
||||
document.dispatchEvent(pointerEvent("pointermove", 800));
|
||||
expect(root.style.height).toBe("200px");
|
||||
document.dispatchEvent(pointerEvent("pointermove", 2000));
|
||||
expect(root.style.height).toBe("140px");
|
||||
document.dispatchEvent(pointerEvent("pointerup", 2000));
|
||||
expect(dockHeight.value).toBe(140);
|
||||
});
|
||||
|
||||
it("caps the height at the viewport fraction shared with the maximize button", () => {
|
||||
const { dockHeight, handle } = setup(320);
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 900));
|
||||
document.dispatchEvent(pointerEvent("pointermove", 0));
|
||||
document.dispatchEvent(pointerEvent("pointerup", 0));
|
||||
expect(dockHeight.value).toBe(800);
|
||||
});
|
||||
|
||||
it("finishes the drag on pointercancel instead of leaking the listeners", () => {
|
||||
const { dockHeight, root, handle } = setup(320);
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 680));
|
||||
document.dispatchEvent(pointerEvent("pointermove", 500));
|
||||
expect(root.style.height).toBe("500px");
|
||||
document.dispatchEvent(pointerEvent("pointercancel", 500));
|
||||
expect(dockHeight.value).toBe(500);
|
||||
expect(document.body.querySelector('[aria-hidden="true"]')).toBeNull();
|
||||
// A later buttonless move must not ghost-drive the dock.
|
||||
document.dispatchEvent(pointerEvent("pointermove", 200));
|
||||
expect(dockHeight.value).toBe(500);
|
||||
expect(root.style.height).toBe("500px");
|
||||
});
|
||||
|
||||
it("finishes the drag when the window loses focus", () => {
|
||||
const { dockHeight, handle } = setup(320);
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 680));
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
expect(dockHeight.value).toBe(320);
|
||||
expect(document.body.querySelector('[aria-hidden="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores non-primary-button presses", () => {
|
||||
const { dockHeight, root, handle } = setup(320);
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 680, { button: 2, buttons: 2 }));
|
||||
document.dispatchEvent(pointerEvent("pointermove", 400, { buttons: 2 }));
|
||||
expect(root.style.height).toBe("");
|
||||
document.dispatchEvent(pointerEvent("pointerup", 400));
|
||||
expect(dockHeight.value).toBe(320);
|
||||
});
|
||||
|
||||
it("starts from the rendered height so a drag out of the maximized state is seamless", () => {
|
||||
const { dockHeight, root, handle } = setup(320);
|
||||
vi.spyOn(root, "getBoundingClientRect").mockReturnValue({ height: 700, top: 300 } as DOMRect);
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 300));
|
||||
document.dispatchEvent(pointerEvent("pointermove", 200));
|
||||
document.dispatchEvent(pointerEvent("pointerup", 200));
|
||||
expect(dockHeight.value).toBe(800);
|
||||
});
|
||||
|
||||
it("toggles the global resize freeze flag for heavy subtrees", () => {
|
||||
const { handle } = setup(320);
|
||||
handle.dispatchEvent(pointerEvent("pointerdown", 680));
|
||||
expect(document.body.classList.contains("dbx-is-resizing")).toBe(true);
|
||||
document.dispatchEvent(pointerEvent("pointerup", 600));
|
||||
expect(document.body.classList.contains("dbx-is-resizing")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { getCurrentScope, onScopeDispose, type Ref } from "vue";
|
||||
import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
|
||||
|
||||
/**
|
||||
* Vertical counterpart of `usePanelResize` for the global bottom dock, which
|
||||
* grows upward from the window's bottom edge (height delta = negated clientY
|
||||
* delta). Same contract as the horizontal original: pointer capture on the
|
||||
* handle, rAF-coalesced direct style writes while dragging, reactive commit on
|
||||
* release, and the global measurement freeze for heavy subtrees.
|
||||
*
|
||||
* Pointer capture is not an optimization here: the dock body is plugin
|
||||
* iframes, and without capture those iframes swallow pointermove/pointerup —
|
||||
* the height freezes mid-drag and a release over an iframe leaks the
|
||||
* listeners, letting later buttonless moves ghost-drive the dock.
|
||||
*/
|
||||
export interface DockResizeOptions {
|
||||
/** Reactive dock height in px; committed on drag end. */
|
||||
dockHeight: Ref<number>;
|
||||
minHeight: number;
|
||||
/** Fraction of the window height the dock may occupy. Keep this identical to the maximize height so maximizing never shrinks a dragged dock. */
|
||||
maxHeightRatio?: number;
|
||||
/** The dock root element, read for the seamless start height and written during the drag. */
|
||||
dockElement: () => HTMLElement | null;
|
||||
}
|
||||
|
||||
export function useDockResize({ dockHeight, minHeight, maxHeightRatio = 0.8, dockElement }: DockResizeOptions) {
|
||||
const maxHeight = () => Math.max(minHeight, Math.floor(window.innerHeight * maxHeightRatio));
|
||||
let rafId: number | null = null;
|
||||
let finish: (() => void) | null = null;
|
||||
|
||||
function startResize(event: PointerEvent) {
|
||||
if (event.button !== 0 || finish) return;
|
||||
event.preventDefault();
|
||||
const handle = event.currentTarget as HTMLElement | null;
|
||||
handle?.setPointerCapture(event.pointerId);
|
||||
beginPanelResize();
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
Object.assign(overlay.style, {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
zIndex: "2147483647",
|
||||
cursor: "row-resize",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
});
|
||||
document.body.append(overlay);
|
||||
|
||||
const rendered = dockElement()?.getBoundingClientRect().height ?? 0;
|
||||
const startHeight = Math.min(maxHeight(), Math.max(minHeight, rendered > 0 ? rendered : dockHeight.value));
|
||||
const startY = event.clientY;
|
||||
const root = dockElement();
|
||||
let currentHeight = startHeight;
|
||||
|
||||
const applyHeight = () => {
|
||||
root?.style.setProperty("height", `${currentHeight}px`);
|
||||
rafId = null;
|
||||
};
|
||||
|
||||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.buttons === 0) {
|
||||
// The press was lost without a matching pointerup (gesture/iframe
|
||||
// edge): release instead of ghost-resizing on buttonless moves.
|
||||
finishResize();
|
||||
return;
|
||||
}
|
||||
const next = startHeight - (moveEvent.clientY - startY);
|
||||
currentHeight = Math.max(minHeight, Math.min(maxHeight(), next));
|
||||
// happy-dom unit tests assert styles synchronously (no frame advance),
|
||||
// so apply immediately under the test mode instead of via rAF.
|
||||
if (import.meta.env.MODE === "test") {
|
||||
applyHeight();
|
||||
return;
|
||||
}
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(applyHeight);
|
||||
};
|
||||
|
||||
const finishResize = () => {
|
||||
if (!finish) return;
|
||||
finish = null;
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
root?.style.setProperty("height", `${currentHeight}px`);
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", finishResize);
|
||||
document.removeEventListener("pointercancel", finishResize);
|
||||
window.removeEventListener("blur", finishResize);
|
||||
if (handle?.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
|
||||
endPanelResize();
|
||||
overlay.remove();
|
||||
dockHeight.value = currentHeight;
|
||||
};
|
||||
finish = finishResize;
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", finishResize);
|
||||
document.addEventListener("pointercancel", finishResize);
|
||||
window.addEventListener("blur", finishResize, { once: true });
|
||||
}
|
||||
|
||||
// A dock unmounted mid-drag must not leave the window listeners behind.
|
||||
if (getCurrentScope()) onScopeDispose(() => finish?.());
|
||||
|
||||
return { startResize };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, ref, watch, type Ref } from "vue";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||||
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
@@ -29,7 +29,7 @@ const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["r
|
||||
|
||||
export interface QuickOpenItem {
|
||||
id: string;
|
||||
type: "connection" | "plugin_workbench" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body" | "sql_file" | "sql_library_file" | "content_match";
|
||||
type: "connection" | "plugin_workbench" | "database" | "schema" | "table" | "view" | "materialized_view" | "procedure" | "function" | "sequence" | "package" | "package-body" | "sql_file" | "sql_library_file" | "content_match" | "plugin_command";
|
||||
label: string;
|
||||
description?: string;
|
||||
connectionId: string;
|
||||
@@ -47,11 +47,21 @@ export interface QuickOpenItem {
|
||||
matchText?: string; // For content matches: matched slice
|
||||
lineText?: string; // For content matches: full matching line
|
||||
highlightIndices?: [number, number]; // For content matches: [start, end) chars into lineText to highlight
|
||||
pluginId?: string; // For plugin workbenches: plugin manifest id
|
||||
pluginId?: string; // For plugin commands and plugin workbenches: source plugin manifest id
|
||||
commandId?: string; // For plugin commands: short id of the command contribution
|
||||
pluginName?: string; // For plugin commands: source plugin display name (provenance hint)
|
||||
contributionId?: string; // For plugin workbenches: workbench contribution id
|
||||
pluginIcon?: string; // For plugin workbenches: contribution icon path, falling back to the manifest icon
|
||||
}
|
||||
|
||||
export interface UseQuickOpenOptions {
|
||||
/**
|
||||
* External extra items (e.g. plugin commandPalette commands): merged into the initial list and the search result pool,
|
||||
* refreshed by the caller; with no query they are appended after the built-in entries in injection order.
|
||||
*/
|
||||
extraItems?: Ref<QuickOpenItem[]>;
|
||||
}
|
||||
|
||||
export type QuickOpenMatchKind = "exact" | "initials" | "prefix" | "word-prefix" | "substring" | "fuzzy";
|
||||
|
||||
export interface QuickOpenMatch {
|
||||
@@ -272,9 +282,10 @@ function collectSqlFileEntries(entries: SqlFileEntry[], results: SqlFileEntry[])
|
||||
}
|
||||
}
|
||||
|
||||
export function useQuickOpen() {
|
||||
export function useQuickOpen(options: UseQuickOpenOptions = {}) {
|
||||
const connectionStore = useConnectionStore();
|
||||
const savedSqlStore = useSavedSqlStore();
|
||||
const extraItems = options.extraItems;
|
||||
const searchQuery = ref("");
|
||||
const selectedIndex = ref(0);
|
||||
const remoteItems = ref<QuickOpenItem[]>([]);
|
||||
@@ -921,7 +932,7 @@ export function useQuickOpen() {
|
||||
const filteredItems = computed((): MatchedItem[] => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
// Show all tree items plus a limited set of recent SQL library files and external SQL files
|
||||
return [...allItems.value, ...sqlLibraryRecentItems.value, ...sqlFileRecentItems.value].map((item) => ({
|
||||
return [...allItems.value, ...sqlLibraryRecentItems.value, ...sqlFileRecentItems.value, ...(extraItems?.value ?? [])].map((item) => ({
|
||||
...item,
|
||||
matchScore: Infinity,
|
||||
matchIndices: [],
|
||||
@@ -932,7 +943,7 @@ export function useQuickOpen() {
|
||||
|
||||
const seen = new Set<string>();
|
||||
// When searching, include ALL SQL library files and external SQL files
|
||||
for (const item of [...allItems.value, ...sqlLibraryAllItems.value, ...sqlFileItems.value, ...remoteItems.value]) {
|
||||
for (const item of [...allItems.value, ...sqlLibraryAllItems.value, ...sqlFileItems.value, ...remoteItems.value, ...(extraItems?.value ?? [])]) {
|
||||
const key = quickOpenItemKey(item);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
@@ -970,6 +981,7 @@ export function useQuickOpen() {
|
||||
sql_library_file: 12,
|
||||
sql_file: 13,
|
||||
content_match: 14,
|
||||
plugin_command: 15,
|
||||
};
|
||||
const typeDifference = typeOrder[a.type] - typeOrder[b.type];
|
||||
if (typeDifference !== 0) return typeDifference;
|
||||
|
||||
@@ -616,6 +616,19 @@ export default {
|
||||
dockerUsersRun: "Docker users should run",
|
||||
toUpdate: "to update",
|
||||
},
|
||||
pluginDock: {
|
||||
close: "Close terminal",
|
||||
hide: "Hide panel",
|
||||
collapse: "Collapse panel",
|
||||
expand: "Expand panel",
|
||||
maximize: "Maximize panel",
|
||||
restore: "Restore panel",
|
||||
newTerminal: "New terminal",
|
||||
newLocalTerminal: "Local terminal",
|
||||
connectionTerminal: "Connection",
|
||||
filter: "Filter…",
|
||||
noMatch: "No matches",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "CONNECTIONS",
|
||||
noConnections: "No connections yet",
|
||||
|
||||
@@ -608,6 +608,19 @@ export default withEnglishFallback({
|
||||
sourceOfficial: "Fuente oficial",
|
||||
sourceCnb: "CNB",
|
||||
},
|
||||
pluginDock: {
|
||||
close: "Cerrar terminal",
|
||||
hide: "Ocultar panel",
|
||||
collapse: "Contraer panel",
|
||||
expand: "Expandir panel",
|
||||
maximize: "Maximizar panel",
|
||||
restore: "Restaurar panel",
|
||||
newTerminal: "Nuevo terminal",
|
||||
newLocalTerminal: "Terminal local",
|
||||
connectionTerminal: "Conexión",
|
||||
filter: "Filtrar…",
|
||||
noMatch: "Sin coincidencias",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "CONEXIONES",
|
||||
noConnections: "Sin conexiones",
|
||||
|
||||
@@ -606,6 +606,19 @@ export default withEnglishFallback({
|
||||
sourceOfficial: "Sorgente ufficiale",
|
||||
sourceCnb: "CNB",
|
||||
},
|
||||
pluginDock: {
|
||||
close: "Chiudi terminale",
|
||||
hide: "Nascondi pannello",
|
||||
collapse: "Comprimi pannello",
|
||||
expand: "Espandi pannello",
|
||||
maximize: "Ingrandisci pannello",
|
||||
restore: "Ripristina pannello",
|
||||
newTerminal: "Nuovo terminale",
|
||||
newLocalTerminal: "Terminale locale",
|
||||
connectionTerminal: "Connessione",
|
||||
filter: "Filtra…",
|
||||
noMatch: "Nessuna corrispondenza",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "CONNESSIONI",
|
||||
noConnections: "Nessuna connessione",
|
||||
|
||||
@@ -607,6 +607,19 @@ export default withEnglishFallback({
|
||||
sourceOfficial: "公式ソース",
|
||||
sourceCnb: "CNB",
|
||||
},
|
||||
pluginDock: {
|
||||
close: "ターミナルを閉じる",
|
||||
hide: "パネルを隠す",
|
||||
collapse: "パネルを折りたたむ",
|
||||
expand: "パネルを展開",
|
||||
maximize: "パネルを最大化",
|
||||
restore: "パネルを元に戻す",
|
||||
newTerminal: "新規ターミナル",
|
||||
newLocalTerminal: "ローカルターミナル",
|
||||
connectionTerminal: "接続",
|
||||
filter: "フィルタ…",
|
||||
noMatch: "一致する項目がありません",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "接続",
|
||||
noConnections: "まだ接続がありません",
|
||||
|
||||
@@ -614,6 +614,19 @@ export default withEnglishFallback({
|
||||
sourceOfficial: "공식 소스",
|
||||
sourceCnb: "CNB",
|
||||
},
|
||||
pluginDock: {
|
||||
close: "터미널 닫기",
|
||||
hide: "패널 숨기기",
|
||||
collapse: "패널 접기",
|
||||
expand: "패널 펼치기",
|
||||
maximize: "패널 최대화",
|
||||
restore: "패널 복원",
|
||||
newTerminal: "새 터미널",
|
||||
newLocalTerminal: "로컬 터미널",
|
||||
connectionTerminal: "연결",
|
||||
filter: "필터…",
|
||||
noMatch: "일치 항목이 없습니다",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "연결",
|
||||
noConnections: "아직 연결이 없습니다",
|
||||
|
||||
@@ -607,6 +607,19 @@ export default withEnglishFallback({
|
||||
sourceOfficial: "Fonte oficial",
|
||||
sourceCnb: "CNB",
|
||||
},
|
||||
pluginDock: {
|
||||
close: "Fechar terminal",
|
||||
hide: "Ocultar painel",
|
||||
collapse: "Recolher painel",
|
||||
expand: "Expandir painel",
|
||||
maximize: "Maximizar painel",
|
||||
restore: "Restaurar painel",
|
||||
newTerminal: "Novo terminal",
|
||||
newLocalTerminal: "Terminal local",
|
||||
connectionTerminal: "Conexão",
|
||||
filter: "Filtrar…",
|
||||
noMatch: "Nenhuma correspondência",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "CONEXÕES",
|
||||
noConnections: "Nenhuma conexão ainda",
|
||||
|
||||
@@ -532,6 +532,19 @@ export default withEnglishFallback({
|
||||
dockerUsersRun: "Docker 用户请运行",
|
||||
toUpdate: "更新",
|
||||
},
|
||||
pluginDock: {
|
||||
close: "关闭终端",
|
||||
hide: "隐藏面板",
|
||||
collapse: "收起面板",
|
||||
expand: "展开面板",
|
||||
maximize: "最大化面板",
|
||||
restore: "还原面板",
|
||||
newTerminal: "新建终端",
|
||||
newLocalTerminal: "本地终端",
|
||||
connectionTerminal: "连接",
|
||||
filter: "筛选…",
|
||||
noMatch: "没有匹配项",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "连接",
|
||||
noConnections: "暂无连接",
|
||||
|
||||
@@ -610,6 +610,19 @@ export default withEnglishFallback({
|
||||
sourceOfficial: "官方來源",
|
||||
sourceCnb: "CNB",
|
||||
},
|
||||
pluginDock: {
|
||||
close: "關閉終端",
|
||||
hide: "隱藏面板",
|
||||
collapse: "收起面板",
|
||||
expand: "展開面板",
|
||||
maximize: "最大化面板",
|
||||
restore: "還原面板",
|
||||
newTerminal: "新增終端",
|
||||
newLocalTerminal: "本機終端",
|
||||
connectionTerminal: "連線",
|
||||
filter: "篩選…",
|
||||
noMatch: "沒有符合項目",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "連線",
|
||||
noConnections: "暫無連線",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -485,6 +485,85 @@ export interface PluginResultViewContribution {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export type PluginCommandPresentation = "tab" | "panel";
|
||||
export type PluginCommandReuse = "singleton" | "new";
|
||||
export type PluginCommandRestore = "none";
|
||||
|
||||
/** v1 ships exactly one action (HOST_PLUGIN_UI_SPEC §4.1): open a declared workbench. */
|
||||
export interface PluginOpenWorkbenchAction {
|
||||
type: "open-workbench";
|
||||
/** Workbench contribution of the SAME plugin. */
|
||||
workbench: string;
|
||||
presentation?: PluginCommandPresentation;
|
||||
reuse?: PluginCommandReuse;
|
||||
instance_key?: string;
|
||||
restore?: PluginCommandRestore;
|
||||
/** Opaque plugin payload; the host serves it under `context.plugin`. */
|
||||
context?: Record<string, unknown>;
|
||||
/**
|
||||
* Generic launch-options extension point: sidecar method returning
|
||||
* `{ entries: [{ label, description?, context? }] }` for the dock "+" picker.
|
||||
* The host renders labels and merges the chosen context into the
|
||||
* host-authored panel context — never interpreting the business meaning.
|
||||
*/
|
||||
options_action?: string;
|
||||
/** When true, the host also offers the plugin's own saved connections as launch targets. */
|
||||
connection_targets?: boolean;
|
||||
}
|
||||
|
||||
export type PluginConditionOperator = "equals" | "notEquals" | "oneOf";
|
||||
|
||||
/** §5.3 structured condition clause; key/operator are host-reserved word lists — unknown values were rejected at parse time. */
|
||||
export interface PluginConditionClause {
|
||||
key: string;
|
||||
operator: PluginConditionOperator;
|
||||
value: string | boolean | string[];
|
||||
}
|
||||
|
||||
/** enablement/when condition group: implicit AND within `all`; absent field defaults to true. */
|
||||
export interface PluginCommandEnablement {
|
||||
all: PluginConditionClause[];
|
||||
}
|
||||
|
||||
/** Context-key snapshot for condition evaluation (host-provided per scenario; clauses referencing a missing key are always false). */
|
||||
export type PluginConditionContextKeys = Record<string, string | boolean | undefined>;
|
||||
|
||||
export interface PluginCommandLaunchOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginCommandContribution {
|
||||
type: "command";
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
action: PluginOpenWorkbenchAction;
|
||||
enablement?: PluginCommandEnablement;
|
||||
}
|
||||
|
||||
export type PluginMenuLocation = "commandPalette" | "appToolbar" | "appSidebar";
|
||||
|
||||
export interface PluginMenuItem {
|
||||
location: PluginMenuLocation;
|
||||
/** Short command id of the SAME plugin. */
|
||||
command: string;
|
||||
group: string;
|
||||
order: number;
|
||||
/** Toolbar entries default to hidden; sidebar entries default to visible. */
|
||||
default_visible?: boolean;
|
||||
/** Placement visibility condition (defaults to true); evaluated independently from command.enablement. */
|
||||
when?: PluginCommandEnablement;
|
||||
}
|
||||
|
||||
export interface PluginMenusContribution {
|
||||
type: "menus";
|
||||
id: string;
|
||||
items: PluginMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Contribution types the host renders through the plugin's own UI entrypoint in
|
||||
* a plugin tab. A `workbench` is launched from the sidebar, the plugin center,
|
||||
@@ -495,7 +574,7 @@ export interface PluginResultViewContribution {
|
||||
*/
|
||||
export type PluginUiContribution = PluginWorkbenchContribution | PluginResultViewContribution;
|
||||
|
||||
export type PluginContribution = PluginConnectionProviderContribution | PluginWorkbenchContribution | PluginFilesystemProviderContribution | PluginContextMenuContribution | PluginResultViewContribution;
|
||||
export type PluginContribution = PluginConnectionProviderContribution | PluginWorkbenchContribution | PluginFilesystemProviderContribution | PluginContextMenuContribution | PluginResultViewContribution | PluginCommandContribution | PluginMenusContribution;
|
||||
|
||||
export interface PluginEngines {
|
||||
dbx: string;
|
||||
|
||||
@@ -242,6 +242,8 @@ pub enum PluginContribution {
|
||||
FilesystemProvider(PluginFilesystemProviderContribution),
|
||||
ContextMenu(PluginContextMenuContribution),
|
||||
ResultView(PluginResultViewContribution),
|
||||
Command(PluginCommandContribution),
|
||||
Menus(PluginMenusContribution),
|
||||
}
|
||||
|
||||
impl PluginContribution {
|
||||
@@ -252,6 +254,8 @@ impl PluginContribution {
|
||||
Self::FilesystemProvider(contribution) => &contribution.id,
|
||||
Self::ContextMenu(contribution) => &contribution.id,
|
||||
Self::ResultView(contribution) => &contribution.id,
|
||||
Self::Command(contribution) => &contribution.id,
|
||||
Self::Menus(contribution) => &contribution.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -686,6 +690,171 @@ pub struct PluginResultViewContribution {
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
/// Stable sidebar/menu group word list (HOST_PLUGIN_UI_SPEC §5.2). Values
|
||||
/// outside this list fail manifest validation instead of being ignored.
|
||||
pub const PLUGIN_MENU_GROUPS: &[&str] = &["navigation", "primary", "secondary", "destructive"];
|
||||
|
||||
/// Upper bound for one `menus` contribution; keeps registry scans and sidebar
|
||||
/// surfaces bounded regardless of what a manifest declares.
|
||||
pub const PLUGIN_MENUS_ITEMS_MAX: usize = 64;
|
||||
|
||||
/// Upper bound of clauses per enablement/when group.
|
||||
pub const PLUGIN_CONDITION_CLAUSES_MAX: usize = 16;
|
||||
|
||||
/// Upper bound for one command's `context` JSON payload (serialized size).
|
||||
pub const PLUGIN_COMMAND_CONTEXT_MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Reserved context-key word list for condition evaluation (HOST_PLUGIN_UI_SPEC §5.3 v1). Values outside the
|
||||
/// list fail the whole manifest validation — never silently ignored.
|
||||
pub const PLUGIN_CONDITION_KEYS: &[&str] = &["connection.state", "object.type", "surface", "readOnly"];
|
||||
|
||||
/// Single command condition clause (shared by enablement/when). `value` is a string for equals/notEquals
|
||||
/// string and a string array for oneOf.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginCommandConditionClause {
|
||||
pub key: String,
|
||||
pub operator: PluginConditionOperator,
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum PluginConditionOperator {
|
||||
#[serde(rename = "equals")]
|
||||
Equals,
|
||||
#[serde(rename = "notEquals")]
|
||||
NotEquals,
|
||||
#[serde(rename = "oneOf")]
|
||||
OneOf,
|
||||
}
|
||||
|
||||
/// enablement/when condition group: implicit AND within `all`; absent field defaults to true.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginCommandEnablement {
|
||||
#[serde(default)]
|
||||
pub all: Vec<PluginCommandConditionClause>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PluginCommandPresentation {
|
||||
#[default]
|
||||
Tab,
|
||||
/// Opens the command in the host's global bottom dock (BottomDock,
|
||||
/// HOST_PLUGIN_UI_SPEC §8.3). Tab and panel instances can coexist: the
|
||||
/// reuse key includes the presentation.
|
||||
Panel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PluginCommandReuse {
|
||||
#[default]
|
||||
Singleton,
|
||||
New,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PluginCommandRestore {
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
/// v1 ships exactly one command action: opening a declared workbench. The
|
||||
/// workbench reference is resolved against the same plugin's contributions and
|
||||
/// the context payload is opaque JSON placed under `context.plugin` by the
|
||||
/// host (HOST_PLUGIN_UI_SPEC §4.1).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum PluginCommandAction {
|
||||
OpenWorkbench(PluginOpenWorkbenchAction),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginOpenWorkbenchAction {
|
||||
/// Workbench contribution of the SAME plugin (dangling references are
|
||||
/// rejected during validation).
|
||||
pub workbench: String,
|
||||
#[serde(default)]
|
||||
pub presentation: PluginCommandPresentation,
|
||||
#[serde(default)]
|
||||
pub reuse: PluginCommandReuse,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub restore: PluginCommandRestore,
|
||||
/// Opaque plugin payload; served to the workbench under `context.plugin`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<serde_json::Value>,
|
||||
/// Generic launch-options extension point: a sidecar method the host calls
|
||||
/// (POST-less invoke, empty params) to fetch dynamic launch entries —
|
||||
/// `{ "entries": [{ "label": string, "description"?: string, "context"?: object }] }`.
|
||||
/// The host renders them as picker items and opens one panel per selection
|
||||
/// with the returned context merged into the host-authored context; the
|
||||
/// host never interprets the entries' business meaning.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub options_action: Option<String>,
|
||||
/// When true, the host also offers the plugin's own saved connections
|
||||
/// (read-only, secret-free list) as launch targets for this command.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub connection_targets: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginCommandContribution {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub action: PluginCommandAction,
|
||||
/// Executable condition (defaults to true); the host must re-evaluate it against the current context snapshot before running the command.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enablement: Option<PluginCommandEnablement>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub enum PluginMenuLocation {
|
||||
#[serde(rename = "commandPalette")]
|
||||
CommandPalette,
|
||||
#[serde(rename = "appToolbar")]
|
||||
AppToolbar,
|
||||
#[serde(rename = "appSidebar")]
|
||||
AppSidebar,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginMenuItem {
|
||||
pub location: PluginMenuLocation,
|
||||
/// Short command id of the SAME plugin (cross-plugin references rejected).
|
||||
pub command: String,
|
||||
pub group: String,
|
||||
pub order: i64,
|
||||
/// Toolbar entries default to hidden; sidebar entries default to visible.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_visible: Option<bool>,
|
||||
/// Placement visibility condition (defaults to true); evaluated independently from command.enablement.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub when: Option<PluginCommandEnablement>,
|
||||
}
|
||||
|
||||
/// Declared placement of plugin commands across host surfaces. Entry labels
|
||||
/// always come from the referenced command — the menus contribution itself
|
||||
/// carries no display text.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginMenusContribution {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub items: Vec<PluginMenuItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginFilesystemProviderContribution {
|
||||
@@ -1060,6 +1229,9 @@ fn validate_contributions(
|
||||
let mut filesystem_provider_ids = HashSet::new();
|
||||
let mut workbench_references = Vec::new();
|
||||
let mut filesystem_references = Vec::new();
|
||||
let mut command_ids = HashSet::new();
|
||||
let mut command_workbench_references = Vec::new();
|
||||
let mut menu_command_references = Vec::new();
|
||||
|
||||
for (index, contribution) in contributions.iter().enumerate() {
|
||||
let id = contribution.id();
|
||||
@@ -1192,6 +1364,73 @@ fn validate_contributions(
|
||||
errors.push(format!("Filesystem provider '{id}' requires a backend entrypoint"));
|
||||
}
|
||||
}
|
||||
PluginContribution::Command(command) => {
|
||||
validate_required_text(&command.label, &format!("Command '{id}' label"), errors);
|
||||
validate_declared_icon(plugin_dir, &format!("Command '{id}' icon"), command.icon.as_deref(), errors);
|
||||
if valid_identifier(id) {
|
||||
command_ids.insert(id.to_string());
|
||||
}
|
||||
if !has_ui {
|
||||
errors.push(format!("Command contribution '{id}' requires a UI entrypoint"));
|
||||
}
|
||||
validate_command_enablement(command.enablement.as_ref(), &format!("Command '{id}' enablement"), errors);
|
||||
match &command.action {
|
||||
PluginCommandAction::OpenWorkbench(action) => {
|
||||
validate_optional_reference(Some(action.workbench.as_str()), "workbench", id, errors);
|
||||
command_workbench_references.push((id.to_string(), action.workbench.clone()));
|
||||
if let Some(options_action) = &action.options_action {
|
||||
if !valid_sidecar_method(options_action) {
|
||||
errors.push(format!("Command '{id}' has an invalid options_action '{options_action}'"));
|
||||
}
|
||||
}
|
||||
if let Some(context) = &action.context {
|
||||
let context_bytes = serde_json::to_vec(context)
|
||||
.map_or(PLUGIN_COMMAND_CONTEXT_MAX_BYTES + 1, |bytes| bytes.len());
|
||||
if context_bytes > PLUGIN_COMMAND_CONTEXT_MAX_BYTES {
|
||||
errors.push(format!(
|
||||
"Command '{id}' context exceeds the {PLUGIN_COMMAND_CONTEXT_MAX_BYTES}-byte limit"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PluginContribution::Menus(menus) => {
|
||||
if menus.items.len() > PLUGIN_MENUS_ITEMS_MAX {
|
||||
errors.push(format!(
|
||||
"Menus contribution '{id}' declares {} items; at most {PLUGIN_MENUS_ITEMS_MAX} are allowed",
|
||||
menus.items.len()
|
||||
));
|
||||
}
|
||||
let mut seen_placements = HashSet::new();
|
||||
for item in &menus.items {
|
||||
if !PLUGIN_MENU_GROUPS.contains(&item.group.as_str()) {
|
||||
errors.push(format!(
|
||||
"Menus contribution '{id}' uses group '{}' outside the host word list ({})",
|
||||
item.group,
|
||||
PLUGIN_MENU_GROUPS.join(", ")
|
||||
));
|
||||
}
|
||||
if item.location == PluginMenuLocation::CommandPalette && item.default_visible.is_some() {
|
||||
errors.push(format!(
|
||||
"Menus contribution '{id}' sets default_visible on a commandPalette placement; the command palette has no visibility toggle"
|
||||
));
|
||||
}
|
||||
if !seen_placements.insert((item.location, item.command.clone())) {
|
||||
errors.push(format!(
|
||||
"Menus contribution '{id}' declares placement {:?} for command '{}' more than once",
|
||||
item.location, item.command
|
||||
));
|
||||
}
|
||||
validate_command_enablement(
|
||||
item.when.as_ref(),
|
||||
&format!("Menus '{id}' item '{}' when", item.command),
|
||||
errors,
|
||||
);
|
||||
validate_optional_reference(Some(item.command.as_str()), "command", id, errors);
|
||||
menu_command_references.push((id.to_string(), item.command.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,6 +1446,78 @@ fn validate_contributions(
|
||||
));
|
||||
}
|
||||
}
|
||||
for (menus, command) in menu_command_references {
|
||||
if !command_ids.contains(&command) {
|
||||
errors.push(format!("Menus contribution '{menus}' references missing command '{command}'"));
|
||||
}
|
||||
}
|
||||
for (command, workbench) in command_workbench_references {
|
||||
if !workbench_ids.contains(&workbench) {
|
||||
errors.push(format!("Command '{command}' references missing workbench '{workbench}'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidecar method names look like `<domain>/<action>[/<sub>]` (lower-case
|
||||
/// words, digits, `-`, `_`, `.` separated by `/`), e.g. `local/shells/list`.
|
||||
fn valid_sidecar_method(value: &str) -> bool {
|
||||
if value.is_empty() || value.len() > 128 || value.starts_with('/') || value.ends_with('/') || value.contains("//") {
|
||||
return false;
|
||||
}
|
||||
value.split('/').all(|segment| {
|
||||
!segment.is_empty()
|
||||
&& segment.chars().next().is_some_and(|first| first.is_ascii_lowercase() || first.is_ascii_digit())
|
||||
&& segment.chars().all(|character| {
|
||||
character.is_ascii_lowercase() || character.is_ascii_digit() || matches!(character, '-' | '_' | '.')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// enablement/when group validation: keys/operators must be in the v1 word lists, value shape must match the operator
|
||||
/// and a non-empty string array for oneOf; the clause count is bounded.
|
||||
fn validate_command_enablement(enablement: Option<&PluginCommandEnablement>, label: &str, errors: &mut Vec<String>) {
|
||||
let Some(enablement) = enablement else {
|
||||
return;
|
||||
};
|
||||
if enablement.all.len() > PLUGIN_CONDITION_CLAUSES_MAX {
|
||||
errors.push(format!(
|
||||
"{label} declares {} conditions; at most {PLUGIN_CONDITION_CLAUSES_MAX} are allowed",
|
||||
enablement.all.len()
|
||||
));
|
||||
}
|
||||
for clause in &enablement.all {
|
||||
if !PLUGIN_CONDITION_KEYS.contains(&clause.key.as_str()) {
|
||||
errors.push(format!(
|
||||
"{label} uses key '{}' outside the host word list ({})",
|
||||
clause.key,
|
||||
PLUGIN_CONDITION_KEYS.join(", ")
|
||||
));
|
||||
}
|
||||
let value_is_scalar = clause.value.as_str().is_some() || clause.value.is_boolean();
|
||||
let value_is_string_array = clause
|
||||
.value
|
||||
.as_array()
|
||||
.is_some_and(|values| !values.is_empty() && values.iter().all(|value| value.is_string()));
|
||||
match clause.operator {
|
||||
PluginConditionOperator::Equals | PluginConditionOperator::NotEquals => {
|
||||
// §5.3 examples include booleans (readOnly notEquals true) — any scalar works.
|
||||
if !value_is_scalar {
|
||||
errors.push(format!(
|
||||
"{label} condition '{}' requires a string or boolean value for equals/notEquals",
|
||||
clause.key
|
||||
));
|
||||
}
|
||||
}
|
||||
PluginConditionOperator::OneOf => {
|
||||
if !value_is_string_array {
|
||||
errors.push(format!(
|
||||
"{label} condition '{}' requires a non-empty string array value for oneOf",
|
||||
clause.key
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_connection_actions(
|
||||
@@ -1492,9 +1803,12 @@ fn valid_locale_tag(value: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
parse_host_network_permission, resolve_safe_plugin_path, validate_connection_actions,
|
||||
PluginConnectionActionContribution, PluginConnectionProviderContribution, PluginFormFieldBinding,
|
||||
PluginManifest, SUPPORTED_PLUGIN_HOST_API_VERSION, SUPPORTED_PLUGIN_PERMISSIONS,
|
||||
parse_host_network_permission, resolve_safe_plugin_path, validate_connection_actions, validate_contributions,
|
||||
PluginCommandAction, PluginCommandContribution, PluginCommandPresentation, PluginCommandRestore,
|
||||
PluginCommandReuse, PluginConnectionActionContribution, PluginConnectionProviderContribution,
|
||||
PluginContribution, PluginFormFieldBinding, PluginManifest, PluginMenuItem, PluginMenuLocation,
|
||||
PluginMenusContribution, PluginOpenWorkbenchAction, SUPPORTED_PLUGIN_HOST_API_VERSION,
|
||||
SUPPORTED_PLUGIN_PERMISSIONS,
|
||||
};
|
||||
|
||||
fn context_menu_manifest(menu: &str) -> (tempfile::TempDir, PluginManifest) {
|
||||
@@ -1521,6 +1835,250 @@ mod tests {
|
||||
(dir, manifest)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_and_menus_contributions_parse_the_frozen_contract() {
|
||||
let command: PluginContribution = serde_json::from_value(serde_json::json!({
|
||||
"type": "command",
|
||||
"id": "open-local-terminal",
|
||||
"label": "Local terminal",
|
||||
"icon": "assets/local-terminal.svg",
|
||||
"action": {
|
||||
"type": "open-workbench",
|
||||
"workbench": "io.dbx.ssh.workbench",
|
||||
"presentation": "tab",
|
||||
"reuse": "singleton",
|
||||
"instance_key": "local-terminal",
|
||||
"restore": "none",
|
||||
"context": { "plugin": { "mode": "local-terminal" } }
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(matches!(command, PluginContribution::Command(_)));
|
||||
|
||||
// contract defaults: presentation=tab, reuse=singleton, restore=none.
|
||||
let command: PluginCommandContribution = serde_json::from_value(serde_json::json!({
|
||||
"id": "open-thing",
|
||||
"label": "Open",
|
||||
"action": { "type": "open-workbench", "workbench": "wb" }
|
||||
}))
|
||||
.unwrap();
|
||||
// v1 ships exactly one action type, so this pattern always matches.
|
||||
let PluginCommandAction::OpenWorkbench(action) = command.action;
|
||||
assert_eq!(action.workbench, "wb");
|
||||
assert_eq!(action.presentation, PluginCommandPresentation::Tab);
|
||||
assert_eq!(action.reuse, PluginCommandReuse::Singleton);
|
||||
assert_eq!(action.restore, PluginCommandRestore::None);
|
||||
|
||||
// the frozen §11 placement vocabulary keeps the original camelCase values.
|
||||
let menus: PluginMenusContribution = serde_json::from_value(serde_json::json!({
|
||||
"id": "entrypoints",
|
||||
"items": [
|
||||
{ "location": "commandPalette", "command": "open-thing", "group": "primary", "order": 100 },
|
||||
{ "location": "appToolbar", "command": "open-thing", "group": "navigation", "order": 100, "default_visible": false },
|
||||
{ "location": "appSidebar", "command": "open-thing", "group": "primary", "order": 100, "default_visible": true }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(menus.items.len(), 3);
|
||||
assert_eq!(menus.items[0].location, PluginMenuLocation::CommandPalette);
|
||||
assert_eq!(menus.items[1].location, PluginMenuLocation::AppToolbar);
|
||||
assert_eq!(menus.items[2].location, PluginMenuLocation::AppSidebar);
|
||||
|
||||
// unknown contribution types, placements and enum values all reject.
|
||||
assert!(serde_json::from_value::<PluginContribution>(serde_json::json!({ "type": "view", "id": "x" })).is_err());
|
||||
assert!(serde_json::from_value::<PluginMenuItem>(serde_json::json!({
|
||||
"location": "statusBar", "command": "x", "group": "primary", "order": 1
|
||||
}))
|
||||
.is_err());
|
||||
let panel: PluginCommandAction = serde_json::from_value(serde_json::json!({
|
||||
"type": "open-workbench", "workbench": "wb", "presentation": "panel"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
panel,
|
||||
PluginCommandAction::OpenWorkbench(PluginOpenWorkbenchAction {
|
||||
presentation: PluginCommandPresentation::Panel,
|
||||
..
|
||||
})
|
||||
));
|
||||
let rpc: Result<PluginCommandAction, _> = serde_json::from_value(serde_json::json!({
|
||||
"type": "invoke-sidecar", "method": "x"
|
||||
}));
|
||||
assert!(rpc.is_err(), "RPC actions are outside the v1 contract");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_launch_extension_fields_parse_and_validate() {
|
||||
let plugin_dir = std::env::temp_dir();
|
||||
let workbench = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "workbench", "id": "sample.main", "label": "Sample"
|
||||
}))
|
||||
.unwrap();
|
||||
let command = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "command", "id": "cmd", "label": "C",
|
||||
"action": {
|
||||
"type": "open-workbench", "workbench": "sample.main",
|
||||
"presentation": "panel",
|
||||
"options_action": "local/terminal/launch-options",
|
||||
"connection_targets": true
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(std::slice::from_ref(&workbench), false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&[workbench, command], false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
|
||||
// options_action 方法名非法拒收。
|
||||
let bad = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "command", "id": "cmd", "label": "C",
|
||||
"action": { "type": "open-workbench", "workbench": "sample.main", "options_action": "local terminal" }
|
||||
}))
|
||||
.unwrap();
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&[bad], false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.iter().any(|e| e.contains("invalid options_action")), "{errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_enablement_and_menu_when_parse_and_validate() {
|
||||
let plugin_dir = std::env::temp_dir();
|
||||
let workbench = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "workbench", "id": "sample.main", "label": "Sample"
|
||||
}))
|
||||
.unwrap();
|
||||
let command = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "command", "id": "cmd", "label": "C",
|
||||
"enablement": { "all": [ { "key": "surface", "operator": "equals", "value": "tab" } ] },
|
||||
"action": { "type": "open-workbench", "workbench": "sample.main" }
|
||||
}))
|
||||
.unwrap();
|
||||
let menus = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "menus", "id": "entrypoints",
|
||||
"items": [
|
||||
{ "location": "commandPalette", "command": "cmd", "group": "primary", "order": 100,
|
||||
"when": { "all": [ { "key": "connection.state", "operator": "oneOf", "value": ["connected", "reconnecting"] } ] } }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&[workbench, command, menus], false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
|
||||
// keys outside the word list reject.
|
||||
let bad_key = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "command", "id": "cmd", "label": "C",
|
||||
"enablement": { "all": [ { "key": "custom.thing", "operator": "equals", "value": "x" } ] },
|
||||
"action": { "type": "open-workbench", "workbench": "sample.main" }
|
||||
}))
|
||||
.unwrap();
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&[bad_key], false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.iter().any(|e| e.contains("outside the host word list")), "{errors:?}");
|
||||
|
||||
// value shapes that mismatch the operator reject (oneOf needs a non-empty string array).
|
||||
let bad_value = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "menus", "id": "entrypoints",
|
||||
"items": [
|
||||
{ "location": "appToolbar", "command": "cmd", "group": "primary", "order": 100,
|
||||
"when": { "all": [ { "key": "surface", "operator": "oneOf", "value": "tab" } ] } }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&[bad_value], false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.iter().any(|e| e.contains("non-empty string array")), "{errors:?}");
|
||||
|
||||
// clause-count cap rejects 20 valid surface clauses.
|
||||
let clauses: Vec<serde_json::Value> =
|
||||
(0..20).map(|_| serde_json::json!({ "key": "surface", "operator": "equals", "value": "tab" })).collect();
|
||||
let too_many = serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "command", "id": "cmd", "label": "C",
|
||||
"enablement": { "all": clauses },
|
||||
"action": { "type": "open-workbench", "workbench": "sample.main" }
|
||||
}))
|
||||
.unwrap();
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&[too_many], false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.iter().any(|e| e.contains("at most 16 are allowed")), "{errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_and_menus_validation_rejects_dangling_and_off_wordlist_values() {
|
||||
let plugin_dir = std::env::temp_dir();
|
||||
|
||||
// valid minimal set: command + workbench + menus -> zero errors.
|
||||
let valid = vec![
|
||||
serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "workbench", "id": "io.dbx.ssh.workbench", "label": "SSH"
|
||||
}))
|
||||
.unwrap(),
|
||||
serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "command", "id": "open-local-terminal", "label": "Local terminal",
|
||||
"action": { "type": "open-workbench", "workbench": "io.dbx.ssh.workbench" }
|
||||
}))
|
||||
.unwrap(),
|
||||
serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "menus", "id": "entrypoints",
|
||||
"items": [
|
||||
{ "location": "appSidebar", "command": "open-local-terminal", "group": "primary", "order": 100 }
|
||||
]
|
||||
}))
|
||||
.unwrap(),
|
||||
];
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&valid, false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
||||
|
||||
// dangling command references reject.
|
||||
let dangling = vec![serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "menus", "id": "entrypoints",
|
||||
"items": [{ "location": "appSidebar", "command": "missing", "group": "primary", "order": 100 }]
|
||||
}))
|
||||
.unwrap()];
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&dangling, false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.iter().any(|error| error.contains("references missing command 'missing'")), "{errors:?}");
|
||||
|
||||
// dangling command workbench references reject.
|
||||
let dangling = vec![serde_json::from_value::<PluginContribution>(serde_json::json!({
|
||||
"type": "command", "id": "cmd", "label": "C",
|
||||
"action": { "type": "open-workbench", "workbench": "missing.workbench" }
|
||||
}))
|
||||
.unwrap()];
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&dangling, false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.iter().any(|error| error.contains("references missing workbench")), "{errors:?}");
|
||||
|
||||
// groups outside the word list reject; default_visible on commandPalette rejects.
|
||||
let menus: PluginMenusContribution = serde_json::from_value(serde_json::json!({
|
||||
"id": "entrypoints",
|
||||
"items": [
|
||||
{ "location": "appSidebar", "command": "cmd", "group": "vendor-custom", "order": 100 },
|
||||
{ "location": "commandPalette", "command": "cmd", "group": "primary", "order": 100, "default_visible": true }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let contributions = vec![PluginContribution::Menus(menus)];
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&contributions, false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.iter().any(|error| error.contains("outside the host word list")), "{errors:?}");
|
||||
assert!(errors.iter().any(|error| error.contains("no visibility toggle")), "{errors:?}");
|
||||
|
||||
// command contexts over 64 KiB reject.
|
||||
let big: PluginCommandContribution = serde_json::from_value(serde_json::json!({
|
||||
"id": "cmd", "label": "C",
|
||||
"action": { "type": "open-workbench", "workbench": "wb", "context": { "blob": "x".repeat(70_000) } }
|
||||
}))
|
||||
.unwrap();
|
||||
let contributions = vec![PluginContribution::Command(big)];
|
||||
let mut errors = Vec::new();
|
||||
validate_contributions(&contributions, false, true, &plugin_dir, &mut errors);
|
||||
assert!(errors.iter().any(|error| error.contains("exceeds the 65536-byte limit")), "{errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_provider_proxy_route_defaults_false_and_parses() {
|
||||
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Reference in New Issue
Block a user