From 4c1eaa6b16474952b3f15f0e30b27aefe0e7ed67 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Thu, 24 Sep 2026 19:14:53 +0800 Subject: [PATCH] feat(plugins): open plugin workbench from context menu --- .../sidebar/SidebarTreeRuntimeHost.vue | 57 +++---- .../src/lib/plugins/frontendPlugin.spec.ts | 22 +++ .../src/lib/plugins/pluginContext.spec.ts | 159 +++++++++++++++++- apps/desktop/src/lib/plugins/pluginContext.ts | 85 +++++++++- apps/desktop/src/types/database.ts | 9 +- .../src/plugins/manifest.rs | 150 +++++++++++++++-- docs/content/docs/plugin-development.cn.mdx | 22 ++- docs/content/docs/plugin-development.mdx | 22 ++- plugins/README.md | 25 ++- plugins/manifest.schema.json | 12 +- 10 files changed, 494 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 867244922e..53695a2680 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -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; diff --git a/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts b/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts index b4c8220370..d2a4fef359 100644 --- a/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts +++ b/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/plugins/pluginContext.spec.ts b/apps/desktop/src/lib/plugins/pluginContext.spec.ts index 234df67785..e0b87ffb42 100644 --- a/apps/desktop/src/lib/plugins/pluginContext.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginContext.spec.ts @@ -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 { return { @@ -15,8 +15,30 @@ function tableNode(overrides: Partial = {}): TreeNode { }; } -describe("plugin table context", () => { - it("builds the public context from canonical table metadata", () => { +function activationHandlers(overrides: Partial = {}): 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); + }); }); diff --git a/apps/desktop/src/lib/plugins/pluginContext.ts b/apps/desktop/src/lib/plugins/pluginContext.ts index 34f08673cd..fa2260c071 100644 --- a/apps/desktop/src/lib/plugins/pluginContext.ts +++ b/apps/desktop/src/lib/plugins/pluginContext.ts @@ -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; +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; + 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): 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 { + 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); + }); +} diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 2bfbf0ebb8..97b0203acf 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -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; diff --git a/crates/dbx-plugin-runtime/src/plugins/manifest.rs b/crates/dbx-plugin-runtime/src/plugins/manifest.rs index 3d597ca5b3..9461c258c9 100644 --- a/crates/dbx-plugin-runtime/src/plugins/manifest.rs +++ b/crates/dbx-plugin-runtime/src/plugins/manifest.rs @@ -663,9 +663,9 @@ pub struct PluginWorkbenchContribution { pub icon: Option, } -/// Native context-menu entry contributed to DBX surfaces. v1 targets the -/// saved-connection and table sidebar menus; clicks are dispatched to the -/// plugin backend as `contextMenu/` requests. +/// Native context-menu entry contributed to DBX surfaces. Legacy entries +/// dispatch `contextMenu/` to the plugin backend; declarative actions are +/// handled directly by the host. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginContextMenuContribution { @@ -678,6 +678,24 @@ pub struct PluginContextMenuContribution { /// Menu surface the item belongs to: `connection` or `table`. #[serde(default)] pub menu: String, + /// Optional host-handled action. When absent, the legacy backend entrypoint is required. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, +} + +/// Actions that the host can perform directly for a context-menu contribution. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum PluginContextMenuAction { + OpenWorkbench(PluginContextMenuOpenWorkbenchAction), +} + +/// Narrow context-menu form of the shared `open-workbench` target contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginContextMenuOpenWorkbenchAction { + /// Workbench contribution of the SAME plugin (dangling references are rejected during validation). + pub workbench: String, } /// Plugin-rendered visualization surface for query results. Selecting the view @@ -1232,6 +1250,7 @@ fn validate_contributions( let mut filesystem_provider_ids = HashSet::new(); let mut workbench_references = Vec::new(); let mut filesystem_references = Vec::new(); + let mut context_menu_workbench_references = Vec::new(); let mut command_ids = HashSet::new(); let mut command_workbench_references = Vec::new(); let mut menu_command_references = Vec::new(); @@ -1320,7 +1339,10 @@ fn validate_contributions( menu.menu )); } - if !has_backend { + if let Some(PluginContextMenuAction::OpenWorkbench(action)) = &menu.action { + validate_optional_reference(Some(action.workbench.as_str()), "workbench", id, errors); + context_menu_workbench_references.push((id.to_string(), action.workbench.clone())); + } else if !has_backend { errors.push(format!("Context menu contribution '{id}' requires a backend entrypoint")); } } @@ -1454,6 +1476,11 @@ fn validate_contributions( errors.push(format!("Menus contribution '{menus}' references missing command '{command}'")); } } + for (context_menu, workbench) in context_menu_workbench_references { + if !workbench_ids.contains(&workbench) { + errors.push(format!("Context menu '{context_menu}' references missing workbench '{workbench}'")); + } + } for (command, workbench) in command_workbench_references { if !workbench_ids.contains(&workbench) { errors.push(format!("Command '{command}' references missing workbench '{workbench}'")); @@ -1814,11 +1841,12 @@ mod tests { SUPPORTED_PLUGIN_PERMISSIONS, }; - fn context_menu_manifest(menu: &str) -> (tempfile::TempDir, PluginManifest) { - let dir = tempfile::tempdir().unwrap(); - let executable = dir.path().join("bin").join("example"); - std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); - std::fs::write(&executable, b"example").unwrap(); + fn context_menu_manifest(menu: &str) -> Result<(tempfile::TempDir, PluginManifest), Box> { + let dir = tempfile::tempdir()?; + let binary_dir = dir.path().join("bin"); + std::fs::create_dir_all(&binary_dir)?; + let executable = binary_dir.join("example"); + std::fs::write(&executable, b"example")?; let manifest = serde_json::from_value(serde_json::json!({ "manifest_version": 1, "id": "io.dbx.example", @@ -1833,9 +1861,8 @@ mod tests { "label": "Inspect", "menu": menu }] - })) - .unwrap(); - (dir, manifest) + }))?; + Ok((dir, manifest)) } #[test] @@ -2259,22 +2286,107 @@ mod tests { } #[test] - fn accepts_connection_and_table_context_menu_targets() { + fn accepts_connection_and_table_context_menu_targets() -> Result<(), Box> { for menu in ["connection", "table"] { - let (dir, manifest) = context_menu_manifest(menu); + let (dir, manifest) = context_menu_manifest(menu)?; let compatibility = manifest.compatibility(dir.path(), "0.1.0"); assert!(compatibility.compatible, "{menu}: {:?}", compatibility.errors); } + Ok(()) } #[test] - fn rejects_unsupported_context_menu_targets() { - let (dir, manifest) = context_menu_manifest("schema"); + fn parses_and_accepts_declarative_context_menu_workbench_without_backend() -> Result<(), Box> + { + let plugin_dir = tempfile::tempdir()?; + let workbench: PluginContribution = serde_json::from_value(serde_json::json!({ + "type": "workbench", + "id": "sample.main", + "label": "Sample" + }))?; + let context_menu: PluginContribution = serde_json::from_value(serde_json::json!({ + "type": "context-menu", + "id": "sample.open", + "label": "Open Sample", + "menu": "connection", + "action": { "type": "open-workbench", "workbench": "sample.main" } + }))?; + + let mut errors = Vec::new(); + validate_contributions(&[workbench, context_menu], false, true, plugin_dir.path(), &mut errors); + assert!(errors.is_empty(), "{errors:?}"); + Ok(()) + } + + #[test] + fn rejects_dangling_and_invalid_context_menu_actions() -> Result<(), Box> { + let plugin_dir = tempfile::tempdir()?; + let dangling: PluginContribution = serde_json::from_value(serde_json::json!({ + "type": "context-menu", + "id": "sample.open", + "label": "Open Missing", + "menu": "table", + "action": { "type": "open-workbench", "workbench": "sample.missing" } + }))?; + let mut errors = Vec::new(); + validate_contributions(&[dangling], false, true, plugin_dir.path(), &mut errors); + assert!(errors.iter().any(|error| error.contains("Context menu 'sample.open' references missing workbench 'sample.missing'")), "{errors:?}"); + assert!(!errors.iter().any(|error| error.contains("requires a backend entrypoint")), "{errors:?}"); + + let unknown_action = serde_json::from_value::(serde_json::json!({ + "type": "context-menu", + "id": "sample.open", + "label": "Open", + "menu": "connection", + "action": { "type": "invoke-sidecar", "method": "contextMenu/sample.open" } + })); + assert!(unknown_action.is_err()); + + let command_only_fields = serde_json::from_value::(serde_json::json!({ + "type": "context-menu", + "id": "sample.open", + "label": "Open", + "menu": "connection", + "action": { "type": "open-workbench", "workbench": "sample.main", "presentation": "panel" } + })); + assert!(command_only_fields.is_err()); + Ok(()) + } + + #[test] + fn legacy_context_menu_still_requires_a_backend_entrypoint() -> Result<(), Box> { + let plugin_dir = tempfile::tempdir()?; + let legacy: PluginContribution = serde_json::from_value(serde_json::json!({ + "type": "context-menu", + "id": "sample.legacy", + "label": "Legacy", + "menu": "connection" + }))?; + + let mut errors = Vec::new(); + validate_contributions(std::slice::from_ref(&legacy), false, false, plugin_dir.path(), &mut errors); + assert!( + errors + .iter() + .any(|error| error == "Context menu contribution 'sample.legacy' requires a backend entrypoint"), + "{errors:?}" + ); + + let mut errors = Vec::new(); + validate_contributions(&[legacy], true, false, plugin_dir.path(), &mut errors); + assert!(errors.is_empty(), "{errors:?}"); + Ok(()) + } + + #[test] + fn rejects_unsupported_context_menu_targets() -> Result<(), Box> { + let (dir, manifest) = context_menu_manifest("schema")?; let compatibility = manifest.compatibility(dir.path(), "0.1.0"); assert!(!compatibility.compatible); assert!(compatibility.errors.iter().any(|error| { error == "Context menu 'io.dbx.example.inspect' declares unsupported menu 'schema'; only 'connection' and 'table' are available" })); + Ok(()) } #[test] @@ -2293,6 +2405,12 @@ mod tests { .collect::>(); assert_eq!(declared, ["connection", "table"]); + assert_eq!( + schema["$defs"]["contextMenuContribution"]["properties"]["action"]["$ref"], + "#/$defs/contextMenuAction" + ); + assert_eq!(schema["$defs"]["contextMenuAction"]["properties"]["type"]["const"], "open-workbench"); + assert_eq!(schema["$defs"]["contextMenuAction"]["required"], serde_json::json!(["type", "workbench"])); } #[test] diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index f4d16cb841..a5f06313dd 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -232,10 +232,10 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 | --- | --- | --- | | `workbench` | `type`, `id`, `label` | 注册可从侧边栏或插件入口打开的工作台。 | | `filesystem-provider` | `type`, `id`, `label`, `schemes` | 注册虚拟文件系统;`root_uri` 例如 `s3://bucket`,能力可选 `read`、`write`、`delete`、`rename`、`mkdir`。 | -| `context-menu` | `type`、`id`、`label`、`menu: "connection"` 或 `"table"` | 向 Sidebar Tree 的连接或 table 右键菜单增加动作。 | +| `context-menu` | `type`、`id`、`label`、`menu: "connection"` 或 `"table"`、可选 `action` | 向 Sidebar Tree 的连接或 table 右键菜单增加后端动作,或打开已声明的 Workbench。 | | `result-view` | `type`, `id`, `label` | 注册查询或任务结果视图。 | -`menu: "table"` contribution 当前只在用户对 Sidebar Tree 中的具体 table 节点打开右键菜单时生效。DBX 通过必需的 backend entrypoint 调用 `contextMenu/`,并传递 table identity: +`menu: "table"` contribution 当前只在用户对 Sidebar Tree 中的具体 table 节点打开右键菜单时生效。未声明 `action` 的旧式 contribution 会通过必需的 backend entrypoint 调用 `contextMenu/`,并传递 table identity: ```json { @@ -250,9 +250,25 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 `database` 和 `schema` 是可选字段;不可用时会省略,不会强制传递空字符串。该 context 只包含对象 identity,不包含 credential、connection string 或原始连接配置。Object Browser 后续可以复用相同 identity contract,但本次版本没有把它作为 table 菜单入口。 +context-menu 也可以声明由 Host 处理的 Workbench action: + +```json +{ + "type": "context-menu", + "id": "vendor.example.generate", + "label": "生成测试数据", + "menu": "table", + "action": { "type": "open-workbench", "workbench": "vendor.example.main" } +} +``` + +`action.workbench` 必须引用同一插件 manifest 中声明的 `workbench`。Host 会直接打开该 Workbench,不调用 sidecar,并将当前 invocation context 作为 Workbench context 传入。`menu: "table"` 传入上面的 `TableContext` 对象本身(不是 backend 的 `{ "table": ... }` envelope);`menu: "connection"` 传入现有的非敏感 connection summary(`id`、`dbType`、`name`、`database`)。Host 还可能提供用于 tab 关联的标准 `connectionId`。两者都不会暴露 host、port、username、password、connection string 或原始连接配置。再次打开同一个 Workbench 时会用最新 invocation context 刷新其 context。 + +只有未声明 `action` 的旧式 context-menu 才要求 backend entrypoint;其返回的 `{ "message": "..." }` 仍会显示为 toast。 + 同一插件内通过 `id` 互相引用:Connection Provider 的 `workbench` 必须指向一个已声明的工作台,`filesystem_provider` 必须指向一个已声明的文件系统 Provider。图标优先使用 Contribution 的 `icon`,缺失时回退到插件级 `icon`。 -自定义文件浏览器应使用 `workbench`:列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`;同时声明两者时默认打开工作台。`context-menu` 调用后端 `contextMenu/`,`result-view` 则在工作台 context 中接收结果快照,被打开的 contribution id 会通过 `dbx-plugin-init` 负载下发,因此单个 UI 入口可以为多个工作台和结果视图服务;具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。 +自定义文件浏览器应使用 `workbench`:列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`;同时声明两者时默认打开工作台。未声明 `action` 的旧式 `context-menu` 调用后端 `contextMenu/`;声明式 `open-workbench` action 则由 Host 直接打开同插件 Workbench。`result-view` 在工作台 context 中接收结果快照,被打开的 contribution id 会通过 `dbx-plugin-init` 负载下发,因此单个 UI 入口可以为多个工作台和结果视图服务;具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。 ### 文件系统协议 diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index 47a5a732f9..69bbe2ce5b 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -232,10 +232,10 @@ Action `when` accepts `always`, `create`, or `edit`; `variant` accepts `default` | --- | --- | --- | | `workbench` | `type`, `id`, `label` | Registers a workbench opened from the sidebar or plugin entry. | | `filesystem-provider` | `type`, `id`, `label`, `schemes` | Registers a virtual filesystem; `root_uri` can be `s3://bucket`, with optional `read`, `write`, `delete`, `rename`, and `mkdir` capabilities. | -| `context-menu` | `type`, `id`, `label`, `menu: "connection"` or `"table"` | Adds an action to a Sidebar Tree connection or table context menu. | +| `context-menu` | `type`, `id`, `label`, `menu: "connection"` or `"table"`, optional `action` | Adds a backend action or opens a declared Workbench from a Sidebar Tree context menu. | | `result-view` | `type`, `id`, `label` | Registers a query or task result view. | -A `menu: "table"` contribution is currently exposed when the user opens a context menu on a concrete table node in the Sidebar Tree. DBX invokes the required backend entrypoint as `contextMenu/` with the table identity: +A `menu: "table"` contribution is currently exposed when the user opens a context menu on a concrete table node in the Sidebar Tree. A legacy contribution without `action` invokes the required backend entrypoint as `contextMenu/` with the table identity: ```json { @@ -250,9 +250,25 @@ A `menu: "table"` contribution is currently exposed when the user opens a contex `database` and `schema` are optional and are omitted when unavailable. The context contains no credential, connection string, or raw connection configuration. Object Browser can reuse this identity contract in a later contribution surface, but it is not a table menu surface in this release. +A context-menu contribution can instead declare a host-handled Workbench action: + +```json +{ + "type": "context-menu", + "id": "vendor.example.generate", + "label": "Generate test data", + "menu": "table", + "action": { "type": "open-workbench", "workbench": "vendor.example.main" } +} +``` + +`action.workbench` must reference a `workbench` contribution in the same plugin. The host opens it directly, without invoking the sidecar, and passes the current invocation context as Workbench context. For `menu: "table"`, this is the `TableContext` object above directly (not the backend `{ "table": ... }` envelope); for `menu: "connection"`, it is the existing non-secret connection summary (`id`, `dbType`, `name`, `database`). The host may also provide the standard `connectionId` for tab association. No host, port, username, password, connection string, or raw connection configuration is exposed. Reopening the Workbench refreshes its context with the latest invocation. + +Only legacy context-menu contributions without `action` require a backend entrypoint; their `{ "message": "..." }` response continues to show a toast. + Reference contributions by ID: a connection provider's `workbench` must point to a declared workbench and `filesystem_provider` to a declared filesystem provider. A contribution icon takes precedence over the plugin-level icon. -Choose `workbench` for a custom file browser: the plugin owns its list, virtualization, previews, context menus, and split panes. Choose `filesystem_provider` only to use DBX's generic file manager; declaring both defaults to the workbench. `context-menu` invokes `contextMenu/` on the backend; `result-view` receives a result snapshot in workbench context, and the opened contribution id arrives in the `dbx-plugin-init` payload so one UI entrypoint can serve several workbenches and result views. See the [full contribution protocol](https://github.com/t8y2/dbx/blob/main/plugins/README.md) for payloads. +Choose `workbench` for a custom file browser: the plugin owns its list, virtualization, previews, context menus, and split panes. Choose `filesystem_provider` only to use DBX's generic file manager; declaring both defaults to the workbench. A legacy `context-menu` invokes `contextMenu/` on the backend, while a declarative `open-workbench` action opens its same-plugin Workbench directly; `result-view` receives a result snapshot in workbench context, and the opened contribution id arrives in the `dbx-plugin-init` payload so one UI entrypoint can serve several workbenches and result views. See the [full contribution protocol](https://github.com/t8y2/dbx/blob/main/plugins/README.md) for payloads. ### Filesystem Protocol diff --git a/plugins/README.md b/plugins/README.md index 15687bf79d..0ccae4cb29 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -455,7 +455,24 @@ For a table-scoped action, declare `menu: "table"`: } ``` -Clicking a connection item dispatches `contextMenu/` with a non-secret connection summary (`{ id, dbType, name, database }`). Clicking a table item uses the same backend method and dispatches: +A context-menu item can instead declare a host-handled Workbench action: + +```json +{ + "type": "context-menu", + "id": "vendor.example.generate", + "label": "Generate test data", + "menu": "table", + "action": { + "type": "open-workbench", + "workbench": "vendor.example.main" + } +} +``` + +The `workbench` reference must identify a `workbench` contribution in the same plugin manifest. This declarative action is resolved by the host and does not invoke the plugin backend. + +For legacy entries without `action`, clicking a connection item dispatches `contextMenu/` with the existing non-secret connection summary (`{ id, dbType, name, database }`) under `connection`. Clicking a table item uses the same backend method and dispatches: ```json { @@ -468,7 +485,11 @@ Clicking a connection item dispatches `contextMenu/` with a non-secret conne } ``` -`database` and `schema` are optional and are omitted when the selected database does not expose those scopes. The table context contains object identity only; it never contains credentials, connection strings, or raw connection configuration. The backend entrypoint is required; return `{ "message": "..." }` to surface a toast. +`database` and `schema` are optional and are omitted when the selected database does not expose those scopes. The table context contains object identity only; it never contains credentials, connection strings, or raw connection configuration. + +For a declarative `open-workbench` action, DBX passes the current connection summary as Workbench context for `menu: "connection"`, and the stable `TableContext` object above directly as Workbench context for `menu: "table"` (without the backend `table` envelope). Connection context includes only `id`, `dbType`, `name`, and `database`; the host may also provide the standard `connectionId` for tab association. Neither path includes `host`, `port`, `username`, `password`, a connection string, or raw connection configuration. Reopening the same Workbench refreshes it with the latest invocation context. + +A backend entrypoint is required only for legacy context-menu entries without a declarative action. Their `{ "message": "..." }` result continues to surface as a toast. ### `filesystem-provider` diff --git a/plugins/manifest.schema.json b/plugins/manifest.schema.json index a7b54164b1..2c5cff2f8e 100644 --- a/plugins/manifest.schema.json +++ b/plugins/manifest.schema.json @@ -325,7 +325,17 @@ "label": { "type": "string", "minLength": 1 }, "description": { "type": "string" }, "icon": { "$ref": "#/$defs/assetPath" }, - "menu": { "enum": ["connection", "table"] } + "menu": { "enum": ["connection", "table"] }, + "action": { "$ref": "#/$defs/contextMenuAction" } + } + }, + "contextMenuAction": { + "type": "object", + "additionalProperties": false, + "required": ["type", "workbench"], + "properties": { + "type": { "const": "open-workbench" }, + "workbench": { "$ref": "#/$defs/identifier" } } }, "resultViewContribution": {