fix(sidebar): centralize tree action handling

This commit is contained in:
t8y2 committed 2026-07-14 18:10:52 +08:00
1 parent 7f096653db
commit ee85033068
28 files changed
+2705 -1032

No files matched your search

@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { createFlatTreeIndex, flattenTree, mutateFlatTreeExpansion, replaceFlatTreeChildren, type FlatTreeNode } from "@/composables/useFlatTree";
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 };
}
function createIndex(nodes: FlatTreeNode[]) {
const databaseTypes = new Set<TreeNodeType>(["database", "redis-db", "mongo-db"]);
return createFlatTreeIndex(nodes, {
isSelectable: (node) => node.type !== "table-search-control",
isBoundary: (type) => type === "connection" || type === "connection-group",
isDatabaseContainer: (type) => databaseTypes.has(type),
isSchemaContainer: (type) => type === "schema",
});
}
describe("createFlatTreeIndex", () => {
it("builds selection and lookup indexes in visible order", () => {
const nodes = [item("connection", "connection", 0, [{ id: "child", label: "child", type: "database" }]), item("database", "database", 1), item("search", "table-search-control", 2), item("table", "table", 2)];
const index = createIndex(nodes);
expect(index.visibleNodes.map((node) => node.id)).toEqual(["connection", "database", "search", "table"]);
expect(index.selectableVisibleNodes.map((node) => node.id)).toEqual(["connection", "database", "table"]);
expect(index.selectableVisibleNodeIndexById.get("table")).toBe(2);
expect(index.selectableVisibleNodeIndexById.has("search")).toBe(false);
expect(index.flatNodeIndexById.get("table")).toBe(3);
expect(index.nodeById.get("database")).toBe(nodes[1].node);
expect(index.expandableNodeIds).toEqual(["connection"]);
});
it("prefers database containers and falls back to schemas per connection", () => {
const nodes = [
item("connection-a", "connection", 0),
item("database-a", "database", 1),
item("schema-a", "schema", 2),
item("table-a", "table", 3),
item("schema-b", "schema", 2),
item("table-b", "table", 3),
item("database-b", "database", 1),
item("table-c", "table", 2),
item("connection-b", "connection", 0),
item("schema-c", "schema", 1),
item("table-d", "table", 2),
];
const index = createIndex(nodes);
expect([...index.stickyContainerIndexByIndex]).toEqual([-1, 1, 1, 1, 1, 1, 6, 6, -1, 9, 9]);
expect(index.nextDatabaseContainerIndexByIndex[1]).toBe(6);
expect(index.nextDatabaseContainerIndexByIndex[6]).toBe(-1);
expect(index.nextSchemaContainerIndexByIndex[2]).toBe(4);
expect(index.nextSchemaContainerIndexByIndex[4]).toBe(-1);
expect(index.nextSchemaContainerIndexByIndex[9]).toBe(-1);
});
it("keeps indexes isolated across connections without database containers", () => {
const nodes = [item("connection-a", "connection", 0), item("schema-a", "schema", 1), item("table-a", "table", 2), item("connection-b", "connection", 0), item("table-b", "table", 1)];
const index = createIndex(nodes);
expect(index.stickyContainerIndexByIndex[2]).toBe(1);
expect(index.stickyContainerIndexByIndex[4]).toBe(-1);
expect(index.nextSchemaContainerIndexByIndex[1]).toBe(-1);
expect(index.flatNodeIndexById.get("table-b")).toBe(4);
});
it("indexes a synthetic large visible tree in one pass", () => {
const nodes: FlatTreeNode[] = [item("connection", "connection", 0)];
for (let index = 0; index < 20_000; index += 1) {
nodes.push(item(`table-${index}`, "table", 1));
}
const index = createIndex(nodes);
expect(index.visibleNodes).toHaveLength(20_001);
expect(index.flatNodeIndexById.get("table-19999")).toBe(20_000);
expect(index.stickyContainerIndexByIndex).toHaveLength(20_001);
});
});
describe("flat-tree range mutations", () => {
it("expands and collapses only the affected descendant range", () => {
const table: TreeNode = { id: "table", label: "table", type: "table" };
const schema: TreeNode = { id: "schema", label: "schema", type: "schema", children: [table] };
const database: TreeNode = { id: "database", label: "database", type: "database", children: [schema] };
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 expandedDatabase = mutateFlatTreeExpansion(initial, 1, database, true);
expect(expandedDatabase.map((entry) => entry.id)).toEqual(["connection", "database", "schema", "sibling"]);
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"]);
const collapsedDatabase = mutateFlatTreeExpansion(expandedSchema, 1, database, false);
expect(collapsedDatabase.map((entry) => entry.id)).toEqual(["connection", "database", "sibling"]);
expect(collapsedDatabase[2].node).toBe(sibling);
});
it("atomically replaces refreshed children and preserves surrounding rows", () => {
const oldTable: TreeNode = { id: "old-table", label: "old-table", type: "table" };
const database: TreeNode = { id: "database", label: "database", type: "database", isExpanded: true, children: [oldTable] };
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 newTable: TreeNode = { id: "new-table", label: "new-table", type: "table" };
database.children = [newTable];
const refreshed = replaceFlatTreeChildren(initial, 1, database);
expect(refreshed.map((entry) => entry.id)).toEqual(["connection", "database", "new-table", "sibling"]);
expect(refreshed[0]).toBe(initial[0]);
expect(refreshed[3]).toBe(initial[3]);
});
it("falls back to an unchanged copy when the indexed parent no longer matches", () => {
const nodes = [item("connection", "connection", 0)];
const other: TreeNode = { id: "other", label: "other", type: "connection", isExpanded: true, children: [] };
const result = replaceFlatTreeChildren(nodes, 0, other);
expect(result).toEqual(nodes);
expect(result).not.toBe(nodes);
});
});
+120
View File
@@ -12,6 +12,25 @@ export interface FlatTreeNode {
poolType: string;
}
export interface FlatTreeIndex {
visibleNodes: TreeNode[];
selectableVisibleNodes: TreeNode[];
selectableVisibleNodeIndexById: Map<string, number>;
flatNodeIndexById: Map<string, number>;
nodeById: Map<string, TreeNode>;
expandableNodeIds: string[];
stickyContainerIndexByIndex: Int32Array;
nextDatabaseContainerIndexByIndex: Int32Array;
nextSchemaContainerIndexByIndex: Int32Array;
}
interface FlatTreeIndexOptions {
isSelectable: (node: TreeNode) => boolean;
isBoundary: (type: TreeNodeType) => boolean;
isDatabaseContainer: (type: TreeNodeType) => boolean;
isSchemaContainer: (type: TreeNodeType) => boolean;
}
function walk(children: TreeNode[], depth: number, result: FlatTreeNode[]) {
for (const node of children) {
result.push({
@@ -27,12 +46,113 @@ function walk(children: TreeNode[], depth: number, result: FlatTreeNode[]) {
}
}
function flatTreeNode(node: TreeNode, depth: number): FlatTreeNode {
return {
node,
depth,
id: node.id,
type: node.type,
poolType: node.type === "connection-group" ? `${node.type}:${node.id}` : node.type,
};
}
function visibleDescendantEnd(nodes: readonly FlatTreeNode[], parentIndex: number): number {
const parentDepth = nodes[parentIndex]?.depth;
if (parentDepth == null) return parentIndex;
let end = parentIndex + 1;
while (end < nodes.length && nodes[end].depth > parentDepth) end += 1;
return end;
}
export function flattenTree(nodes: TreeNode[]): FlatTreeNode[] {
const result: FlatTreeNode[] = [];
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 end = visibleDescendantEnd(nodes, parentIndex);
const replacement: FlatTreeNode[] = [];
if (parent.isExpanded && parent.children) walk(parent.children, nodes[parentIndex].depth + 1, replacement);
// 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)];
}
export function mutateFlatTreeExpansion(nodes: readonly FlatTreeNode[], parentIndex: number, parent: TreeNode, expanded: boolean): FlatTreeNode[] {
if (parent.isExpanded !== expanded) parent.isExpanded = expanded;
return replaceFlatTreeChildren(nodes, parentIndex, parent);
}
export function shouldVirtualizeFlatTree(count: number): boolean {
return count > 0;
}
export function createFlatTreeIndex(nodes: readonly FlatTreeNode[], options: FlatTreeIndexOptions): FlatTreeIndex {
const visibleNodes: TreeNode[] = [];
const selectableVisibleNodes: TreeNode[] = [];
const selectableVisibleNodeIndexById = new Map<string, number>();
const flatNodeIndexById = new Map<string, number>();
const nodeById = new Map<string, TreeNode>();
const expandableNodeIds: string[] = [];
const stickyContainerIndexByIndex = new Int32Array(nodes.length);
const nextDatabaseContainerIndexByIndex = new Int32Array(nodes.length);
const nextSchemaContainerIndexByIndex = new Int32Array(nodes.length);
stickyContainerIndexByIndex.fill(-1);
nextDatabaseContainerIndexByIndex.fill(-1);
nextSchemaContainerIndexByIndex.fill(-1);
let databaseContainerIndex = -1;
let schemaContainerIndex = -1;
for (let index = 0; index < nodes.length; index += 1) {
const item = nodes[index];
const node = item.node;
visibleNodes.push(node);
flatNodeIndexById.set(item.id, index);
nodeById.set(item.id, node);
if (node.children?.length) expandableNodeIds.push(item.id);
if (options.isSelectable(node)) {
selectableVisibleNodeIndexById.set(item.id, selectableVisibleNodes.length);
selectableVisibleNodes.push(node);
}
if (options.isBoundary(item.type)) {
databaseContainerIndex = -1;
schemaContainerIndex = -1;
continue;
}
if (options.isDatabaseContainer(item.type)) databaseContainerIndex = index;
if (options.isSchemaContainer(item.type)) schemaContainerIndex = index;
stickyContainerIndexByIndex[index] = databaseContainerIndex >= 0 ? databaseContainerIndex : schemaContainerIndex;
}
let nextDatabaseContainerIndex = -1;
let nextSchemaContainerIndex = -1;
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const item = nodes[index];
if (options.isBoundary(item.type)) {
nextDatabaseContainerIndex = -1;
nextSchemaContainerIndex = -1;
continue;
}
nextDatabaseContainerIndexByIndex[index] = nextDatabaseContainerIndex;
nextSchemaContainerIndexByIndex[index] = nextSchemaContainerIndex;
if (options.isDatabaseContainer(item.type)) nextDatabaseContainerIndex = index;
if (options.isSchemaContainer(item.type)) nextSchemaContainerIndex = index;
}
return {
visibleNodes,
selectableVisibleNodes,
selectableVisibleNodeIndexById,
flatNodeIndexById,
nodeById,
expandableNodeIds,
stickyContainerIndexByIndex,
nextDatabaseContainerIndexByIndex,
nextSchemaContainerIndexByIndex,
};
}