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

+48 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import { migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import { buildTreeNodesFromLayout } from "@/lib/sidebar/sidebarLayout";
import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
@@ -17,7 +17,7 @@ describe("sidebar pinned tree nodes", () => {
},
];
expect(updatePinnedTreeNodeInPlace(tree, "conn:db:b", true)).toBe("siblings");
expect(updatePinnedTreeNodeInPlace(tree, tree[0].children![1], true)).toBe("siblings");
expect(tree[0].children?.map((node) => node.id)).toEqual(["conn:db:b", "conn:db:a"]);
expect(tree[0].children?.[0].pinned).toBe(true);
@@ -29,12 +29,57 @@ describe("sidebar pinned tree nodes", () => {
{ id: "group-b", label: "B", type: "connection-group" },
];
expect(updatePinnedTreeNodeInPlace(tree, "group-b", true)).toBe("root");
expect(updatePinnedTreeNodeInPlace(tree, tree[1], true)).toBe("root");
expect(tree.map((node) => node.id)).toEqual(["group-b", "group-a"]);
expect(tree[0].pinned).toBe(true);
});
it("scopes duplicate node ids by database when pinning", () => {
const databaseA: TreeNode = {
id: "conn:a",
label: "A",
type: "database",
children: [{ id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "a" }],
};
const databaseB: TreeNode = {
id: "conn:b",
label: "B",
type: "database",
children: [{ id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "b" }],
};
const tree: TreeNode[] = [{ id: "conn", label: "Connection", type: "connection", children: [databaseA, databaseB] }];
expect(updatePinnedTreeNodeInPlace(tree, databaseA.children![0], true)).toBe("siblings");
expect(databaseA.children![0].pinned).toBe(true);
expect(databaseB.children![0].pinned).not.toBe(true);
});
it("clears stale legacy duplicate pins after switching to scoped keys", () => {
const tableA: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "a", pinned: true };
const tableB: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "b", pinned: true };
const tree: TreeNode[] = [
{ id: "conn:a", label: "A", type: "database", children: [tableA] },
{ id: "conn:b", label: "B", type: "database", children: [tableB] },
];
syncPinnedTreeNodeStateInPlace(tree, new Set([treeNodePinKey(tableA)]));
expect(tableA.pinned).toBe(true);
expect(tableB.pinned).toBe(false);
});
it("migrates a legacy id once instead of pinning every colliding node", () => {
const tableA: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "a" };
const tableB: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "b" };
const migrated = migrateLegacyPinnedTreeNodeIds([tableA, tableB], new Set(["duplicate-table-id"]));
expect(migrated.changed).toBe(true);
expect(migrated.ids).toEqual(new Set([treeNodePinKey(tableA)]));
});
it("applies pinned state to connection groups when rebuilding from layout", () => {
const layout: SidebarLayout = {
groups: [
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/database";
import { createSidebarActionRequest, createSidebarActionTarget, findSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
function tableNode(): TreeNode {
return {
id: "table-1",
label: "users",
type: "table",
connectionId: "connection-1",
database: "app",
schema: "public",
children: [{ id: "column-1", label: "id", type: "column" }],
};
}
describe("sidebar action targets", () => {
it("captures a frozen shallow snapshot without retaining children", () => {
const node = tableNode();
const target = createSidebarActionTarget(node);
node.label = "recycled-row";
node.connectionId = "connection-2";
expect(target).toMatchObject({ label: "users", connectionId: "connection-1", children: undefined, hiddenChildren: undefined });
expect(Object.isFrozen(target)).toBe(true);
});
it("captures selection independently from later selection changes", () => {
const selectedNodeIds = ["table-1", "table-2"];
const request = createSidebarActionRequest(tableNode(), selectedNodeIds, { initialEditing: true });
selectedNodeIds.splice(0, selectedNodeIds.length, "table-3");
expect(request.selectedNodeIds).toEqual(["table-1", "table-2"]);
expect(request.payload).toEqual({ initialEditing: true });
expect(Object.isFrozen(request.selectedNodeIds)).toBe(true);
expect(Object.isFrozen(request)).toBe(true);
});
it("does not resolve a recycled row with the same local id", () => {
const target = createSidebarActionTarget(tableNode());
const recycled = tableNode();
recycled.connectionId = "connection-2";
expect(findSidebarActionTarget([recycled], target)).toBeNull();
});
it("returns null when the action target was removed", () => {
const target = createSidebarActionTarget(tableNode());
expect(findSidebarActionTarget([], target)).toBeNull();
});
it("resolves targets stored only in hidden children", () => {
const node = tableNode();
const target = createSidebarActionTarget(node);
const root: TreeNode = { id: "root", label: "root", type: "database", hiddenChildren: [node] };
expect(findSidebarActionTarget([root], target)).toBe(node);
});
});
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately } from "@/lib/sidebar/sidebarDataOpenCoordinator";
describe("sidebarDataOpenCoordinator", () => {
afterEach(() => {
cancelPendingSidebarDataOpen();
});
it("cancels the active request when a newer open starts", async () => {
const cancel = vi.fn();
let firstIsCurrent = true;
runSidebarDataOpenImmediately(async (request) => {
request.registerCancel(cancel);
await Promise.resolve();
firstIsCurrent = request.isCurrent();
});
runSidebarDataOpenImmediately(() => undefined);
await Promise.resolve();
expect(cancel).toHaveBeenCalledOnce();
expect(firstIsCurrent).toBe(false);
});
it("runs registered cancellation immediately for an already stale request", async () => {
const cancel = vi.fn();
let registerLateCancel: (() => void) | undefined;
runSidebarDataOpenImmediately((request) => {
registerLateCancel = () => request.registerCancel(cancel);
});
runSidebarDataOpenImmediately(() => undefined);
registerLateCancel?.();
await Promise.resolve();
expect(cancel).toHaveBeenCalledOnce();
});
it("recovers after a runner throws synchronously", async () => {
const nextRunner = vi.fn();
runSidebarDataOpenImmediately(() => {
throw new Error("boom");
});
await Promise.resolve();
runSidebarDataOpenImmediately(nextRunner);
await Promise.resolve();
expect(nextRunner).toHaveBeenCalledOnce();
});
it("keeps only the newest rapid navigation request current", async () => {
const states: boolean[] = [];
const captures: Array<() => void> = [];
for (let index = 0; index < 3; index += 1) {
runSidebarDataOpenImmediately((request) => {
captures.push(() => states.push(request.isCurrent()));
});
}
captures.forEach((capture) => capture());
expect(states).toEqual([false, false, true]);
});
it("does not cancel work that was not registered as sidebar-owned", async () => {
const sidebarCancel = vi.fn();
const unrelatedEditorCancel = vi.fn();
runSidebarDataOpenImmediately((request) => request.registerCancel(sidebarCancel));
runSidebarDataOpenImmediately(() => undefined);
await Promise.resolve();
expect(sidebarCancel).toHaveBeenCalledOnce();
expect(unrelatedEditorCancel).not.toHaveBeenCalled();
});
it("prevents a superseded connection completion from becoming current", async () => {
let finishConnection: (() => void) | undefined;
let completionWasCurrent = true;
runSidebarDataOpenImmediately(async (request) => {
await new Promise<void>((resolve) => {
finishConnection = resolve;
});
completionWasCurrent = request.isCurrent();
});
runSidebarDataOpenImmediately(() => undefined);
finishConnection?.();
await Promise.resolve();
await Promise.resolve();
expect(completionWasCurrent).toBe(false);
});
});
+73 -11
View File
@@ -2,6 +2,33 @@ import type { TreeNode } from "@/types/database";
export type PinnedTreeNodeUpdateScope = "missing" | "root" | "siblings";
export function treeNodePinKey(node: TreeNode): string {
if (!node.connectionId) return node.id;
const identity = [node.database || "", node.schema || "", node.catalog || "", node.type, node.objectName || node.tableName || node.label, node.signature || "", node.id];
return `${node.connectionId}:pin:v2:${encodeURIComponent(JSON.stringify(identity))}`;
}
export function migrateLegacyPinnedTreeNodeIds(nodes: readonly TreeNode[], pinnedIds: Set<string>): { ids: Set<string>; changed: boolean } {
const next = new Set(pinnedIds);
let changed = false;
const visit = (items: readonly TreeNode[]) => {
for (const node of items) {
const pinKey = treeNodePinKey(node);
if (pinKey !== node.id && next.has(node.id) && !next.has(pinKey)) {
// A legacy id cannot distinguish colliding nodes. Claim it once for the
// first matching loaded node, then persist the fully scoped identity.
next.delete(node.id);
next.add(pinKey);
changed = true;
}
if (node.children) visit(node.children);
if (node.hiddenChildren) visit(node.hiddenChildren);
}
};
visit(nodes);
return { ids: next, changed };
}
export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
const pinned: T[] = [];
const unpinned: T[] = [];
@@ -14,19 +41,20 @@ export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean):
return [...pinned, ...unpinned];
}
function findTreeNodeLocation(nodes: TreeNode[], id: string, parent: TreeNode | null = null): { node: TreeNode; parent: TreeNode | null } | null {
function findTreeNodeLocation(nodes: TreeNode[], target: TreeNode, parent: TreeNode | null = null): { node: TreeNode; parent: TreeNode | null } | null {
const targetKey = treeNodePinKey(target);
for (const node of nodes) {
if (node.id === id) return { node, parent };
if (node === target || treeNodePinKey(node) === targetKey) return { node, parent };
if (node.children) {
const found = findTreeNodeLocation(node.children, id, node);
const found = findTreeNodeLocation(node.children, target, node);
if (found) return found;
}
}
return null;
}
export function updatePinnedTreeNodeInPlace(nodes: TreeNode[], id: string, pinned: boolean): PinnedTreeNodeUpdateScope {
const location = findTreeNodeLocation(nodes, id);
export function updatePinnedTreeNodeInPlace(nodes: TreeNode[], target: TreeNode, pinned: boolean): PinnedTreeNodeUpdateScope {
const location = findTreeNodeLocation(nodes, target);
if (!location) return "missing";
location.node.pinned = pinned;
@@ -42,13 +70,47 @@ export function updatePinnedTreeNodeInPlace(nodes: TreeNode[], id: string, pinne
return "root";
}
export function applyPinnedTreeNodeState(nodes: TreeNode[], pinnedIds: Set<string>): TreeNode[] {
function clonePinnedTreeNode(node: TreeNode, pinnedIds: Set<string>, clones: WeakMap<TreeNode, TreeNode>): TreeNode {
const existing = clones.get(node);
if (existing) return existing;
const clone: TreeNode = {
...node,
pinned: pinnedIds.has(treeNodePinKey(node)) || pinnedIds.has(node.id),
};
clones.set(node, clone);
if (node.children) clone.children = applyPinnedTreeNodeStateInternal(node.children, pinnedIds, clones);
if (node.hiddenChildren) clone.hiddenChildren = applyPinnedTreeNodeStateInternal(node.hiddenChildren, pinnedIds, clones);
return clone;
}
function applyPinnedTreeNodeStateInternal(nodes: TreeNode[], pinnedIds: Set<string>, clones: WeakMap<TreeNode, TreeNode>): TreeNode[] {
return orderPinnedFirst(
nodes.map((node) => ({
...node,
pinned: pinnedIds.has(node.id),
children: node.children ? applyPinnedTreeNodeState(node.children, pinnedIds) : node.children,
})),
nodes.map((node) => clonePinnedTreeNode(node, pinnedIds, clones)),
(node) => !!node.pinned,
);
}
export function applyPinnedTreeNodeState(nodes: TreeNode[], pinnedIds: Set<string>): TreeNode[] {
return applyPinnedTreeNodeStateInternal(nodes, pinnedIds, new WeakMap());
}
function syncPinnedTreeNodeStateInPlaceInternal(nodes: TreeNode[], pinnedIds: Set<string>, visited: WeakSet<TreeNode>): void {
for (const node of nodes) {
if (visited.has(node)) continue;
visited.add(node);
node.pinned = pinnedIds.has(treeNodePinKey(node)) || pinnedIds.has(node.id);
if (node.children) {
syncPinnedTreeNodeStateInPlaceInternal(node.children, pinnedIds, visited);
node.children = orderPinnedFirst(node.children, (child) => !!child.pinned);
}
if (node.hiddenChildren) {
syncPinnedTreeNodeStateInPlaceInternal(node.hiddenChildren, pinnedIds, visited);
node.hiddenChildren = orderPinnedFirst(node.hiddenChildren, (child) => !!child.pinned);
}
}
nodes.splice(0, nodes.length, ...orderPinnedFirst(nodes, (node) => !!node.pinned));
}
export function syncPinnedTreeNodeStateInPlace(nodes: TreeNode[], pinnedIds: Set<string>): void {
syncPinnedTreeNodeStateInPlaceInternal(nodes, pinnedIds, new WeakSet());
}
@@ -0,0 +1,45 @@
import type { TreeNode } from "@/types/database";
export type SidebarActionTarget = Readonly<TreeNode>;
export interface SidebarActionRequest<TPayload = undefined> {
target: SidebarActionTarget;
selectedNodeIds: readonly string[];
payload: TPayload;
}
function sameActionTarget(left: TreeNode, right: SidebarActionTarget): boolean {
return left.id === right.id && left.type === right.type && left.connectionId === right.connectionId && left.database === right.database && left.schema === right.schema && left.catalog === right.catalog && left.label === right.label && left.signature === right.signature;
}
export function findSidebarActionTarget(nodes: readonly TreeNode[], target: SidebarActionTarget): TreeNode | null {
const visited = new WeakSet<TreeNode>();
const find = (items: readonly TreeNode[]): TreeNode | null => {
for (const node of items) {
if (visited.has(node)) continue;
visited.add(node);
if (sameActionTarget(node, target)) return node;
const child = node.children ? find(node.children) : null;
if (child) return child;
const hiddenChild = node.hiddenChildren ? find(node.hiddenChildren) : null;
if (hiddenChild) return hiddenChild;
}
return null;
};
return find(nodes);
}
export function createSidebarActionTarget(node: TreeNode): SidebarActionTarget {
// Virtual rows can be recycled while an async dialog is opening, so actions
// must never retain the mutable row node or its potentially large child tree.
const meta = node.meta && typeof node.meta === "object" ? Object.freeze({ ...node.meta }) : node.meta;
return Object.freeze({ ...node, children: undefined, hiddenChildren: undefined, meta });
}
export function createSidebarActionRequest<TPayload>(node: TreeNode, selectedNodeIds: readonly string[], payload: TPayload): SidebarActionRequest<TPayload> {
return Object.freeze({
target: createSidebarActionTarget(node),
selectedNodeIds: Object.freeze([...selectedNodeIds]),
payload,
});
}
@@ -0,0 +1,22 @@
import type { SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
export interface SidebarDangerDialogOption {
checked: boolean;
label: string;
hint: string;
onChange?: (checked: boolean) => void | Promise<void>;
}
export interface SidebarDangerDialogRequest {
target: SidebarActionTarget;
title: string;
message: string;
confirmLabel: string;
sql?: string;
details?: string;
detailsText?: string;
loading?: boolean;
closeOnConfirm?: boolean;
option?: SidebarDangerDialogOption;
confirm: () => void | Promise<void>;
}
@@ -0,0 +1,60 @@
export interface SidebarDataOpenRequest {
isCurrent: () => boolean;
registerCancel: (cancel: () => void | Promise<void>) => void;
}
type OpenDataRunner = (request: SidebarDataOpenRequest) => void | Promise<void>;
let generation = 0;
let activeGeneration = 0;
let activeCancel: (() => void | Promise<void>) | null = null;
function runCancellation(cancel: (() => void | Promise<void>) | null) {
if (!cancel) return;
void Promise.resolve(cancel()).catch(() => undefined);
}
function supersedeCurrentRequest(): number {
generation += 1;
runCancellation(activeCancel);
activeCancel = null;
activeGeneration = 0;
return generation;
}
function executeRequest(requestGeneration: number, runner: OpenDataRunner) {
if (requestGeneration !== generation) return;
const request: SidebarDataOpenRequest = {
isCurrent: () => requestGeneration === generation,
registerCancel: (cancel) => {
if (requestGeneration !== generation) {
runCancellation(cancel);
return;
}
activeGeneration = requestGeneration;
activeCancel = cancel;
},
};
let result: void | Promise<void>;
try {
result = runner(request);
} catch {
result = undefined;
}
void Promise.resolve(result)
.catch(() => undefined)
.finally(() => {
if (activeGeneration !== requestGeneration) return;
activeGeneration = 0;
activeCancel = null;
});
}
export function runSidebarDataOpenImmediately(runner: OpenDataRunner) {
const requestGeneration = supersedeCurrentRequest();
executeRequest(requestGeneration, runner);
}
export function cancelPendingSidebarDataOpen() {
supersedeCurrentRequest();
}
@@ -4,6 +4,6 @@ export function isSidebarDatabaseOpened(node: TreeNode, isTreeNodeChildrenLoaded
return (node.type === "database" || node.type === "mongo-db" || node.type === "vector-database") && !!node.connectionId && node.database != null && isTreeNodeChildrenLoaded(node.id);
}
export function canCloseSidebarDatabaseConnection(node: TreeNode, isTreeNodeChildrenLoaded: (nodeId: string) => boolean): boolean {
return node.type === "database" && !!node.connectionId && node.database != null && isTreeNodeChildrenLoaded(node.id);
export function canCloseSidebarDatabaseConnection(node: TreeNode, isTreeNodeChildrenLoaded: (nodeId: string) => boolean, isDatabaseUsedByOpenTab: (connectionId: string, database: string) => boolean = () => false): boolean {
return node.type === "database" && !!node.connectionId && node.database != null && (isTreeNodeChildrenLoaded(node.id) || isDatabaseUsedByOpenTab(node.connectionId, node.database));
}
@@ -4,8 +4,8 @@ function isErrorResult(result: QueryResult | undefined): boolean {
return result?.columns.length === 1 && result.columns[0] === "Error";
}
export function canActivateExistingDataTableTab(tab: QueryTab): boolean {
if (tab.isExecuting) return true;
export function canActivateExistingDataTableTab(tab: QueryTab, options: { activateExecuting?: boolean } = {}): boolean {
if (tab.isExecuting) return options.activateExecuting !== false;
if (isErrorResult(tab.result)) return false;
return !!tab.result || !!tab.results?.length;
}