feat(plugins): open plugin workbench from context menu

This commit is contained in:
jearhe authored and GitHub committed 2026-09-24 19:14:53 +08:00
1 parent 05a72de255
commit 4c1eaa6b16
10 files changed
+494 -69

No files matched your search

@@ -72,8 +72,9 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { useToast } from "@/composables/useToast";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import { buildPluginTableContextMenuInvocation } from "@/lib/plugins/pluginContext";
import type { InstalledPlugin } from "@/types/database";
import { activatePluginContextMenuItem, buildPluginConnectionContextMenuInvocation, buildPluginTableContextMenuInvocation } from "@/lib/plugins/pluginContext";
import type { PluginContextMenuInvocation } from "@/lib/plugins/pluginContext";
import type { InstalledPlugin, PluginContextMenuContribution } from "@/types/database";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/backend/api";
@@ -6732,6 +6733,15 @@ function treeItemMenuItems(): ContextMenuItem[] {
return items;
}
function activateSidebarPluginContextMenuItem(pluginId: string, contribution: PluginContextMenuContribution, invocation: PluginContextMenuInvocation) {
void activatePluginContextMenuItem(pluginId, contribution, invocation, {
findWorkbench: (ownerPluginId, workbenchId) => !!sidebarPluginRegistry.value.findWorkbench(ownerPluginId, workbenchId),
openWorkbench: (ownerPluginId, workbenchId, options) => queryStore.openPluginWorkbench(ownerPluginId, workbenchId, options),
invokePlugin: (ownerPluginId, method, params) => api.invokePlugin(ownerPluginId, method, params),
toast,
});
}
/** Plugin-contributed native menu entries for saved connections. */
function appendPluginConnectionMenuItems(items: ContextMenuItem[], node: TreeNode) {
if (node.type !== "connection") return;
@@ -6739,26 +6749,19 @@ function appendPluginConnectionMenuItems(items: ContextMenuItem[], node: TreeNod
if (pluginItems.length === 0) return;
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
if (!config) return;
items.push({ label: "", separator: true });
for (const { plugin, contribution } of pluginItems) {
items.push({
label: contribution.label,
icon: PlugZap,
action: () => {
api
.invokePlugin(plugin.manifest.id, `contextMenu/${contribution.id}`, {
connection: { id: config.id, dbType: config.db_type, name: config.name, database: config.database || "" },
})
.then((result) => {
const message = (result as { message?: unknown } | null | undefined)?.message;
if (typeof message === "string" && message.trim()) toast(message, 4000);
})
.catch((error: unknown) => {
toast(String((error as Error)?.message || error), 5000);
});
const menuItems = pluginItems.flatMap(({ plugin, contribution }) => {
const invocation = buildPluginConnectionContextMenuInvocation(contribution.id, config);
if (!invocation) return [];
return [
{
label: contribution.label,
icon: PlugZap,
action: () => activateSidebarPluginContextMenuItem(plugin.manifest.id, contribution, invocation),
},
});
}
];
});
if (menuItems.length === 0) return;
items.push({ label: "", separator: true }, ...menuItems);
}
/** Plugin-contributed native menu entries for canonical table nodes. */
@@ -6774,17 +6777,7 @@ function appendPluginTableMenuItems(items: ContextMenuItem[], node: TreeNode) {
tableItems.push({
label: contribution.label,
icon: PlugZap,
action: () => {
api
.invokePlugin(plugin.manifest.id, invocation.method, invocation.params)
.then((result) => {
const message = (result as { message?: unknown } | null | undefined)?.message;
if (typeof message === "string" && message.trim()) toast(message, 4000);
})
.catch((error: unknown) => {
toast(String((error as Error)?.message || error), 5000);
});
},
action: () => activateSidebarPluginContextMenuItem(plugin.manifest.id, contribution, invocation),
});
}
if (tableItems.length === 0) return;
+22
View File
@@ -210,6 +210,27 @@ describe("FrontendPluginRegistry", () => {
expect(registry.findUiContribution("com.example.plugin", "example.graph")).toBeUndefined();
});
it("keeps declarative context-menu actions as context-menu metadata, not commands", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("com.example.plugin", [
{ type: "workbench", id: "example.main", label: "Example Workbench" },
{
type: "context-menu",
id: "example.open",
label: "Open Example",
menu: "connection",
action: { type: "open-workbench", workbench: "example.main" },
},
]),
]);
const item = registry.listContextMenuItems("connection")[0];
expect(item?.contribution.action).toEqual({ type: "open-workbench", workbench: "example.main" });
expect(registry.findWorkbench("com.example.plugin", "example.main")?.contribution.id).toBe("example.main");
expect(registry.findCommand("com.example.plugin", "example.open")).toBeUndefined();
expect(registry.listCommands()).toHaveLength(0);
});
it("indexes context-menu contributions per menu surface", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("com.example.plugin", [
@@ -221,6 +242,7 @@ describe("FrontendPluginRegistry", () => {
const connectionItems = registry.listContextMenuItems("connection");
expect(connectionItems).toHaveLength(1);
expect(connectionItems[0]?.contribution.id).toBe("example.inspect");
expect(connectionItems[0]?.contribution.action).toBeUndefined();
expect(connectionItems[0]?.plugin.manifest.id).toBe("com.example.plugin");
const tableItems = registry.listContextMenuItems("table");
+153 -6
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/database";
import { buildPluginTableContext, buildPluginTableContextMenuInvocation } from "./pluginContext";
import { describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, PluginContextMenuContribution, TreeNode } from "@/types/database";
import { activatePluginContextMenuItem, buildPluginConnectionContextMenuInvocation, buildPluginTableContext, buildPluginTableContextMenuInvocation, type PluginContextMenuActivationHandlers } from "./pluginContext";
function tableNode(overrides: Partial<TreeNode> = {}): TreeNode {
return {
@@ -15,8 +15,30 @@ function tableNode(overrides: Partial<TreeNode> = {}): TreeNode {
};
}
describe("plugin table context", () => {
it("builds the public context from canonical table metadata", () => {
function activationHandlers(overrides: Partial<PluginContextMenuActivationHandlers> = {}): PluginContextMenuActivationHandlers {
return {
findWorkbench: () => true,
openWorkbench: () => undefined,
invokePlugin: async () => undefined,
toast: () => undefined,
...overrides,
};
}
const pluginId = "com.example.plugin";
function openWorkbenchContribution(menu: "connection" | "table"): PluginContextMenuContribution {
return {
type: "context-menu",
id: "example.open",
label: "Open Example",
menu,
action: { type: "open-workbench", workbench: "example.main" },
};
}
describe("plugin context menu context", () => {
it("builds the public table context from canonical table metadata", () => {
expect(buildPluginTableContext(tableNode())).toEqual({
connectionId: "conn-1",
database: "demo",
@@ -43,7 +65,7 @@ describe("plugin table context", () => {
expect(buildPluginTableContextMenuInvocation("example.inspect", node)).toBeNull();
});
it("prepares the existing contextMenu backend invocation without secrets", () => {
it("prepares the existing table backend invocation and carries TableContext separately", () => {
expect(buildPluginTableContextMenuInvocation("example.inspect", tableNode())).toEqual({
method: "contextMenu/example.inspect",
params: {
@@ -54,10 +76,135 @@ describe("plugin table context", () => {
table: "users",
},
},
context: {
connectionId: "conn-1",
database: "demo",
schema: "public",
table: "users",
},
connectionId: "conn-1",
});
});
it("does not build table context for non-table nodes", () => {
expect(buildPluginTableContext(tableNode({ type: "view" }))).toBeNull();
});
it("opens the same-plugin workbench from a connection menu without invoking the backend", async () => {
const connection = {
id: "conn-1",
db_type: "mysql",
name: "Development",
database: "app",
host: "db.internal",
port: 3306,
username: "user",
password: "test-secret",
connection_string: "mysql://user:test-secret@db.internal/app",
} as ConnectionConfig;
const invocation = buildPluginConnectionContextMenuInvocation("example.open", connection);
expect(invocation).not.toBeNull();
const openWorkbench = vi.fn();
const invokePlugin = vi.fn().mockResolvedValue({ message: "must not run" });
const findWorkbench = vi.fn((ownerPluginId: string, workbenchId: string) => ownerPluginId === pluginId && workbenchId === "example.main");
await activatePluginContextMenuItem(pluginId, openWorkbenchContribution("connection"), invocation!, activationHandlers({ findWorkbench, openWorkbench, invokePlugin }));
expect(invocation?.context).toEqual({ id: "conn-1", dbType: "mysql", name: "Development", database: "app" });
expect(JSON.stringify(invocation)).not.toContain("test-secret");
expect(findWorkbench).toHaveBeenCalledWith(pluginId, "example.main");
expect(openWorkbench).toHaveBeenCalledWith(pluginId, "example.main", {
title: "Open Example",
connectionId: "conn-1",
context: invocation?.context,
refreshContextOnReuse: true,
});
expect(invokePlugin).not.toHaveBeenCalled();
});
it("opens a table workbench with the exact stable TableContext and no credentials", async () => {
const invocation = buildPluginTableContextMenuInvocation("example.open", tableNode());
expect(invocation).not.toBeNull();
const openWorkbench = vi.fn();
const invokePlugin = vi.fn();
await activatePluginContextMenuItem(pluginId, openWorkbenchContribution("table"), invocation!, activationHandlers({ openWorkbench, invokePlugin }));
expect(openWorkbench).toHaveBeenCalledWith(pluginId, "example.main", {
title: "Open Example",
connectionId: "conn-1",
context: { connectionId: "conn-1", database: "demo", schema: "public", table: "users" },
refreshContextOnReuse: true,
});
expect(invokePlugin).not.toHaveBeenCalled();
});
it("reports a missing workbench explicitly without falling back to the backend", async () => {
const invocation = buildPluginTableContextMenuInvocation("example.open", tableNode())!;
const openWorkbench = vi.fn();
const invokePlugin = vi.fn();
const toast = vi.fn();
await activatePluginContextMenuItem(pluginId, openWorkbenchContribution("table"), invocation, activationHandlers({ findWorkbench: () => false, openWorkbench, invokePlugin, toast }));
expect(openWorkbench).not.toHaveBeenCalled();
expect(invokePlugin).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith("Plugin workbench 'com.example.plugin/example.main' is unavailable", 5000);
});
it("preserves legacy table backend invocation and result toast behavior", async () => {
const invocation = buildPluginTableContextMenuInvocation("example.legacy-table", tableNode())!;
const invokePlugin = vi.fn().mockResolvedValue({ message: "Legacy table action complete" });
const toast = vi.fn();
const contribution: PluginContextMenuContribution = {
type: "context-menu",
id: "example.legacy-table",
label: "Legacy table",
menu: "table",
};
await activatePluginContextMenuItem(pluginId, contribution, invocation, activationHandlers({ invokePlugin, toast }));
expect(invokePlugin).toHaveBeenCalledWith(pluginId, "contextMenu/example.legacy-table", {
table: { connectionId: "conn-1", database: "demo", schema: "public", table: "users" },
});
expect(toast).toHaveBeenCalledWith("Legacy table action complete", 4000);
});
it("preserves legacy backend invocation and result toast behavior", async () => {
const connection = { id: "conn-1", db_type: "mysql", name: "Development", database: "app" } as ConnectionConfig;
const invocation = buildPluginConnectionContextMenuInvocation("example.legacy", connection)!;
const invokePlugin = vi.fn().mockResolvedValue({ message: "Legacy action complete" });
const toast = vi.fn();
const contribution: PluginContextMenuContribution = {
type: "context-menu",
id: "example.legacy",
label: "Legacy",
menu: "connection",
};
await activatePluginContextMenuItem(pluginId, contribution, invocation, activationHandlers({ invokePlugin, toast }));
expect(invokePlugin).toHaveBeenCalledWith(pluginId, "contextMenu/example.legacy", {
connection: { id: "conn-1", dbType: "mysql", name: "Development", database: "app" },
});
expect(toast).toHaveBeenCalledWith("Legacy action complete", 4000);
});
it("keeps legacy backend errors as error toasts", async () => {
const connection = { id: "conn-1", db_type: "mysql", name: "Development", database: "app" } as ConnectionConfig;
const invocation = buildPluginConnectionContextMenuInvocation("example.legacy", connection)!;
const invokePlugin = vi.fn().mockRejectedValue(new Error("sidecar failed"));
const toast = vi.fn();
const contribution: PluginContextMenuContribution = {
type: "context-menu",
id: "example.legacy",
label: "Legacy",
menu: "connection",
};
await activatePluginContextMenuItem(pluginId, contribution, invocation, activationHandlers({ invokePlugin, toast }));
expect(toast).toHaveBeenCalledWith("sidecar failed", 5000);
});
});
+81 -4
View File
@@ -1,12 +1,31 @@
import type { PluginTableContext, TreeNode } from "@/types/database";
import type { ConnectionConfig, PluginContextMenuContribution, PluginTableContext, TreeNode } from "@/types/database";
import type { PluginWorkbenchContext } from "./pluginHostBridge";
export type PluginTableContextNode = Pick<TreeNode, "type" | "connectionId" | "database" | "schema" | "tableName">;
interface PluginContextMenuConnectionContext {
id: string;
dbType: string;
name: string;
database: string;
}
type PluginContextMenuBackendParams = { connection: PluginContextMenuConnectionContext } | { table: PluginTableContext };
export interface PluginContextMenuInvocation {
method: string;
params: {
table: PluginTableContext;
};
params: PluginContextMenuBackendParams;
/** Semantic invocation context passed directly to a declared plugin workbench. */
context: PluginWorkbenchContext;
/** Host-side tab identity; never copied from a plugin-controlled value. */
connectionId: string;
}
export interface PluginContextMenuActivationHandlers {
findWorkbench(pluginId: string, workbenchId: string): boolean;
openWorkbench(pluginId: string, workbenchId: string, options: { title: string; connectionId: string; context: PluginWorkbenchContext; refreshContextOnReuse: true }): void;
invokePlugin(pluginId: string, method: string, params: PluginContextMenuBackendParams): Promise<unknown>;
toast(message: string, durationMs: number): void;
}
function nonEmptyContextValue(value: string | undefined): string | undefined {
@@ -37,8 +56,66 @@ export function buildPluginTableContextMenuInvocation(contributionId: string, no
if (!contributionId.trim()) return null;
const table = buildPluginTableContext(node);
if (!table) return null;
const context = { ...table } satisfies PluginWorkbenchContext;
return {
method: `contextMenu/${contributionId}`,
params: { table },
context,
connectionId: table.connectionId,
};
}
/**
* Keep the existing connection backend envelope while exposing only its
* established non-secret summary as workbench context.
*/
export function buildPluginConnectionContextMenuInvocation(contributionId: string, connection: Pick<ConnectionConfig, "id" | "db_type" | "name" | "database">): PluginContextMenuInvocation | null {
if (!contributionId.trim()) return null;
const context = {
id: connection.id,
dbType: connection.db_type,
name: connection.name,
database: connection.database || "",
} satisfies PluginWorkbenchContext;
return {
method: `contextMenu/${contributionId}`,
params: { connection: context },
context,
connectionId: connection.id,
};
}
/**
* Declarative actions stay in the host: only legacy entries reach the plugin
* backend, preserving the original message/error toast behavior.
*/
export function activatePluginContextMenuItem(pluginId: string, contribution: PluginContextMenuContribution, invocation: PluginContextMenuInvocation, handlers: PluginContextMenuActivationHandlers): Promise<void> | void {
const action = contribution.action;
if (action?.type === "open-workbench") {
if (!handlers.findWorkbench(pluginId, action.workbench)) {
handlers.toast(`Plugin workbench '${pluginId}/${action.workbench}' is unavailable`, 5000);
return;
}
try {
handlers.openWorkbench(pluginId, action.workbench, {
title: contribution.label,
connectionId: invocation.connectionId,
context: invocation.context,
refreshContextOnReuse: true,
});
} catch (error) {
handlers.toast(String((error as Error)?.message || error), 5000);
}
return;
}
return handlers
.invokePlugin(pluginId, invocation.method, invocation.params)
.then((result) => {
const message = (result as { message?: unknown } | null | undefined)?.message;
if (typeof message === "string" && message.trim()) handlers.toast(message, 4000);
})
.catch((error: unknown) => {
handlers.toast(String((error as Error)?.message || error), 5000);
});
}
+7 -2
View File
@@ -476,6 +476,7 @@ export interface PluginContextMenuContribution {
description?: string;
icon?: string;
menu: PluginContextMenuTarget;
action?: PluginOpenWorkbenchTarget;
}
export interface PluginResultViewContribution {
@@ -490,11 +491,15 @@ 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 {
/** Shared wire-level navigation contract used by commands and context-menu contributions. */
export interface PluginOpenWorkbenchTarget {
type: "open-workbench";
/** Workbench contribution of the SAME plugin. */
workbench: string;
}
/** v1 command action extends the shared target with command-specific launch behavior. */
export interface PluginOpenWorkbenchAction extends PluginOpenWorkbenchTarget {
presentation?: PluginCommandPresentation;
reuse?: PluginCommandReuse;
instance_key?: string;