From 78315c377a544d816104d19a99317ecd1d2ba330 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Tue, 22 Sep 2026 14:23:53 +0800 Subject: [PATCH] feat(plugin): add table-scoped context-menu contributions --- .../sidebar/SidebarTreeRuntimeHost.vue | 34 +++++++++ .../src/lib/__tests__/table/tableTree.spec.ts | 17 +++++ .../src/lib/plugins/frontendPlugin.spec.ts | 18 +++-- .../desktop/src/lib/plugins/frontendPlugin.ts | 2 +- .../src/lib/plugins/pluginContext.spec.ts | 63 ++++++++++++++++ apps/desktop/src/lib/plugins/pluginContext.ts | 44 ++++++++++++ apps/desktop/src/lib/table/tableTree.ts | 1 + .../connectionStore.metadataLoading.spec.ts | 6 +- apps/desktop/src/stores/connectionStore.ts | 16 ++--- apps/desktop/src/types/database.ts | 11 ++- .../src/plugins/manifest.rs | 71 +++++++++++++++++-- docs/content/docs/plugin-development.cn.mdx | 17 ++++- docs/content/docs/plugin-development.mdx | 17 ++++- plugins/README.md | 28 +++++++- plugins/manifest.schema.json | 2 +- 15 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/lib/plugins/pluginContext.spec.ts create mode 100644 apps/desktop/src/lib/plugins/pluginContext.ts diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 20ef01b888..41d61a25bf 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -72,6 +72,7 @@ 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 { useDatabaseOptions } from "@/composables/useDatabaseOptions"; import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database"; @@ -5981,6 +5982,7 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { variant: "destructive" as const, }); } + appendPluginTableMenuItems(items, node); return true; } @@ -6162,6 +6164,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { items.push({ label: "", separator: true }); items.push(exportDataSubmenu(false)); items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw, shortcut: shortcutRefresh }); + appendPluginTableMenuItems(items, node); return true; } const destructiveActions: ContextMenuItem[] = []; @@ -6301,6 +6304,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { icon: RefreshCw, shortcut: shortcutRefresh, }); + appendPluginTableMenuItems(items, node); return true; } @@ -6746,6 +6750,36 @@ function appendPluginConnectionMenuItems(items: ContextMenuItem[], node: TreeNod } } +/** Plugin-contributed native menu entries for canonical table nodes. */ +function appendPluginTableMenuItems(items: ContextMenuItem[], node: TreeNode) { + if (node.type !== "table") return; + const pluginItems = sidebarPluginRegistry.value.listContextMenuItems("table"); + if (pluginItems.length === 0) return; + + const tableItems: ContextMenuItem[] = []; + for (const { plugin, contribution } of pluginItems) { + const invocation = buildPluginTableContextMenuInvocation(contribution.id, node); + if (!invocation) continue; + 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); + }); + }, + }); + } + if (tableItems.length === 0) return; + items.push({ label: "", separator: true }, ...tableItems); +} + function activateRuntimeNode(node: TreeNode) { activeNode.value = node; } diff --git a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts index d5634a5128..4fbe3edbe1 100644 --- a/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/tableTree.spec.ts @@ -72,6 +72,23 @@ describe("case-sensitive database objects", () => { expect(grouped.find((node) => node.type === "group-views")?.children?.map((node) => node.valid)).toEqual([false, true]); }); + it("stores the canonical table name separately from the display label", () => { + const nodes = buildTableTreeNodes({ + ...context, + schema: "public", + tables: [ + { name: "users", table_type: "BASE TABLE" }, + { name: "active_users", table_type: "VIEW" }, + ], + }); + const table = nodes.find((node) => node.type === "table"); + const view = nodes.find((node) => node.type === "view"); + + expect(table).toMatchObject({ type: "table", label: "users", tableName: "users" }); + expect(view).toMatchObject({ type: "view", label: "active_users" }); + expect(view?.tableName).toBeUndefined(); + }); + it("keeps table nodes whose names differ only by case across pages", () => { const firstPage = buildTableTreeNodes({ ...context, diff --git a/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts b/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts index 777530b9df..b4c8220370 100644 --- a/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts +++ b/apps/desktop/src/lib/plugins/frontendPlugin.spec.ts @@ -214,15 +214,21 @@ describe("FrontendPluginRegistry", () => { const registry = createFrontendPluginRegistry([ installedPlugin("com.example.plugin", [ { type: "context-menu", id: "example.inspect", label: "Inspect endpoint", menu: "connection" }, - { type: "context-menu", id: "example.other", label: "Wrong surface", menu: "tree" }, + { type: "context-menu", id: "example.inspect-table", label: "Inspect table", menu: "table" }, ]), ]); - const items = registry.listContextMenuItems("connection"); - expect(items).toHaveLength(1); - expect(items[0]?.contribution.id).toBe("example.inspect"); - expect(items[0]?.plugin.manifest.id).toBe("com.example.plugin"); - expect(registry.listContextMenuItems("tree")).toHaveLength(1); + const connectionItems = registry.listContextMenuItems("connection"); + expect(connectionItems).toHaveLength(1); + expect(connectionItems[0]?.contribution.id).toBe("example.inspect"); + expect(connectionItems[0]?.plugin.manifest.id).toBe("com.example.plugin"); + + const tableItems = registry.listContextMenuItems("table"); + expect(tableItems).toHaveLength(1); + expect(tableItems[0]?.contribution.id).toBe("example.inspect-table"); + + const emptyRegistry = createFrontendPluginRegistry([installedPlugin("com.example.empty")]); + expect(emptyRegistry.listContextMenuItems("table")).toHaveLength(0); expect(registry.listContextMenuItems("missing")).toHaveLength(0); }); diff --git a/apps/desktop/src/lib/plugins/frontendPlugin.ts b/apps/desktop/src/lib/plugins/frontendPlugin.ts index 069b305c68..4e5245f0bc 100644 --- a/apps/desktop/src/lib/plugins/frontendPlugin.ts +++ b/apps/desktop/src/lib/plugins/frontendPlugin.ts @@ -58,7 +58,7 @@ export class FrontendPluginRegistry { return this.listContributions("filesystem-provider"); } - /** Native context-menu entries declared for a specific menu surface such as `connection`. */ + /** Native context-menu entries declared for a specific menu surface. */ listContextMenuItems(menu: string): PluginContributionEntry[] { return this.listContributions("context-menu").filter((entry) => entry.contribution.menu === menu); } diff --git a/apps/desktop/src/lib/plugins/pluginContext.spec.ts b/apps/desktop/src/lib/plugins/pluginContext.spec.ts new file mode 100644 index 0000000000..234df67785 --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginContext.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { TreeNode } from "@/types/database"; +import { buildPluginTableContext, buildPluginTableContextMenuInvocation } from "./pluginContext"; + +function tableNode(overrides: Partial = {}): TreeNode { + return { + id: "connection:demo:public:users", + label: "users", + type: "table", + connectionId: "conn-1", + database: "demo", + schema: "public", + tableName: "users", + ...overrides, + }; +} + +describe("plugin table context", () => { + it("builds the public context from canonical table metadata", () => { + expect(buildPluginTableContext(tableNode())).toEqual({ + connectionId: "conn-1", + database: "demo", + schema: "public", + table: "users", + }); + }); + + it("omits optional database and schema values when the node has no scope", () => { + expect(buildPluginTableContext(tableNode({ database: undefined, schema: undefined }))).toEqual({ + connectionId: "conn-1", + table: "users", + }); + expect(buildPluginTableContext(tableNode({ database: "", schema: "" }))).toEqual({ + connectionId: "conn-1", + table: "users", + }); + }); + + it("fails closed when canonical table identity is missing instead of using the label", () => { + const node = tableNode({ tableName: undefined, label: "users" }); + + expect(buildPluginTableContext(node)).toBeNull(); + expect(buildPluginTableContextMenuInvocation("example.inspect", node)).toBeNull(); + }); + + it("prepares the existing contextMenu backend invocation without secrets", () => { + expect(buildPluginTableContextMenuInvocation("example.inspect", tableNode())).toEqual({ + method: "contextMenu/example.inspect", + params: { + table: { + connectionId: "conn-1", + database: "demo", + schema: "public", + table: "users", + }, + }, + }); + }); + + it("does not build table context for non-table nodes", () => { + expect(buildPluginTableContext(tableNode({ type: "view" }))).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/plugins/pluginContext.ts b/apps/desktop/src/lib/plugins/pluginContext.ts new file mode 100644 index 0000000000..34f08673cd --- /dev/null +++ b/apps/desktop/src/lib/plugins/pluginContext.ts @@ -0,0 +1,44 @@ +import type { PluginTableContext, TreeNode } from "@/types/database"; + +export type PluginTableContextNode = Pick; + +export interface PluginContextMenuInvocation { + method: string; + params: { + table: PluginTableContext; + }; +} + +function nonEmptyContextValue(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized || undefined; +} + +/** + * Builds the public table identity from the sidebar node's canonical metadata. + * Display labels are deliberately not part of this input or its fallback path. + */ +export function buildPluginTableContext(node: PluginTableContextNode): PluginTableContext | null { + if (node.type !== "table") return null; + + const connectionId = nonEmptyContextValue(node.connectionId); + const table = nonEmptyContextValue(node.tableName); + if (!connectionId || !table) return null; + + const context: PluginTableContext = { connectionId, table }; + const database = nonEmptyContextValue(node.database); + const schema = nonEmptyContextValue(node.schema); + if (database !== undefined) context.database = database; + if (schema !== undefined) context.schema = schema; + return context; +} + +export function buildPluginTableContextMenuInvocation(contributionId: string, node: PluginTableContextNode): PluginContextMenuInvocation | null { + if (!contributionId.trim()) return null; + const table = buildPluginTableContext(node); + if (!table) return null; + return { + method: `contextMenu/${contributionId}`, + params: { table }, + }; +} diff --git a/apps/desktop/src/lib/table/tableTree.ts b/apps/desktop/src/lib/table/tableTree.ts index 023c1d79a1..2c4e7110c3 100644 --- a/apps/desktop/src/lib/table/tableTree.ts +++ b/apps/desktop/src/lib/table/tableTree.ts @@ -87,6 +87,7 @@ function makeTableTreeEntry({ database, schema, catalog, + ...(objectType === "TABLE" ? { tableName: name } : {}), isExpanded: false, children: [], }; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 69cb77f287..ed62a4849b 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -848,7 +848,7 @@ describe("connectionStore metadata loading", () => { }); it("keeps concurrent table-tree and local-index refreshes in separate cache entries", async () => { - const treeCacheKey = "pg-1:app:public:group-tables:objects-v8"; + const treeCacheKey = "pg-1:app:public:group-tables:objects-v9"; const indexCacheKey = `${treeCacheKey}:table-search-index-v1`; const cachedPayloads = new Map([ [ @@ -1031,7 +1031,7 @@ describe("connectionStore metadata loading", () => { expect(storedViewGroup?.type).toBe("group-views"); await store.loadObjectGroupChildren(storedViewGroup!); - expect(loadSchemaCache).toHaveBeenCalledWith("oracle-1:XE:DIP:group-views:objects-v7"); + expect(loadSchemaCache).toHaveBeenCalledWith("oracle-1:XE:DIP:group-views:objects-v8"); expect(listTables).toHaveBeenCalledWith(connection.id, "XE", "DIP", undefined, 201, 0, ["VIEW"]); expect(storedViewGroup?.children?.map((node) => node.label)).toEqual(["V_ONE", "V_THREE", "V_TWO"]); }); @@ -2999,7 +2999,7 @@ describe("connectionStore metadata loading", () => { const loadPromise = store.loadTables(connection.id, "prulife", "xtdpcky", { force: true }); await vi.waitFor(() => expect(saveSchemaCache).toHaveBeenCalledTimes(1)); - expect(saveSchemaCache.mock.calls[0]?.[0]).toBe(`${connection.id}:prulife:xtdpcky:objects-grouped-v8-informix-owner-v2`); + expect(saveSchemaCache.mock.calls[0]?.[0]).toBe(`${connection.id}:prulife:xtdpcky:objects-grouped-v9-informix-owner-v2`); expect(schemaNode.isLoading).toBe(true); schemaNode.isExpanded = false; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 0166175d94..6e5af9ed70 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2156,9 +2156,9 @@ export const useConnectionStore = defineStore("connection", () => { function objectGroupCacheKey(node: TreeNode): string { const config = node.connectionId ? getConfig(node.connectionId) : undefined; const objectTreeProfileCacheKey = driverProfileObjectTreeProfileForConnection(config)?.cacheKey; - // objects-v8: object-group listing SQL gained a pg_type branch for - // PostgreSQL-family user-defined types; older cached lists miss TYPE nodes. - const baseCacheVersion = objectTreeCacheVersion(config, node.database, node.schema, config?.db_type === "oracle" ? "objects-v7" : "objects-v8"); + // objects-v9: table nodes now carry canonical tableName metadata for + // table-scoped plugin context; older cached nodes lack that identity. + const baseCacheVersion = objectTreeCacheVersion(config, node.database, node.schema, config?.db_type === "oracle" ? "objects-v8" : "objects-v9"); const cacheVersion = objectTreeProfileCacheKey ? `${baseCacheVersion}:${objectTreeProfileCacheKey}` : baseCacheVersion; return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, cacheVersion); } @@ -2824,7 +2824,7 @@ export const useConnectionStore = defineStore("connection", () => { if (parent.type === "group-tables") return objectGroupCacheKey(parent); if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return null; const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; - const cacheVersion = ownerAwareMetadataCacheVersion(getConfig(parent.connectionId), simpleObjectDisplay ? "objects-simple-v8" : "objects-grouped-v8"); + const cacheVersion = ownerAwareMetadataCacheVersion(getConfig(parent.connectionId), simpleObjectDisplay ? "objects-simple-v9" : "objects-grouped-v9"); return schemaCacheKey(parent.connectionId, parent.database, parent.schema || "", cacheVersion); } @@ -5990,7 +5990,7 @@ export const useConnectionStore = defineStore("connection", () => { nodeKind: "simple-tables", catalog, }); - const cacheKey = schemaCacheKey(connectionId, `doris-catalog:${catalog}`, database, "objects-simple-v5"); + const cacheKey = schemaCacheKey(connectionId, `doris-catalog:${catalog}`, database, "objects-simple-v6"); if (!options?.force && !searchFilter && !tableNameFilter) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { @@ -6057,7 +6057,7 @@ export const useConnectionStore = defineStore("connection", () => { // collapses that to the database node id, so the loaded tables were attached to the // database node instead of the schema node, leaving `(default)` permanently empty. const nodeId = schema != null ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`; - const cacheKey = schemaCacheKey(connectionId, database, schema || "", objectTreeCacheVersion(configForScope, database, schema, "objects-simple-v8")); + const cacheKey = schemaCacheKey(connectionId, database, schema || "", objectTreeCacheVersion(configForScope, database, schema, "objects-simple-v9")); if (await hydrateTreeNodeFromCache(findNode(treeNodes.value, nodeId), cacheKey)) { void loadTables(connectionId, database, schema, { ...options, force: true }).catch(() => undefined); return; @@ -6097,7 +6097,7 @@ export const useConnectionStore = defineStore("connection", () => { const objectTreeProfile = driverProfileObjectTreeProfileForConnection(config); const isPublicSynonymScope = config?.db_type === "xugu" && isXuguPublicSynonymScope(schema); const isSchedulerJobScope = config?.db_type === "xugu" && isXuguSchedulerJobScope(schema); - const baseCacheVersion = objectTreeCacheVersion(config, database, schema, simpleObjectDisplay ? "objects-simple-v8" : "objects-grouped-v8"); + const baseCacheVersion = objectTreeCacheVersion(config, database, schema, simpleObjectDisplay ? "objects-simple-v9" : "objects-grouped-v9"); const cacheVersion = !simpleObjectDisplay && objectTreeProfile?.cacheKey ? `${baseCacheVersion}:${objectTreeProfile.cacheKey}` : baseCacheVersion; const cacheKey = schemaCacheKey(connectionId, database, schema || "", cacheVersion); const querySchema = connectionObjectTreeQuerySchema(config, database, schema); @@ -6438,7 +6438,7 @@ export const useConnectionStore = defineStore("connection", () => { targetParent.objectCount = mergedChildren.length; setChildren(targetParent, nextChildren); if (!options?.searchFilter) { - await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v8")), nextChildren); + await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v9")), nextChildren); } // 该分支只服务 simple 库/模式表列表;搜索分页结果不是全量,不能作为成员依据。 if (!page.hasMore && !options?.searchFilter) pruneTableVGroupStaleMembers(targetParent, nextChildren, true); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index eb9a80fea9..f4289b3206 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -459,13 +459,22 @@ export interface PluginFilesystemMutationResult { entry?: PluginFilesystemEntry; } +export type PluginContextMenuTarget = "connection" | "table"; + +export interface PluginTableContext { + connectionId: string; + database?: string; + schema?: string; + table: string; +} + export interface PluginContextMenuContribution { type: "context-menu"; id: string; label: string; description?: string; icon?: string; - menu: string; + menu: PluginContextMenuTarget; } export interface PluginResultViewContribution { diff --git a/crates/dbx-plugin-runtime/src/plugins/manifest.rs b/crates/dbx-plugin-runtime/src/plugins/manifest.rs index 4bd428e497..e1d9a09236 100644 --- a/crates/dbx-plugin-runtime/src/plugins/manifest.rs +++ b/crates/dbx-plugin-runtime/src/plugins/manifest.rs @@ -648,8 +648,8 @@ pub struct PluginWorkbenchContribution { } /// Native context-menu entry contributed to DBX surfaces. v1 targets the -/// saved-connection sidebar menu; clicks are dispatched to the plugin backend -/// as `contextMenu/` requests. +/// saved-connection and table sidebar menus; clicks are dispatched to the +/// plugin backend as `contextMenu/` requests. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginContextMenuContribution { @@ -659,7 +659,7 @@ pub struct PluginContextMenuContribution { pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub icon: Option, - /// Menu surface the item belongs to; currently only `connection`. + /// Menu surface the item belongs to: `connection` or `table`. #[serde(default)] pub menu: String, } @@ -1130,9 +1130,9 @@ fn validate_contributions( PluginContribution::ContextMenu(menu) => { validate_required_text(&menu.label, &format!("Context menu '{id}' label"), errors); validate_declared_icon(plugin_dir, &format!("Context menu '{id}' icon"), menu.icon.as_deref(), errors); - if menu.menu != "connection" { + if menu.menu != "connection" && menu.menu != "table" { errors.push(format!( - "Context menu '{id}' declares unsupported menu '{}'; only 'connection' is available", + "Context menu '{id}' declares unsupported menu '{}'; only 'connection' and 'table' are available", menu.menu )); } @@ -1488,6 +1488,30 @@ mod tests { PluginManifest, SUPPORTED_PLUGIN_HOST_API_VERSION, 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(); + let manifest = serde_json::from_value(serde_json::json!({ + "manifest_version": 1, + "id": "io.dbx.example", + "name": "Example", + "version": "1.0.0", + "publisher": "example", + "engines": { "dbx": ">=0.1.0", "host_api": "^1.0" }, + "entrypoints": { "backend": { "executable": "bin/example" } }, + "contributions": [{ + "type": "context-menu", + "id": "io.dbx.example.inspect", + "label": "Inspect", + "menu": menu + }] + })) + .unwrap(); + (dir, manifest) + } + #[test] fn connection_provider_proxy_route_defaults_false_and_parses() { let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({ @@ -1651,6 +1675,43 @@ mod tests { manifest.compatibility(dir.path(), "0.1.0").errors } + #[test] + fn accepts_connection_and_table_context_menu_targets() { + for menu in ["connection", "table"] { + let (dir, manifest) = context_menu_manifest(menu); + let compatibility = manifest.compatibility(dir.path(), "0.1.0"); + assert!(compatibility.compatible, "{menu}: {:?}", compatibility.errors); + } + } + + #[test] + fn rejects_unsupported_context_menu_targets() { + 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" + })); + } + + #[test] + fn manifest_schema_context_menu_enum_matches_runtime_targets() { + let schema_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("plugins") + .join("manifest.schema.json"); + let schema: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&schema_path).unwrap()).unwrap(); + let declared = schema["$defs"]["contextMenuContribution"]["properties"]["menu"]["enum"] + .as_array() + .expect("contextMenuContribution.menu.enum must be an array") + .iter() + .map(|value| value.as_str().unwrap().to_string()) + .collect::>(); + + assert_eq!(declared, ["connection", "table"]); + } + #[test] fn validates_manifest_v1_and_resolves_package_executable() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index 57bdec7e50..121f2eaa01 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -213,9 +213,24 @@ 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"` | 向连接的右键菜单增加动作。 | +| `context-menu` | `type`、`id`、`label`、`menu: "connection"` 或 `"table"` | 向 Sidebar Tree 的连接或 table 右键菜单增加动作。 | | `result-view` | `type`, `id`, `label` | 注册查询或任务结果视图。 | +`menu: "table"` contribution 当前只在用户对 Sidebar Tree 中的具体 table 节点打开右键菜单时生效。DBX 通过必需的 backend entrypoint 调用 `contextMenu/`,并传递 table identity: + +```json +{ + "table": { + "connectionId": "connection-id", + "database": "example", + "schema": "public", + "table": "users" + } +} +``` + +`database` 和 `schema` 是可选字段;不可用时会省略,不会强制传递空字符串。该 context 只包含对象 identity,不包含 credential、connection string 或原始连接配置。Object Browser 后续可以复用相同 identity contract,但本次版本没有把它作为 table 菜单入口。 + 同一插件内通过 `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)。 diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index df361f19da..f81587a74d 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -213,9 +213,24 @@ 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"` | Adds an action to a connection context menu. | +| `context-menu` | `type`, `id`, `label`, `menu: "connection"` or `"table"` | Adds an action to a Sidebar Tree connection or table 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: + +```json +{ + "table": { + "connectionId": "connection-id", + "database": "example", + "schema": "public", + "table": "users" + } +} +``` + +`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. + 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. diff --git a/plugins/README.md b/plugins/README.md index 521618bddd..ac6117aa57 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -431,7 +431,7 @@ The `context.result` snapshot is bounded — `{ columns, rows (<= 500), truncate ### `context-menu` -A context-menu entry is rendered **natively** by DBX (no sandbox iframe, native theme and keyboard behavior) in the declared menu surface. v1 supports the saved-connection sidebar menu: +A context-menu entry is rendered **natively** by DBX (no sandbox iframe, native theme and keyboard behavior) in the declared menu surface. v1 supports the saved-connection and table menus in the Sidebar Tree. Object Browser integration is not part of the first table contribution surface. ```json { @@ -442,7 +442,31 @@ A context-menu entry is rendered **natively** by DBX (no sandbox iframe, native } ``` -Clicking the item dispatches a `contextMenu/` backend request with a non-secret connection summary (`{ id, dbType, name, database }`). The backend entrypoint is required; return `{ "message": "..." }` to surface a toast. +For a table-scoped action, declare `menu: "table"`: + +```json +{ + "type": "context-menu", + "id": "example.inspect-table", + "label": "Inspect table", + "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: + +```json +{ + "table": { + "connectionId": "connection-id", + "database": "example", + "schema": "public", + "table": "users" + } +} +``` + +`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. ### `filesystem-provider` diff --git a/plugins/manifest.schema.json b/plugins/manifest.schema.json index ab82c20318..dce5101ed5 100644 --- a/plugins/manifest.schema.json +++ b/plugins/manifest.schema.json @@ -325,7 +325,7 @@ "label": { "type": "string", "minLength": 1 }, "description": { "type": "string" }, "icon": { "$ref": "#/$defs/assetPath" }, - "menu": { "const": "connection" } + "menu": { "enum": ["connection", "table"] } } }, "resultViewContribution": {