feat(plugin): add table-scoped context-menu contributions

This commit is contained in:
jearhe authored and GitHub committed 2026-09-22 14:23:53 +08:00
1 parent def42987a4
commit 78315c377a
15 files changed
+318 -29

No files matched your search

@@ -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;
}
+17
View File
@@ -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,
+12 -6
View File
@@ -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);
});
@@ -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<PluginContextMenuContribution>[] {
return this.listContributions("context-menu").filter((entry) => entry.contribution.menu === menu);
}
+63
View File
@@ -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> = {}): 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();
});
});
@@ -0,0 +1,44 @@
import type { PluginTableContext, TreeNode } from "@/types/database";
export type PluginTableContextNode = Pick<TreeNode, "type" | "connectionId" | "database" | "schema" | "tableName">;
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 },
};
}
+1
View File
@@ -87,6 +87,7 @@ function makeTableTreeEntry({
database,
schema,
catalog,
...(objectType === "TABLE" ? { tableName: name } : {}),
isExpanded: false,
children: [],
};
@@ -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<string, unknown>([
[
@@ -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;
+8 -8
View File
@@ -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);
+10 -1
View File
@@ -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 {