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

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

No files matched your search

@@ -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>