fix(plugin): resolve result-view by UI contribution
This commit is contained in:
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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user