diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index e2c62cde60..39ac9baca0 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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(() => { /> + diff --git a/apps/desktop/src/components/layout/AppSidebar.connectionMultiSelect.spec.ts b/apps/desktop/src/components/layout/AppSidebar.connectionMultiSelect.spec.ts index 0aa9e84703..5023086adb 100644 --- a/apps/desktop/src/components/layout/AppSidebar.connectionMultiSelect.spec.ts +++ b/apps/desktop/src/components/layout/AppSidebar.connectionMultiSelect.spec.ts @@ -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()), + useI18n: () => ({ t: (key: string) => key, locale: { value: "en" } }), })); vi.mock("@/stores/connectionStore", () => ({ diff --git a/apps/desktop/src/components/layout/AppToolbar.vue b/apps/desktop/src/components/layout/AppToolbar.vue index 7c141c6130..3d64586437 100644 --- a/apps/desktop/src/components/layout/AppToolbar.vue +++ b/apps/desktop/src/components/layout/AppToolbar.vue @@ -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(() => { GitHub + + + + + + {{ entry.label }} · {{ entry.pluginName }} + diff --git a/apps/desktop/src/components/plugins/PluginBottomDock.spec.ts b/apps/desktop/src/components/plugins/PluginBottomDock.spec.ts new file mode 100644 index 0000000000..6bd7ac209e --- /dev/null +++ b/apps/desktop/src/components/plugins/PluginBottomDock.spec.ts @@ -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 | 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"); + }); +}); diff --git a/apps/desktop/src/components/plugins/PluginBottomDock.vue b/apps/desktop/src/components/plugins/PluginBottomDock.vue new file mode 100644 index 0000000000..63002a9ea9 --- /dev/null +++ b/apps/desktop/src/components/plugins/PluginBottomDock.vue @@ -0,0 +1,443 @@ + + + diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue index 07d45f67d2..21b1a4fe3a 100644 --- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue +++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue @@ -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(() => { -
+
{{ t("pluginPlatform.filesystemProviders") }}
diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index 44b35797a5..e2bb5d71a9 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -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 (!/]*\bsrc=/i.test(html) && !/]*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 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 { + 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); diff --git a/apps/desktop/src/components/quick-open/QuickOpenDialog.vue b/apps/desktop/src/components/quick-open/QuickOpenDialog.vue index 457e76a837..286e23affb 100644 --- a/apps/desktop/src/components/quick-open/QuickOpenDialog.vue +++ b/apps/desktop/src/components/quick-open/QuickOpenDialog.vue @@ -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(null); const listRef = ref(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 () => {
- {{ getTypeLabel(item.type) }} + {{ getTypeBadge(item) }}
diff --git a/apps/desktop/src/composables/__tests__/useDockResize.spec.ts b/apps/desktop/src/composables/__tests__/useDockResize.spec.ts new file mode 100644 index 0000000000..49cfa4e7fa --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useDockResize.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/composables/useDockResize.ts b/apps/desktop/src/composables/useDockResize.ts new file mode 100644 index 0000000000..cff44a22a2 --- /dev/null +++ b/apps/desktop/src/composables/useDockResize.ts @@ -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; + 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 }; +} diff --git a/apps/desktop/src/composables/useQuickOpen.ts b/apps/desktop/src/composables/useQuickOpen.ts index 864dd940f4..982d18cca4 100644 --- a/apps/desktop/src/composables/useQuickOpen.ts +++ b/apps/desktop/src/composables/useQuickOpen.ts @@ -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(["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; +} + 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([]); @@ -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(); // 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; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fbc6bea091..ad76ec4c96 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 9bfb3e0d9c..a6434c0a31 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 2de0ceafea..0ea54d8fc0 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index bebe5af7ce..be91a66376 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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: "まだ接続がありません", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index b107ed5ae1..e4483feebc 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -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: "아직 연결이 없습니다", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 686d1328ae..27a382fd8f 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 5f6cb1f93e..9d108a5cc3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "暂无连接", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a9426d9598..d9bbdf92d2 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "暫無連線", diff --git a/apps/desktop/src/lib/plugins/frontendPlugin.ts b/apps/desktop/src/lib/plugins/frontendPlugin.ts index 4e5245f0bc..ca71114067 100644 --- a/apps/desktop/src/lib/plugins/frontendPlugin.ts +++ b/apps/desktop/src/lib/plugins/frontendPlugin.ts @@ -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[] { + return this.listContributions("command"); + } + + findCommand(pluginId: string, contributionId: string): PluginContributionEntry | 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 => 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); } diff --git a/apps/desktop/src/lib/plugins/pluginBottomDock.spec.ts b/apps/desktop/src/lib/plugins/pluginBottomDock.spec.ts new file mode 100644 index 0000000000..f364316c61 --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginBottomDock.spec.ts @@ -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 { + 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"); + }); +}); diff --git a/apps/desktop/src/lib/plugins/pluginBottomDock.ts b/apps/desktop/src/lib/plugins/pluginBottomDock.ts new file mode 100644 index 0000000000..3d4193e8bb --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginBottomDock.ts @@ -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; +} + +const dockEntries = ref([]); +const activeEntryId = ref(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; +} + +/** 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))); +} diff --git a/apps/desktop/src/lib/plugins/pluginCommandPalette.spec.ts b/apps/desktop/src/lib/plugins/pluginCommandPalette.spec.ts new file mode 100644 index 0000000000..d140b44505 --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginCommandPalette.spec.ts @@ -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>(), +})); + +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(); + }); +}); diff --git a/apps/desktop/src/lib/plugins/pluginCommandPalette.ts b/apps/desktop/src/lib/plugins/pluginCommandPalette.ts new file mode 100644 index 0000000000..04f154a5eb --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginCommandPalette.ts @@ -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([]); + // shallowRef: the registry is a class instance with private fields — a deep ref's UnwrapRef would break its nominal type. + const registry = shallowRef(null); + + async function refresh(): Promise { + 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 }; +} diff --git a/apps/desktop/src/lib/plugins/pluginCommandRegistry.spec.ts b/apps/desktop/src/lib/plugins/pluginCommandRegistry.spec.ts new file mode 100644 index 0000000000..161b36dd8b --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginCommandRegistry.spec.ts @@ -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 = {}) { + 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(); + }); +}); diff --git a/apps/desktop/src/lib/plugins/pluginCommandRegistry.ts b/apps/desktop/src/lib/plugins/pluginCommandRegistry.ts new file mode 100644 index 0000000000..f67daabb9c --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginCommandRegistry.ts @@ -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, 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([]); + // 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(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 }; +} diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index 28718230f2..ac11dd46c4 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -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); + }); }); diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index 997526f6bf..9c0cafd858 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -99,6 +99,12 @@ export interface PluginHostBridgeApi { openFilesystem?(pluginId: string, providerId: string, context?: PluginWorkbenchContext): Promise | void; /** Explicit user-triggered reconnect of an owned plugin connection (full flow, interactive password prompt allowed). */ reopenConnection?(pluginId: string, connectionId: string): Promise; + /** + * 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(); + 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 { + 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'] }, '*'); })();`; } diff --git a/apps/desktop/src/lib/plugins/pluginUiHtmlCache.spec.ts b/apps/desktop/src/lib/plugins/pluginUiHtmlCache.spec.ts new file mode 100644 index 0000000000..37a0b5dabe --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginUiHtmlCache.spec.ts @@ -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: "a", entryDirectory: "" }); + expect(getCachedPluginUiHtml("io.dbx.ssh@0.4.88")).toEqual({ html: "a", 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(); + }); +}); diff --git a/apps/desktop/src/lib/plugins/pluginUiHtmlCache.ts b/apps/desktop/src/lib/plugins/pluginUiHtmlCache.ts new file mode 100644 index 0000000000..b1539fed35 --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginUiHtmlCache.ts @@ -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(); +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(); +} diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 5510399cbe..577b515804 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -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; + /** + * 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; + +export interface PluginCommandLaunchOption { + label: string; + description?: string; + context?: Record; +} + +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; diff --git a/crates/dbx-plugin-runtime/src/plugins/manifest.rs b/crates/dbx-plugin-runtime/src/plugins/manifest.rs index 50a3f001f8..01e88bea9d 100644 --- a/crates/dbx-plugin-runtime/src/plugins/manifest.rs +++ b/crates/dbx-plugin-runtime/src/plugins/manifest.rs @@ -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, } +/// 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, +} + +#[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, + #[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, + /// 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, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + 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, +} + +#[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, + /// Placement visibility condition (defaults to true); evaluated independently from command.enablement. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub when: Option, +} + +/// 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, +} + #[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 `/[/]` (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) { + 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::(serde_json::json!({ "type": "view", "id": "x" })).is_err()); + assert!(serde_json::from_value::(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 = 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::(serde_json::json!({ + "type": "workbench", "id": "sample.main", "label": "Sample" + })) + .unwrap(); + let command = serde_json::from_value::(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::(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::(serde_json::json!({ + "type": "workbench", "id": "sample.main", "label": "Sample" + })) + .unwrap(); + let command = serde_json::from_value::(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::(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::(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::(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 = + (0..20).map(|_| serde_json::json!({ "key": "surface", "operator": "equals", "value": "tab" })).collect(); + let too_many = serde_json::from_value::(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::(serde_json::json!({ + "type": "workbench", "id": "io.dbx.ssh.workbench", "label": "SSH" + })) + .unwrap(), + serde_json::from_value::(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::(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::(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::(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!({ diff --git a/docs/assets/pr-a4/dock-terminal.png b/docs/assets/pr-a4/dock-terminal.png new file mode 100644 index 0000000000..eb1f33348b Binary files /dev/null and b/docs/assets/pr-a4/dock-terminal.png differ diff --git a/docs/assets/pr-a4/hidden.png b/docs/assets/pr-a4/hidden.png new file mode 100644 index 0000000000..5e22bcbd3c Binary files /dev/null and b/docs/assets/pr-a4/hidden.png differ diff --git a/docs/assets/pr-a4/plus-menu.png b/docs/assets/pr-a4/plus-menu.png new file mode 100644 index 0000000000..5f096e45ab Binary files /dev/null and b/docs/assets/pr-a4/plus-menu.png differ diff --git a/docs/assets/pr-a4/resized.png b/docs/assets/pr-a4/resized.png new file mode 100644 index 0000000000..cd267729e9 Binary files /dev/null and b/docs/assets/pr-a4/resized.png differ diff --git a/docs/assets/pr-a4/restored.png b/docs/assets/pr-a4/restored.png new file mode 100644 index 0000000000..a7d616a839 Binary files /dev/null and b/docs/assets/pr-a4/restored.png differ