fix(sidebar): isolate virtual tree row identities

Closes #6773
This commit is contained in:
t8y2 committed 2026-08-20 15:59:10 +00:00
1 parent 90a18a7b23
commit 10d1509d92
6 files changed
+101 -14

No files matched your search

@@ -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
+53 -1
View File
@@ -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);
});
});
+17 -6
View File
@@ -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<TreeNode, "id" | "type">): 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[] {
@@ -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<TreeNodeType>(["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}`,
});