fix(plugin): resolve result-view by UI contribution

This commit is contained in:
jearhe authored and GitHub committed 2026-09-20 01:32:23 +08:00
1 parent dc6c9b5c7c
commit 4f3be8ccfb
14 files changed
+337 -16

No files matched your search

@@ -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,
@@ -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: () => ({}) },
@@ -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<string, unknown>) => (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<Element> | 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("<!doctype html><html><body></body></html>"), 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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>).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");
});
});
@@ -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)", …
@@ -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({
+68
View File
@@ -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", () => {
+16 -2
View File
@@ -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<PluginUiContribution> | undefined {
return [...this.listWorkbenches(), ...this.listResultViews()].find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId);
}
private listContributions<T extends PluginContribution["type"]>(type: T): Array<PluginContributionEntry<Extract<PluginContribution, { type: T }>>> {
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<string, any> {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+19 -1
View File
@@ -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;
@@ -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<string, any> {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -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();
+10
View File
@@ -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 {