fix(sidebar): centralize tree action handling
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user