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)", …