diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 917e8bd131..bff81de661 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1065,8 +1065,10 @@ function onRefreshObjectBrowser(event: Event) { function openPluginResultView(pluginId: string, contributionId: string, label: string) { const result = props.activeTab.result; if (!result) return; - // Plugin workbenches receive a bounded snapshot; plugins re-query through - // their backend when they need the full or streamed result set. + // The tab carries the result-view contribution id, not a workbench id: the + // plugin UI is told which declared surface the user picked, and it receives a + // bounded snapshot — plugins re-query through their backend when they need the + // full or streamed result set. const cappedRows = result.rows.slice(0, 500); queryStore.openPluginWorkbench(pluginId, contributionId, { title: label, diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index ba311be803..330854873e 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -6,7 +6,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { copyToClipboard } from "@/lib/common/clipboard"; import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type PluginSaveFileRequest, type PluginSaveFileResult, type PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge"; import { buildPluginEditorAppearance } from "@/lib/plugins/pluginAppearance"; -import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database"; +import type { InstalledPlugin, PluginUiContribution } from "@/types/database"; import { useI18n } from "vue-i18n"; import { useTheme } from "@/composables/useTheme"; import { useSettingsStore } from "@/stores/settingsStore"; @@ -15,7 +15,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; const props = withDefaults( defineProps<{ plugin: InstalledPlugin; - contribution: PluginWorkbenchContribution; + contribution: PluginUiContribution; context?: PluginWorkbenchContext; }>(), { context: () => ({}) }, diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchTab.spec.ts b/apps/desktop/src/components/plugins/PluginWorkbenchTab.spec.ts new file mode 100644 index 0000000000..f3b3207b56 --- /dev/null +++ b/apps/desktop/src/components/plugins/PluginWorkbenchTab.spec.ts @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom + +import { createApp, nextTick, 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(), + readPluginUiEntry: vi.fn(), + readPluginUiAsset: vi.fn(), + subscribePluginEvents: vi.fn(), + repushPluginConnection: vi.fn(), + ensureConnected: vi.fn(), + openPluginWorkbench: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + invokePlugin: vi.fn(), + notifyPlugin: vi.fn(), + sendPluginBinary: vi.fn(), + listPlugins: mocks.listPlugins, + readPluginUiEntry: mocks.readPluginUiEntry, + readPluginUiAsset: mocks.readPluginUiAsset, + subscribePluginEvents: mocks.subscribePluginEvents, +})); +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); +vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: vi.fn() })); +vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: ref(false), themeRevision: ref(0) }) })); +vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { uiFontFamily: "", fontFamily: "", fontSize: 14 } }) })); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + connectedIds: new Set(["conn-1"]), + getConfig: () => undefined, + ensureConnected: mocks.ensureConnected, + repushPluginConnection: mocks.repushPluginConnection, + reopenPluginConnection: vi.fn(), + }), +})); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openPluginWorkbench: mocks.openPluginWorkbench }) })); +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ locale: ref("en"), t: (key: string, params?: Record) => (params ? `${key} ${JSON.stringify(params)}` : key) }), +})); + +import PluginWorkbenchTab from "./PluginWorkbenchTab.vue"; + +const plugin: InstalledPlugin = { + manifest: { + id: "com.example.graph", + name: "Graph plugin", + version: "1.0.0", + permissions: [], + drivers: [], + entrypoints: { ui: { root: "ui", entry: "ui/index.html" } }, + contributions: [ + { type: "workbench", id: "graph.main", label: "Graph workbench" }, + { type: "result-view", id: "graph.chart", label: "Chart" }, + { type: "context-menu", id: "graph.inspect", label: "Inspect", menu: "connection" }, + ], + }, + compatibility: { compatible: true }, +}; + +/** The bounded snapshot `ContentArea.openPluginResultView` hands to the plugin UI. */ +const resultContext = { + connectionId: "conn-1", + database: "dbx_test", + sql: "SELECT id, name FROM dbx_9597_orders", + result: { columns: ["id", "name"], rows: [[1, "alpha"]], truncated: false }, +}; + +describe("PluginWorkbenchTab contribution resolution", () => { + let app: App | undefined; + let root: HTMLDivElement; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => window.setTimeout(() => callback(0), 0)); + vi.stubGlobal("getComputedStyle", () => Object.assign([], { getPropertyValue: () => "" })); + mocks.listPlugins.mockResolvedValue([plugin]); + mocks.readPluginUiEntry.mockResolvedValue({ dataBase64: btoa(""), contentType: "text/html" }); + mocks.subscribePluginEvents.mockResolvedValue(vi.fn()); + mocks.repushPluginConnection.mockResolvedValue(undefined); + mocks.ensureConnected.mockResolvedValue(undefined); + root = document.createElement("div"); + document.body.appendChild(root); + }); + + afterEach(() => { + app?.unmount(); + root.remove(); + vi.unstubAllGlobals(); + }); + + async function mountTab(contributionId: string, context?: Record) { + app = createApp(PluginWorkbenchTab, { pluginId: "com.example.graph", contributionId, connectionId: "conn-1", context }); + app.mount(root); + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); + } + + /** Mount, reveal the sandbox iframe, and capture the bridge init payload. */ + async function mountLoadedTab(contributionId: string, context?: Record) { + await mountTab(contributionId, context); + const frame = root.querySelector("iframe"); + expect(frame).toBeInstanceOf(HTMLIFrameElement); + const target = frame!.contentWindow!; + const postMessage = vi.spyOn(target, "postMessage").mockImplementation(() => {}); + frame!.dispatchEvent(new Event("load")); + await vi.waitFor(() => expect(postMessage).toHaveBeenCalled()); + return postMessage; + } + + function initPayload(postMessage: { mock: { calls: unknown[][] } }) { + const init = postMessage.mock.calls.map((call) => call[0] as Record).find((message) => message.type === "init"); + expect(init).toBeDefined(); + return init!; + } + + it("renders a declared result-view instead of reporting it unavailable", async () => { + const postMessage = await mountLoadedTab("graph.chart", resultContext); + + // The regression behind #9597: the toolbar opens the tab with the + // result-view contribution id, which a workbench-only lookup never resolves. + expect(root.textContent).not.toContain("pluginPlatform.workbenchUnavailable"); + expect(initPayload(postMessage)).toMatchObject({ type: "init", pluginId: "com.example.graph", contributionId: "graph.chart" }); + }); + + it("delivers the query result snapshot to a result-view plugin UI", async () => { + const postMessage = await mountLoadedTab("graph.chart", resultContext); + + expect(initPayload(postMessage).context).toEqual(resultContext); + }); + + it("still renders a declared workbench", async () => { + const postMessage = await mountLoadedTab("graph.main"); + + expect(root.textContent).not.toContain("pluginPlatform.workbenchUnavailable"); + expect(initPayload(postMessage)).toMatchObject({ contributionId: "graph.main" }); + }); + + it("reports an unavailable contribution when the id matches no renderable plugin UI", async () => { + await mountTab("graph.missing"); + + expect(root.querySelector("iframe")).toBeNull(); + expect(root.textContent).toContain("pluginPlatform.workbenchUnavailable"); + + // A declared contribution that owns another host surface stays unavailable + // here: resolving plugin UI must not turn the lookup into a wildcard. + app?.unmount(); + await mountTab("graph.inspect"); + + expect(root.querySelector("iframe")).toBeNull(); + expect(root.textContent).toContain("pluginPlatform.workbenchUnavailable"); + }); +}); diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchTab.vue b/apps/desktop/src/components/plugins/PluginWorkbenchTab.vue index 90c75452b8..f7087c4abd 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchTab.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchTab.vue @@ -36,7 +36,7 @@ const hostContext = computed(() => { if (!resolvedConnectionId.value || props.context?.connectionId) return props.context; return { ...props.context, connectionId: resolvedConnectionId.value }; }); -const entry = computed(() => createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(props.pluginId, props.contributionId)); +const entry = computed(() => createFrontendPluginRegistry(plugins.value, appLocale.value).findUiContribution(props.pluginId, props.contributionId)); async function load() { const generation = ++loadGeneration; @@ -91,6 +91,8 @@ function start() { } function openWorkbench(pluginId: string, contributionId: string, context?: PluginWorkbenchContext, options?: { forceNew?: boolean }) { + // `host.openWorkbench` navigates between declared workbenches only; the tab it + // opens resolves its own entry contribution through `findUiContribution`. const target = createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(pluginId, contributionId); // Session tabs are per-connection: title them after the connection (like the // sidebar-opened tab) so parallel sessions read "server", "server (2)", … diff --git a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts index fbe26ea58e..3eb0276d55 100644 --- a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts @@ -224,6 +224,38 @@ describe("openTabsPersistence originalSql round-trip", () => { }); }); + it("preserves a result-view tab's entry contribution id and result snapshot", () => { + const [restored] = roundTrip([ + queryTab({ + id: "plugin-result-view", + title: "Chart", + connectionId: "plugin-connection", + database: "dbx_test", + mode: "plugin-workbench", + pluginWorkbench: { + pluginId: "dbx.example.graph", + contributionId: "dbx.example.graph.chart", + context: { + connectionId: "plugin-connection", + database: "dbx_test", + sql: "SELECT 1", + result: { columns: ["id"], rows: [[1]], truncated: false }, + }, + }, + }), + ]); + + // `contributionId` names the entry contribution, not a workbench: a restored + // result-view tab must keep its own id so the renderer can resolve it again. + expect(restored.pluginWorkbench?.contributionId).toBe("dbx.example.graph.chart"); + expect(restored.pluginWorkbench?.context).toEqual({ + connectionId: "plugin-connection", + database: "dbx_test", + sql: "SELECT 1", + result: { columns: ["id"], rows: [[1]], truncated: false }, + }); + }); + it("preserves host-owned plugin filesystem navigation", () => { const [restored] = roundTrip([ queryTab({ diff --git a/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts b/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts index cf258b054e..777530b9df 100644 --- a/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts +++ b/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts @@ -148,6 +148,68 @@ describe("FrontendPluginRegistry", () => { expect(views[0]?.contribution.label).toBe("Graph"); }); + it("resolves the plugin UI contribution behind a workbench or result-view tab", () => { + const registry = createFrontendPluginRegistry([ + installedPlugin("com.example.plugin", [ + { type: "workbench", id: "example.main", label: "Example Workbench", icon: "assets/main.svg" }, + { type: "result-view", id: "example.graph", label: "Graph", icon: "assets/graph.svg" }, + ]), + ]); + + const workbench = registry.findUiContribution("com.example.plugin", "example.main"); + expect(workbench?.contribution).toMatchObject({ type: "workbench", id: "example.main", label: "Example Workbench", icon: "assets/main.svg" }); + + // The result-view keeps its own id and display metadata: the plugin UI is + // told which declared contribution the user opened, and it is not a workbench. + const resultView = registry.findUiContribution("com.example.plugin", "example.graph"); + expect(resultView?.contribution).toMatchObject({ type: "result-view", id: "example.graph", label: "Graph", icon: "assets/graph.svg" }); + }); + + it("keeps workbench lookups scoped to workbench contributions", () => { + const registry = createFrontendPluginRegistry([ + installedPlugin("com.example.plugin", [ + { type: "workbench", id: "example.main", label: "Example Workbench" }, + { type: "result-view", id: "example.graph", label: "Graph" }, + ]), + ]); + + // `findWorkbench` answers `host.openWorkbench` and `connection-provider.workbench`: + // a result-view id must never satisfy it. + expect(registry.findWorkbench("com.example.plugin", "example.graph")).toBeUndefined(); + expect(registry.findWorkbench("com.example.plugin", "example.main")?.contribution.id).toBe("example.main"); + }); + + it("does not resolve unknown, foreign, or non-UI contributions as plugin UI", () => { + const registry = createFrontendPluginRegistry([ + installedPlugin("com.example.plugin", [ + { type: "result-view", id: "example.graph", label: "Graph" }, + { type: "context-menu", id: "example.inspect", label: "Inspect", menu: "connection" }, + { type: "filesystem-provider", id: "example.files", label: "Files", schemes: ["example"] }, + ]), + installedPlugin("com.example.other", []), + ]); + + expect(registry.findUiContribution("com.example.plugin", "example.graph")?.contribution.type).toBe("result-view"); + expect(registry.findUiContribution("com.example.plugin", "example.missing")).toBeUndefined(); + expect(registry.findUiContribution("com.example.other", "example.graph")).toBeUndefined(); + // Context menus render natively and filesystem providers own their tab mode. + expect(registry.findUiContribution("com.example.plugin", "example.inspect")).toBeUndefined(); + expect(registry.findUiContribution("com.example.plugin", "example.files")).toBeUndefined(); + }); + + it("does not resolve plugin UI contributions of incompatible plugins", () => { + const plugin = installedPlugin("com.example.plugin", [ + { type: "workbench", id: "example.main", label: "Example Workbench" }, + { type: "result-view", id: "example.graph", label: "Graph" }, + ]); + plugin.compatibility = { compatible: false, errors: ["Unsupported host API"] }; + + const registry = createFrontendPluginRegistry([plugin]); + + expect(registry.findUiContribution("com.example.plugin", "example.main")).toBeUndefined(); + expect(registry.findUiContribution("com.example.plugin", "example.graph")).toBeUndefined(); + }); + it("indexes context-menu contributions per menu surface", () => { const registry = createFrontendPluginRegistry([ installedPlugin("com.example.plugin", [ @@ -226,6 +288,10 @@ describe("FrontendPluginRegistry", () => { database_type: "unsafe", fields: [], }, + // Every contribution the host renders through the plugin UI entrypoint + // resolves its icon asset path the same way. + { type: "workbench", id: "unsafe.main", label: "Unsafe workbench", icon: "../outside.svg" }, + { type: "result-view", id: "unsafe.graph", label: "Unsafe graph", icon: "/outside.svg" }, ]); plugin.manifest.icon = "\\outside.svg"; @@ -234,6 +300,8 @@ describe("FrontendPluginRegistry", () => { expect(definition.plugin.manifest.icon).toBeUndefined(); expect(pluginConnectionProviderIcon(entry)).toBeUndefined(); + expect(createFrontendPluginRegistry([plugin]).listWorkbenches()[0]?.contribution.icon).toBeUndefined(); + expect(createFrontendPluginRegistry([plugin]).listResultViews()[0]?.contribution.icon).toBeUndefined(); }); it("localizes plugin metadata, contributions, and form fields", () => { diff --git a/apps/desktop/src/lib/plugins/frontendPlugin.ts b/apps/desktop/src/lib/plugins/frontendPlugin.ts index 2f371531dc..069b305c68 100644 --- a/apps/desktop/src/lib/plugins/frontendPlugin.ts +++ b/apps/desktop/src/lib/plugins/frontendPlugin.ts @@ -12,6 +12,7 @@ import type { PluginFormFieldLocalization, PluginFormFieldValue, PluginManifestLocalization, + PluginUiContribution, PluginWorkbenchContribution, } from "@/types/database"; import { uuid } from "@/lib/common/utils"; @@ -71,6 +72,17 @@ export class FrontendPluginRegistry { return this.listWorkbenches().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId); } + /** + * Resolve any contribution the plugin UI entrypoint can render, whichever host + * surface opened the tab — a `workbench` opened from the sidebar or a + * `result-view` opened from the query-result toolbar. Lookups stay scoped to + * the renderable contribution types, so an id owned by a native context menu + * or a filesystem provider is not a plugin UI surface. + */ + findUiContribution(pluginId: string, contributionId: string): PluginContributionEntry | undefined { + return [...this.listWorkbenches(), ...this.listResultViews()].find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId); + } + private listContributions(type: T): Array>> { return this.definitions .filter((definition) => definition.plugin.compatibility.compatible) @@ -278,7 +290,9 @@ 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") { + } else if (localized.type === "workbench" || localized.type === "result-view") { + // Both render through the plugin UI entrypoint, so both resolve their icon + // asset path the same way. localized.icon = optionalPluginAssetPath(localized.icon); } return localized; @@ -324,6 +338,6 @@ function isPluginFormFieldValue(value: unknown): value is PluginFormFieldValue { return value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } -function isRecord(value: unknown): value is Record { +function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index ad33fbfb87..dfdac1540c 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { reactive, readonly } from "vue"; import { PluginHostBridge, pluginSandboxDocument, pluginSdkSource } from "./pluginHostBridge"; -import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database"; +import type { InstalledPlugin, PluginResultViewContribution, PluginWorkbenchContribution } from "@/types/database"; function plugin(permissions: string[] = []): InstalledPlugin { return { @@ -11,6 +11,7 @@ function plugin(permissions: string[] = []): InstalledPlugin { } const workbench: PluginWorkbenchContribution = { type: "workbench", id: "sample.main", label: "Sample" }; +const resultView: PluginResultViewContribution = { type: "result-view", id: "sample.graph", label: "Graph" }; describe("PluginHostBridge", () => { it("binds backend calls to the owning plugin identity", async () => { @@ -166,6 +167,23 @@ describe("PluginHostBridge", () => { ).toThrow("exceeds"); }); + it("sends the opened contribution id for a result-view surface in the init message", () => { + const messages: unknown[] = []; + const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; + // The plugin has a single UI entrypoint, so the contribution id is the only + // signal telling its UI which declared surface was opened. + const bridge = new PluginHostBridge(plugin(), resultView, { connectionId: "connection", sql: "SELECT 1" }, () => target, { + invoke: vi.fn(), + notify: vi.fn(), + sendBinary: vi.fn(), + readAsset: vi.fn(), + }); + + bridge.sendInit(); + + expect(messages[0]).toMatchObject({ source: "dbx-host", type: "init", pluginId: "sample", contributionId: "sample.graph", context: { connectionId: "connection", sql: "SELECT 1" } }); + }); + it("rejects privileged host calls without manifest permission", async () => { const messages: unknown[] = []; const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window; diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index 9303c818a8..70d538d547 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -1,4 +1,4 @@ -import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayload, PluginWorkbenchContribution } from "@/types/database"; +import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayload, PluginUiContribution } from "@/types/database"; import { clonePluginData, snapshotPluginWorkbenchContext } from "./pluginData"; const PLUGIN_MESSAGE_SOURCE = "dbx-plugin"; @@ -81,7 +81,7 @@ export class PluginHostBridge { constructor( private readonly plugin: InstalledPlugin, - private readonly workbench: PluginWorkbenchContribution, + private readonly contribution: PluginUiContribution, context: PluginWorkbenchContext, private readonly targetWindow: () => Window | null, private readonly api: PluginHostBridgeApi, @@ -161,7 +161,7 @@ export class PluginHostBridge { version: BRIDGE_VERSION, type: "init", pluginId: this.plugin.manifest.id, - contributionId: this.workbench.id, + contributionId: this.contribution.id, locale: this.locale, theme: this.theme ? clonePluginData(this.theme) : undefined, permissions: [...(this.plugin.manifest.permissions || [])], @@ -684,6 +684,6 @@ function enforcePayloadLimit(value: unknown): void { if (bytes > MAX_BRIDGE_PAYLOAD_BYTES) throw new Error("Plugin bridge request is too large"); } -function isRecord(value: unknown): value is Record { +function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/apps/desktop/src/stores/__tests__/queryStore.openPluginWorkbench.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.openPluginWorkbench.spec.ts index 7b35d0567b..e095346cba 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.openPluginWorkbench.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.openPluginWorkbench.spec.ts @@ -96,6 +96,23 @@ describe("queryStore openPluginWorkbench reuse", () => { expect(queryStore.tabs.find((t) => t.id === id)?.pluginWorkbench?.context).toEqual({ connectionId: "conn-1" }); }); + it("keeps a result-view tab distinct from the plugin's workbench tab", () => { + const queryStore = useQueryStore(); + + const workbenchId = queryStore.openPluginWorkbench("io.dbx.ssh", "ssh.main", { title: "SSH server", connectionId: "conn-1", context: { connectionId: "conn-1" } }); + const resultViewId = queryStore.openPluginWorkbench("io.dbx.ssh", "ssh.erd", { + title: "Graph", + connectionId: "conn-1", + context: { connectionId: "conn-1", sql: "SELECT 1", result: { columns: ["id"], rows: [[1]], truncated: false } }, + }); + + // The tab keeps the entry contribution id: the renderer resolves it as a + // result-view, and the sidebar workbench tab is not reused for it. + expect(resultViewId).not.toBe(workbenchId); + expect(queryStore.tabs.find((tab) => tab.id === resultViewId)?.pluginWorkbench?.contributionId).toBe("ssh.erd"); + expect(queryStore.tabs.find((tab) => tab.id === resultViewId)?.pluginWorkbench?.context?.result).toEqual({ columns: ["id"], rows: [[1]], truncated: false }); + }); + it("numbers same-connection session tabs Termius-style and never backfills", async () => { const queryStore = useQueryStore(); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 1dc89ffd0e..5638513c0b 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -472,6 +472,16 @@ export interface PluginResultViewContribution { icon?: string; } +/** + * 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, + * or `host.openWorkbench`; a `result-view` is launched from the query-result + * toolbar with the current result snapshot as context. Both declare display + * metadata only — the opened contribution id is what tells the plugin UI which + * of its declared surfaces to render. + */ +export type PluginUiContribution = PluginWorkbenchContribution | PluginResultViewContribution; + export type PluginContribution = PluginConnectionProviderContribution | PluginWorkbenchContribution | PluginFilesystemProviderContribution | PluginContextMenuContribution | PluginResultViewContribution; export interface PluginEngines { diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index 4498987696..12cc1abb40 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -214,7 +214,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 同一插件内通过 `id` 互相引用:Connection Provider 的 `workbench` 必须指向一个已声明的工作台,`filesystem_provider` 必须指向一个已声明的文件系统 Provider。图标优先使用 Contribution 的 `icon`,缺失时回退到插件级 `icon`。 -自定义文件浏览器应使用 `workbench`:列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`;同时声明两者时默认打开工作台。`context-menu` 调用后端 `contextMenu/`,`result-view` 则在工作台 context 中接收结果快照;具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。 +自定义文件浏览器应使用 `workbench`:列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`;同时声明两者时默认打开工作台。`context-menu` 调用后端 `contextMenu/`,`result-view` 则在工作台 context 中接收结果快照,被打开的 contribution id 会通过 `dbx-plugin-init` 负载下发,因此单个 UI 入口可以为多个工作台和结果视图服务;具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。 ### 文件系统协议 diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index a84e50cf5c..3916719900 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -214,7 +214,7 @@ Action `when` accepts `always`, `create`, or `edit`; `variant` accepts `default` Reference contributions by ID: a connection provider's `workbench` must point to a declared workbench and `filesystem_provider` to a declared filesystem provider. A contribution icon takes precedence over the plugin-level icon. -Choose `workbench` for a custom file browser: the plugin owns its list, virtualization, previews, context menus, and split panes. Choose `filesystem_provider` only to use DBX's generic file manager; declaring both defaults to the workbench. `context-menu` invokes `contextMenu/` on the backend; `result-view` receives a result snapshot in workbench context. See the [full contribution protocol](https://github.com/t8y2/dbx/blob/main/plugins/README.md) for payloads. +Choose `workbench` for a custom file browser: the plugin owns its list, virtualization, previews, context menus, and split panes. Choose `filesystem_provider` only to use DBX's generic file manager; declaring both defaults to the workbench. `context-menu` invokes `contextMenu/` on the backend; `result-view` receives a result snapshot in workbench context, and the opened contribution id arrives in the `dbx-plugin-init` payload so one UI entrypoint can serve several workbenches and result views. See the [full contribution protocol](https://github.com/t8y2/dbx/blob/main/plugins/README.md) for payloads. ### Filesystem Protocol diff --git a/plugins/README.md b/plugins/README.md index 0c1402f72b..bdcffd29ff 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -412,7 +412,7 @@ Plugin-authored names, descriptions, contribution labels, form-field text, and s ### `result-view` -A result view contributes a plugin-rendered visualization for query results. DBX shows one toolbar button per installed view next to the result grid; clicking it opens the plugin workbench with the current result as context: +A result view contributes a plugin-rendered visualization for query results. DBX shows one toolbar button per installed view next to the result grid; clicking it opens a plugin tab that renders the plugin's UI entrypoint with the current result as context: ```json { @@ -422,7 +422,9 @@ A result view contributes a plugin-rendered visualization for query results. DBX } ``` -The workbench `context.result` is a bounded snapshot — `{ columns, rows (<= 500), truncated }` plus `sql`, `connectionId`, and `database`. Plugins that need the full or streamed result set should re-execute through their backend using the SQL and connection reference. Requires a UI entrypoint. +A result view declares display metadata only: it carries no UI of its own and never names a workbench. The opened contribution id reaches the plugin UI in the init payload (`dbx-plugin-init` detail `contributionId`), so a plugin that declares several result views selects the matching one inside its single UI entrypoint. + +The `context.result` snapshot is bounded — `{ columns, rows (<= 500), truncated }` plus `sql`, `connectionId`, and `database`. Plugins that need the full or streamed result set should re-execute through their backend using the SQL and connection reference. Requires a UI entrypoint. ### `context-menu`