feat(plugin): add table-scoped context-menu contributions
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user