From 10d1509d9240043b25611325856e348ad6ce6c2e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 20 Aug 2026 15:59:10 +0000 Subject: [PATCH] fix(sidebar): isolate virtual tree row identities Closes #6773 --- .../src/components/sidebar/ConnectionTree.vue | 2 +- .../composables/__tests__/useFlatTree.spec.ts | 54 ++++++++++++++++++- apps/desktop/src/composables/useFlatTree.ts | 23 +++++--- .../lib/sidebar/sidebarTableSearchControl.ts | 3 +- .../sidebarTableSearchControl.test.ts | 24 +++++++-- packages/app-tests/useFlatTree.test.ts | 9 ++++ 6 files changed, 101 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 25cf0a9007..e422abecf7 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -2284,7 +2284,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); :buffer="SIDEBAR_TREE_SCROLL_BUFFER" :prerender="SIDEBAR_TREE_PRERENDER_COUNT" :skip-hover="true" - key-field="id" + key-field="renderKey" type-field="poolType" list-class="connection-tree-content" flow-mode diff --git a/apps/desktop/src/composables/__tests__/useFlatTree.spec.ts b/apps/desktop/src/composables/__tests__/useFlatTree.spec.ts index 64bc6c5a40..ee0555b369 100644 --- a/apps/desktop/src/composables/__tests__/useFlatTree.spec.ts +++ b/apps/desktop/src/composables/__tests__/useFlatTree.spec.ts @@ -4,7 +4,7 @@ import type { TreeNode, TreeNodeType } from "@/types/database"; function item(id: string, type: TreeNodeType, depth: number, children?: TreeNode[]): FlatTreeNode { const node: TreeNode = { id, label: id, type, children }; - return { id, type, depth, node, poolType: type }; + return { id, renderKey: `test:${depth}:${type}:${id}`, type, depth, node, poolType: type }; } function createIndex(nodes: FlatTreeNode[]) { @@ -90,16 +90,26 @@ describe("flat-tree range mutations", () => { const connection: TreeNode = { id: "connection", label: "connection", type: "connection", isExpanded: true, children: [database] }; const sibling: TreeNode = { id: "sibling", label: "sibling", type: "connection" }; const initial = flattenTree([connection, sibling]); + const connectionRenderKey = initial[0].renderKey; + const databaseRenderKey = initial[1].renderKey; + const siblingRenderKey = initial[2].renderKey; const expandedDatabase = mutateFlatTreeExpansion(initial, 1, database, true); expect(expandedDatabase.map((entry) => entry.id)).toEqual(["connection", "database", "schema", "sibling"]); + expect(expandedDatabase[0].renderKey).toBe(connectionRenderKey); + expect(expandedDatabase[1].renderKey).toBe(databaseRenderKey); + expect(expandedDatabase.at(-1)?.renderKey).toBe(siblingRenderKey); expect(expandedDatabase.at(-1)?.node).toBe(sibling); const expandedSchema = mutateFlatTreeExpansion(expandedDatabase, 2, schema, true); expect(expandedSchema.map((entry) => entry.id)).toEqual(["connection", "database", "schema", "table", "sibling"]); + expect(expandedSchema[0].renderKey).toBe(connectionRenderKey); + expect(expandedSchema[1].renderKey).toBe(databaseRenderKey); + expect(expandedSchema.at(-1)?.renderKey).toBe(siblingRenderKey); const collapsedDatabase = mutateFlatTreeExpansion(expandedSchema, 1, database, false); expect(collapsedDatabase.map((entry) => entry.id)).toEqual(["connection", "database", "sibling"]); + expect(collapsedDatabase.map((entry) => entry.renderKey)).toEqual([connectionRenderKey, databaseRenderKey, siblingRenderKey]); expect(collapsedDatabase[2].node).toBe(sibling); }); @@ -115,6 +125,10 @@ describe("flat-tree range mutations", () => { const refreshed = replaceFlatTreeChildren(initial, 1, database); expect(refreshed.map((entry) => entry.id)).toEqual(["connection", "database", "new-table", "sibling"]); + expect(refreshed[0].renderKey).toBe(initial[0].renderKey); + expect(refreshed[1].renderKey).toBe(initial[1].renderKey); + expect(refreshed[3].renderKey).toBe(initial[3].renderKey); + expect(refreshed[2].renderKey).not.toBe(initial[2].renderKey); expect(refreshed[0]).toBe(initial[0]); expect(refreshed[3]).toBe(initial[3]); }); @@ -129,3 +143,41 @@ describe("flat-tree range mutations", () => { expect(result).not.toBe(nodes); }); }); + +describe("flat-tree render identities", () => { + it("isolates equal business ids that belong to different lineages", () => { + const collidingId = "connection:a:b"; + const collidingSchemaId = "connection:a:b:c"; + const directDatabaseSchema: TreeNode = { id: collidingSchemaId, label: "c", type: "schema" }; + const directDatabase: TreeNode = { id: collidingId, label: "a:b", type: "database", isExpanded: true, children: [directDatabaseSchema] }; + const schema: TreeNode = { id: collidingId, label: "b", type: "schema" }; + const sameTypeSchema: TreeNode = { id: collidingSchemaId, label: "b:c", type: "schema" }; + const parentDatabase: TreeNode = { id: "connection:a", label: "a", type: "database", isExpanded: true, children: [schema, sameTypeSchema] }; + const connection: TreeNode = { id: "connection", label: "connection", type: "connection", isExpanded: true, children: [directDatabase, parentDatabase] }; + + const flattened = flattenTree([connection]); + const collisions = flattened.filter((entry) => entry.id === collidingId); + const sameTypeCollisions = flattened.filter((entry) => entry.id === collidingSchemaId); + + expect(collisions.map((entry) => entry.type)).toEqual(["database", "schema"]); + expect(new Set(collisions.map((entry) => entry.renderKey))).toHaveLength(2); + expect(sameTypeCollisions.map((entry) => entry.type)).toEqual(["schema", "schema"]); + expect(new Set(sameTypeCollisions.map((entry) => entry.renderKey))).toHaveLength(2); + expect(new Set(flattened.map((entry) => entry.renderKey))).toHaveLength(flattened.length); + }); + + it("keeps unique and default database business identities unchanged", () => { + const defaultDatabase: TreeNode = { id: "connection:", label: "Default", type: "database" }; + const namedDatabase: TreeNode = { id: "connection:app", label: "app", type: "database" }; + const connection: TreeNode = { id: "connection", label: "connection", type: "connection", isExpanded: true, children: [defaultDatabase, namedDatabase] }; + + const flattened = flattenTree([connection]); + + expect(flattened.map(({ id, type, poolType }) => ({ id, type, poolType }))).toEqual([ + { id: "connection", type: "connection", poolType: "connection" }, + { id: "connection:", type: "database", poolType: "database" }, + { id: "connection:app", type: "database", poolType: "database" }, + ]); + expect(new Set(flattened.map((entry) => entry.renderKey))).toHaveLength(flattened.length); + }); +}); diff --git a/apps/desktop/src/composables/useFlatTree.ts b/apps/desktop/src/composables/useFlatTree.ts index 13662280df..911028cb12 100644 --- a/apps/desktop/src/composables/useFlatTree.ts +++ b/apps/desktop/src/composables/useFlatTree.ts @@ -8,6 +8,7 @@ export interface FlatTreeNode { node: TreeNode; depth: number; id: string; + renderKey: string; type: TreeNodeType; poolType: string; } @@ -31,26 +32,35 @@ interface FlatTreeIndexOptions { isSchemaContainer: (type: TreeNodeType) => boolean; } -function walk(children: TreeNode[], depth: number, result: FlatTreeNode[]) { +export function appendFlatTreeRenderKey(parentRenderKey: string, node: Pick): string { + return `${parentRenderKey}${node.type.length}:${node.type}${node.id.length}:${node.id}`; +} + +function walk(children: TreeNode[], depth: number, result: FlatTreeNode[], parentRenderKey: string) { for (const node of children) { + // Business ids are delimiter-composed and can collide across different + // database/schema paths, while the recycler requires a unique row key. + const renderKey = appendFlatTreeRenderKey(parentRenderKey, node); result.push({ node, depth, id: node.id, + renderKey, type: node.type, poolType: node.type === "connection-group" ? `${node.type}:${node.id}` : node.type, }); if (node.isExpanded && node.children) { - walk(node.children, depth + 1, result); + walk(node.children, depth + 1, result, renderKey); } } } -function flatTreeNode(node: TreeNode, depth: number): FlatTreeNode { +function flatTreeNode(node: TreeNode, depth: number, renderKey: string): FlatTreeNode { return { node, depth, id: node.id, + renderKey, type: node.type, poolType: node.type === "connection-group" ? `${node.type}:${node.id}` : node.type, }; @@ -66,20 +76,21 @@ function visibleDescendantEnd(nodes: readonly FlatTreeNode[], parentIndex: numbe export function flattenTree(nodes: TreeNode[]): FlatTreeNode[] { const result: FlatTreeNode[] = []; - walk(nodes, 0, result); + walk(nodes, 0, result, ""); return result; } export function replaceFlatTreeChildren(nodes: readonly FlatTreeNode[], parentIndex: number, parent: TreeNode): FlatTreeNode[] { if (parentIndex < 0 || parentIndex >= nodes.length || nodes[parentIndex].id !== parent.id) return [...nodes]; + const parentItem = nodes[parentIndex]; const end = visibleDescendantEnd(nodes, parentIndex); const replacement: FlatTreeNode[] = []; - if (parent.isExpanded && parent.children) walk(parent.children, nodes[parentIndex].depth + 1, replacement); + if (parent.isExpanded && parent.children) walk(parent.children, parentItem.depth + 1, replacement, parentItem.renderKey); // One splice-shaped replacement keeps recycled-list consumers from observing // an intermediate state where old and new child ranges coexist. - return [...nodes.slice(0, parentIndex), flatTreeNode(parent, nodes[parentIndex].depth), ...replacement, ...nodes.slice(end)]; + return [...nodes.slice(0, parentIndex), flatTreeNode(parent, parentItem.depth, parentItem.renderKey), ...replacement, ...nodes.slice(end)]; } export function mutateFlatTreeExpansion(nodes: readonly FlatTreeNode[], parentIndex: number, parent: TreeNode, expanded: boolean): FlatTreeNode[] { diff --git a/apps/desktop/src/lib/sidebar/sidebarTableSearchControl.ts b/apps/desktop/src/lib/sidebar/sidebarTableSearchControl.ts index 445eb44a0b..65c4a47716 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTableSearchControl.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTableSearchControl.ts @@ -1,4 +1,4 @@ -import type { FlatTreeNode } from "@/composables/useFlatTree"; +import { appendFlatTreeRenderKey, type FlatTreeNode } from "@/composables/useFlatTree"; import type { TreeNode, TreeNodeType } from "@/types/database"; const simpleObjectParentTypes = new Set(["database", "schema", "linked-server-schema"]); @@ -59,6 +59,7 @@ export function insertSidebarTableSearchControls( node, depth: item.depth + 1, id: node.id, + renderKey: appendFlatTreeRenderKey(item.renderKey, node), type: node.type, poolType: `${node.type}:${node.id}`, }); diff --git a/packages/app-tests/sidebarTableSearchControl.test.ts b/packages/app-tests/sidebarTableSearchControl.test.ts index 4fe6369e60..22ec96b2b3 100644 --- a/packages/app-tests/sidebarTableSearchControl.test.ts +++ b/packages/app-tests/sidebarTableSearchControl.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; import { insertSidebarTableSearchControls } from "../../apps/desktop/src/lib/sidebar/sidebarTableSearchControl.ts"; -import type { FlatTreeNode } from "../../apps/desktop/src/composables/useFlatTree.ts"; +import { appendFlatTreeRenderKey, type FlatTreeNode } from "../../apps/desktop/src/composables/useFlatTree.ts"; import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; function flat(node: TreeNode, depth = 0): FlatTreeNode { @@ -9,6 +9,7 @@ function flat(node: TreeNode, depth = 0): FlatTreeNode { node, depth, id: node.id, + renderKey: appendFlatTreeRenderKey("", node), type: node.type, poolType: node.type, }; @@ -38,6 +39,7 @@ test("inserts a local table search control above simple table children", () => { ); assert.equal(nodes[1].node.tableSearchParentId, "conn:app"); assert.equal(nodes[1].depth, 1); + assert.equal(nodes[1].renderKey, appendFlatTreeRenderKey(nodes[0].renderKey, nodes[1].node)); }); test("keeps a simple local table search control visible when the current search has no results", () => { @@ -128,8 +130,14 @@ test("does not insert a local table search control for empty table scopes", () = activeQueries: {}, }); - assert.deepEqual(groupedNodes.map((item) => item.node.type), ["group-tables"]); - assert.deepEqual(simpleNodes.map((item) => item.node.type), ["schema"]); + assert.deepEqual( + groupedNodes.map((item) => item.node.type), + ["group-tables"], + ); + assert.deepEqual( + simpleNodes.map((item) => item.node.type), + ["schema"], + ); }); test("keeps a grouped local table search control visible when the current search has no results", () => { @@ -203,7 +211,10 @@ test("hides local table search controls while global sidebar filtering is active activeQueries: { "conn:app": "orders" }, }); - assert.deepEqual(nodes.map((item) => item.node.type), ["database"]); + assert.deepEqual( + nodes.map((item) => item.node.type), + ["database"], + ); }); test("does not insert local table search controls when the user setting is disabled", () => { @@ -223,5 +234,8 @@ test("does not insert local table search controls when the user setting is disab activeQueries: { "conn:app:__tables": "orders" }, }); - assert.deepEqual(nodes.map((item) => item.node.type), ["group-tables"]); + assert.deepEqual( + nodes.map((item) => item.node.type), + ["group-tables"], + ); }); diff --git a/packages/app-tests/useFlatTree.test.ts b/packages/app-tests/useFlatTree.test.ts index d3bcfea012..141b72caec 100644 --- a/packages/app-tests/useFlatTree.test.ts +++ b/packages/app-tests/useFlatTree.test.ts @@ -1,8 +1,11 @@ import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; import { test } from "vitest"; import { SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree } from "../../apps/desktop/src/composables/useFlatTree.ts"; import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; +const connectionTreeSource = readFileSync(new URL("../../apps/desktop/src/components/sidebar/ConnectionTree.vue", import.meta.url), "utf8"); + test("flattenTree preserves depth and node type for virtualized sidebar rows", () => { const nodes: TreeNode[] = [ { @@ -66,3 +69,9 @@ test("sidebar virtual tree keeps enough buffered rows for fast scrolling", () => test("sidebar virtual tree prerenders enough rows for the first frame", () => { assert.ok(SIDEBAR_TREE_PRERENDER_COUNT >= 40); }); + +test("sidebar virtual tree recycles rows by render identity", () => { + assert.match(connectionTreeSource, /key-field="renderKey"/); + assert.match(connectionTreeSource, /type-field="poolType"/); + assert.match(connectionTreeSource, /\bflow-mode\b/); +});