From 801a96a0034acbd07c70ac9c278a06176ad6deae Mon Sep 17 00:00:00 2001 From: Elias <45232878+mapan0424@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:34:50 +0800 Subject: [PATCH] feat(xugu): group object type members --- .../sidebar/SidebarTreeRuntimeHost.vue | 6 + .../src/components/sidebar/TreeItem.vue | 12 +- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../__tests__/sidebar/xuguTypeMembers.spec.ts | 27 ++++- .../src/lib/sidebar/sidebarTreeItemLayout.ts | 2 + apps/desktop/src/lib/sidebar/treeNodeGroup.ts | 2 + apps/desktop/src/lib/sidebar/treeNodeIcon.ts | 8 ++ .../src/lib/sidebar/xuguTypeMembers.ts | 42 ++++++- .../connectionStore.metadataLoading.spec.ts | 108 ++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 84 +++++++++----- apps/desktop/src/types/database.ts | 2 + 18 files changed, 273 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index b7a07d1f48..1e23330ea5 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -614,6 +614,12 @@ async function toggle() { return; } + if (node.type === "type-attributes" || node.type === "type-methods") { + node.isExpanded = !node.isExpanded; + emitNodeToggled(node, wasExpanded); + return; + } + if (node.type === "group-extensions" && connectionStore.canUseLoadedTreeNodeToggle(node)) { node.isExpanded = !node.isExpanded; if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 7d872d826c..c68a86a239 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -229,6 +229,14 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { } else { return { icon: Columns3, colorClass: "text-muted-foreground" }; } + case "type-attribute": + return { icon: Columns3, colorClass: "text-muted-foreground" }; + case "type-method": + return { icon: Braces, colorClass: "text-amber-500" }; + case "type-attributes": + return { icon: ListTree, colorClass: "text-green-400" }; + case "type-methods": + return { icon: Braces, colorClass: "text-amber-500" }; case "group-columns": return { icon: ListTree, colorClass: "text-green-400" }; case "group-indexes": @@ -1247,7 +1255,9 @@ function onKeydown(event: KeyboardEvent) { node.type === 'group-synonyms' || node.type === 'group-packages' || node.type === 'group-types' || - node.type === 'group-partitions') && + node.type === 'group-partitions' || + node.type === 'type-attributes' || + node.type === 'type-methods') && node.objectCount != null " class="text-muted-foreground text-[10px] shrink-0" diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 861777a7e6..ad1bc757a2 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2853,6 +2853,8 @@ export default { linkedServers: "Linked Servers", defaultDatabase: "Default DB", columns: "Columns", + attributes: "Attributes", + methods: "Methods", indexes: "Indexes", foreignKeys: "Foreign Keys", triggers: "Triggers", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c223dee3cd..e03306e7f9 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2794,6 +2794,8 @@ export default withEnglishFallback({ linkedServers: "Servidores vinculados", defaultDatabase: "Base predeterminada", columns: "Columnas", + attributes: "Atributos", + methods: "Métodos", indexes: "Índices", foreignKeys: "Claves foráneas", triggers: "Disparadores", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 0666daa700..db23f8aee4 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2792,6 +2792,8 @@ export default withEnglishFallback({ linkedServers: "Server Collegati", defaultDatabase: "DB Predefinito", columns: "Colonne", + attributes: "Attributi", + methods: "Metodi", indexes: "Indici", foreignKeys: "Chiavi Esterne", triggers: "Trigger", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index eb61b9d1de..594721b538 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2818,6 +2818,8 @@ export default withEnglishFallback({ damengJobAdmin: "エージェントジョブ", defaultDatabase: "デフォルトDB", columns: "列", + attributes: "属性", + methods: "メソッド", indexes: "インデックス", foreignKeys: "外部キー", triggers: "トリガー", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index d3ef223afd..5eccd03dbf 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2704,6 +2704,8 @@ export default withEnglishFallback({ linkedServers: "연결된 서버", defaultDatabase: "기본 DB", columns: "컬럼", + attributes: "속성", + methods: "메서드", indexes: "인덱스", foreignKeys: "외래 키", triggers: "트리거", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 77384d22f0..fa4810164a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2795,6 +2795,8 @@ export default withEnglishFallback({ materializedViews: "Visualizações Materializadas", defaultDatabase: "Banco padrão", columns: "Colunas", + attributes: "Atributos", + methods: "Métodos", indexes: "Índices", foreignKeys: "Chaves estrangeiras", triggers: "Triggers", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8b34b3343a..162a67ea2f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2778,6 +2778,8 @@ export default withEnglishFallback({ linkedServers: "链接服务器", defaultDatabase: "默认库", columns: "字段", + attributes: "属性", + methods: "方法", indexes: "索引", foreignKeys: "外键", triggers: "触发器", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index ac27f9e047..3442350f4b 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2794,6 +2794,8 @@ export default withEnglishFallback({ materializedViews: "具體化檢視", defaultDatabase: "預設庫", columns: "欄位", + attributes: "屬性", + methods: "方法", indexes: "索引", foreignKeys: "外鍵", triggers: "觸發器", diff --git a/apps/desktop/src/lib/__tests__/sidebar/xuguTypeMembers.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/xuguTypeMembers.spec.ts index bd7f30998d..1efbbfb314 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/xuguTypeMembers.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/xuguTypeMembers.spec.ts @@ -31,11 +31,32 @@ describe("Xugu object type members", () => { { name: "itemId", kind: "column", data_type: "INTEGER" }, ]); - expect(nodes.map((node) => ({ type: node.type, label: node.label }))).toEqual([ - { type: "type-attribute", label: "itemId (INTEGER)" }, + expect(nodes.map((node) => ({ type: node.type, label: node.label, objectCount: node.objectCount }))).toEqual([ + { type: "type-attributes", label: "tree.attributes", objectCount: 1 }, + { type: "type-methods", label: "tree.methods", objectCount: 2 }, + ]); + expect(nodes.every((node) => node.parentType === "type" && node.parentName === "OrderType")).toBe(true); + expect(nodes[0].children?.map((node) => ({ type: node.type, label: node.label }))).toEqual([{ type: "type-attribute", label: "itemId (INTEGER)" }]); + expect(nodes[1].children?.map((node) => ({ type: node.type, label: node.label }))).toEqual([ { type: "type-method", label: "FUNCTION total(quantity INTEGER, price NUMERIC(12,2)) → NUMERIC(18,2)" }, { type: "type-method", label: "PROCEDURE rename(OUT result VARCHAR(40))" }, ]); - expect(nodes.every((node) => node.parentType === "type" && node.parentName === "OrderType" && node.children === undefined)).toBe(true); + expect(nodes.flatMap((node) => node.children ?? []).every((node) => node.parentType === "type" && node.parentName === "OrderType" && node.children === undefined)).toBe(true); + }); + + it("omits the methods group when a type only has attributes", () => { + const nodes = buildXuguTypeMemberNodes(typeNode, [{ name: "itemId", kind: "column", data_type: "INTEGER" }]); + + expect(nodes.map((node) => ({ type: node.type, objectCount: node.objectCount }))).toEqual([{ type: "type-attributes", objectCount: 1 }]); + }); + + it("omits the attributes group when a type only has methods", () => { + const nodes = buildXuguTypeMemberNodes(typeNode, [{ name: "rename", kind: "procedure", signature: "newName VARCHAR(40)" }]); + + expect(nodes.map((node) => ({ type: node.type, objectCount: node.objectCount }))).toEqual([{ type: "type-methods", objectCount: 1 }]); + }); + + it("returns no groups when a type has no members", () => { + expect(buildXuguTypeMemberNodes(typeNode, [])).toEqual([]); }); }); diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts index fda695dbc4..fa6b4822be 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeItemLayout.ts @@ -12,6 +12,8 @@ const leafTypes: Set = new Set([ "package-body", "type-body", "type-member", + "type-attribute", + "type-method", "object-browser", "redis-db", "mq-tenant", diff --git a/apps/desktop/src/lib/sidebar/treeNodeGroup.ts b/apps/desktop/src/lib/sidebar/treeNodeGroup.ts index 036b55cde3..c4a134ce39 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeGroup.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeGroup.ts @@ -19,6 +19,8 @@ const treeGroupNodeTypes = new Set([ "group-types", "group-partitions", "group-extensions", + "type-attributes", + "type-methods", ]); export function isTreeGroupNodeType(type: TreeNodeType): boolean { diff --git a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts index a48766d83b..92c2ac4206 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeIcon.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeIcon.ts @@ -46,6 +46,14 @@ export function getTreeNodeIconInfo(node: TreeNode): TreeNodeIconInfo | null { return { icon: Eye, colorClass: "text-indigo-500" }; case "column": return { icon: Columns3, colorClass: (node.meta as ColumnInfo | undefined)?.is_primary_key ? "text-orange-400" : "text-muted-foreground" }; + case "type-attribute": + return { icon: Columns3, colorClass: "text-muted-foreground" }; + case "type-method": + return { icon: Braces, colorClass: "text-amber-500" }; + case "type-attributes": + return { icon: ListTree, colorClass: "text-green-400" }; + case "type-methods": + return { icon: Braces, colorClass: "text-amber-500" }; case "group-columns": return { icon: ListTree, colorClass: "text-green-400" }; case "group-indexes": diff --git a/apps/desktop/src/lib/sidebar/xuguTypeMembers.ts b/apps/desktop/src/lib/sidebar/xuguTypeMembers.ts index 5f3b565522..0ec7339935 100644 --- a/apps/desktop/src/lib/sidebar/xuguTypeMembers.ts +++ b/apps/desktop/src/lib/sidebar/xuguTypeMembers.ts @@ -1,10 +1,15 @@ import type { CompletionAssistantCandidate, TreeNode } from "@/types/database"; +export type XuguTypeMemberGroupLabels = { + attributes: string; + methods: string; +}; + export function isXuguTypeMemberContainer(node: TreeNode, databaseType?: string): boolean { return databaseType === "xugu" && node.type === "type" && node.xuguTypeMembersExpandable === true && !!node.connectionId && !!node.database; } -export function buildXuguTypeMemberNodes(typeNode: TreeNode, candidates: readonly CompletionAssistantCandidate[]): TreeNode[] { +export function buildXuguTypeMemberNodes(typeNode: TreeNode, candidates: readonly CompletionAssistantCandidate[], labels: XuguTypeMemberGroupLabels = { attributes: "tree.attributes", methods: "tree.methods" }): TreeNode[] { const parentName = typeNode.objectName || typeNode.label; const parentSchema = typeNode.schema; const seen = new Set(); @@ -53,5 +58,38 @@ export function buildXuguTypeMemberNodes(typeNode: TreeNode, candidates: readonl }); } - return [...attributes, ...methods]; + const groupBase = { + connectionId: typeNode.connectionId, + database: typeNode.database, + schema: parentSchema, + parentName, + parentSchema, + parentType: "type" as const, + isExpanded: false, + }; + + const groups: TreeNode[] = []; + if (attributes.length > 0) { + groups.push({ + ...groupBase, + id: `${typeNode.id}:attributes`, + label: labels.attributes, + type: "type-attributes", + objectName: parentName, + objectCount: attributes.length, + children: attributes, + }); + } + if (methods.length > 0) { + groups.push({ + ...groupBase, + id: `${typeNode.id}:methods`, + label: labels.methods, + type: "type-methods", + objectName: parentName, + objectCount: methods.length, + children: methods, + }); + } + return groups; } diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index d650d5b114..c72f75fa31 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -1,5 +1,6 @@ import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout"; import type { ConnectionConfig, ObjectInfo, TableInfo, TreeNode } from "@/types/database"; function installLocalStorage() { @@ -201,6 +202,113 @@ describe("connectionStore metadata loading", () => { expect(packageNode.children?.every((child) => child.parentName === "business_api")).toBe(true); }, 15000); + it("loads Xugu object type members through the scoped completion endpoint", async () => { + const completionAssistantSearch = vi.fn(async (request: { object_kinds?: string[] }) => ({ + candidates: request.object_kinds?.includes("column") + ? [ + { name: "street", kind: "column", data_type: "VARCHAR(120)" }, + { name: "created_at", kind: "column", data_type: "DATETIME" }, + ] + : [{ name: "format", kind: "function", signature: "p_locale IN VARCHAR", data_type: "VARCHAR" }], + incomplete: false, + fallback_used: false, + })); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined), completionAssistantSearch })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = xuguConnection(); + const typeNode: TreeNode = { + id: "xugu-1:app_db:app_schema:type:address_t", + label: "ADDRESS_T", + type: "type", + objectName: "ADDRESS_T", + connectionId: connection.id, + database: "app_db", + schema: "app_schema", + xuguTypeMembersExpandable: true, + children: [], + isExpanded: false, + }; + store.connections = [connection]; + store.connectedIds = new Set([connection.id]); + store.treeNodes = [typeNode]; + + await store.loadXuguTypeMembers(typeNode); + + expect(completionAssistantSearch).toHaveBeenCalledTimes(2); + expect(completionAssistantSearch).toHaveBeenCalledWith( + expect.objectContaining({ + connection_id: connection.id, + database: "app_db", + schema: "app_schema", + object_kinds: ["column"], + parent_schema: "app_schema", + parent_name: "ADDRESS_T", + parent_type: "type", + }), + ); + expect(completionAssistantSearch).toHaveBeenCalledWith( + expect.objectContaining({ + connection_id: connection.id, + database: "app_db", + schema: "app_schema", + object_kinds: ["routine"], + parent_schema: "app_schema", + parent_name: "ADDRESS_T", + parent_type: "type", + }), + ); + expect(typeNode.isExpanded).toBe(true); + expect(typeNode.children?.map((child) => ({ label: child.label, objectCount: child.objectCount }))).toEqual([ + { label: "tree.attributes", objectCount: 2 }, + { label: "tree.methods", objectCount: 1 }, + ]); + expect(typeNode.children?.[0].children?.map((child) => child.label)).toEqual(["street (VARCHAR(120))", "created_at (DATETIME)"]); + expect(typeNode.children?.[1].children?.map((child) => child.label)).toEqual(["FUNCTION format(p_locale IN VARCHAR) → VARCHAR"]); + expect(typeNode.children?.every((child) => child.parentName === "ADDRESS_T")).toBe(true); + }, 15000); + + it("hides the Xugu type expander after loading no members", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [], incomplete: false, fallback_used: false }); + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ checkConnectionHealth: vi.fn().mockResolvedValue(undefined), completionAssistantSearch })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = xuguConnection(); + const typeNode: TreeNode = { + id: "xugu-1:app_db:app_schema:type:empty_t", + label: "EMPTY_T", + type: "type", + objectName: "EMPTY_T", + connectionId: connection.id, + database: "app_db", + schema: "app_schema", + xuguTypeMembersExpandable: true, + children: [], + isExpanded: false, + }; + store.connections = [connection]; + store.connectedIds = new Set([connection.id]); + store.treeNodes = [typeNode]; + + await store.loadXuguTypeMembers(typeNode); + + expect(completionAssistantSearch).toHaveBeenCalledTimes(2); + expect(typeNode.children).toEqual([]); + expect(typeNode.isExpanded).toBe(false); + expect(typeNode.xuguTypeMembersExpandable).toBe(false); + expect( + canTreeNodeShowExpander({ + type: typeNode.type, + childCount: typeNode.children?.length ?? 0, + explicitContainer: typeNode.xuguTypeMembersExpandable === true, + }), + ).toBe(false); + }, 15000); + it("loads Oracle package overloads through the shared package member path", async () => { const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [ diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 358f69665a..ce24ef884a 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -5728,40 +5728,64 @@ export const useConnectionStore = defineStore("connection", () => { const schema = node.schema || ""; const parentName = node.objectName || node.label; - node.isLoading = true; + let load = beginTreeNodeLoad(node); try { - const [attributes, methods] = await Promise.all([ - completionAssistantSearch({ - connection_id: connectionId, + await runTreeMetadataLoad( + { + kind: "xugu-type-members", + connectionId, database, schema, - object_kinds: ["column"], - mask: "", - max_results: 500, - global_search: false, - parent_schema: schema, - parent_name: parentName, - parent_type: "type", - match_mode: "prefix", - }), - completionAssistantSearch({ - connection_id: connectionId, - database, - schema, - object_kinds: ["routine"], - mask: "", - max_results: 500, - global_search: false, - parent_schema: schema, - parent_name: parentName, - parent_type: "type", - match_mode: "prefix", - }), - ]); - node.children = buildXuguTypeMemberNodes(node, [...attributes.candidates, ...methods.candidates]); - node.isExpanded = true; + nodeKind: node.type, + extra: { typeName: parentName }, + }, + async () => { + await ensureConnected(connectionId); + load = reclaimTreeNodeLoad(load, node); + const [attributes, methods] = await Promise.all([ + completionAssistantSearch({ + connection_id: connectionId, + database, + schema, + object_kinds: ["column"], + mask: "", + max_results: 500, + global_search: false, + parent_schema: schema, + parent_name: parentName, + parent_type: "type", + match_mode: "prefix", + }), + completionAssistantSearch({ + connection_id: connectionId, + database, + schema, + object_kinds: ["routine"], + mask: "", + max_results: 500, + global_search: false, + parent_schema: schema, + parent_name: parentName, + parent_type: "type", + match_mode: "prefix", + }), + ]); + const targetNode = treeNodeLoadTarget(load); + if (!targetNode) return; + const children = buildXuguTypeMemberNodes(targetNode, [...attributes.candidates, ...methods.candidates], { + attributes: "tree.attributes", + methods: "tree.methods", + }); + setChildren(targetNode, children); + targetNode.isExpanded = children.length > 0; + if (children.length === 0) targetNode.xuguTypeMembersExpandable = false; + }, + ); + } catch (error) { + recordMetadataLoadError(connectionId, error, load); + throw error; } finally { - node.isLoading = false; + finishTreeNodeLoad(load); } } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 78a3f6dd16..70efe936e7 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -899,6 +899,8 @@ export type TreeNodeType = | "column" | "type-attribute" | "type-method" + | "type-attributes" + | "type-methods" | "index" | "fkey" | "trigger"