feat(plugins): open plugin workbench from context menu
This commit is contained in:
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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user