fix(sidebar): restore node order after unpinning

This commit is contained in:
t8y2
2026-07-14 18:39:44 +08:00
parent ee85033068
commit 39aa506e44
4 changed files with 163 additions and 26 deletions
+99 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import { reactive } from "vue";
import { inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import { buildTreeNodesFromLayout } from "@/lib/sidebar/sidebarLayout";
import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
@@ -23,6 +24,39 @@ describe("sidebar pinned tree nodes", () => {
expect(tree[0].children?.[0].pinned).toBe(true);
});
it("restores the original sibling order after unpinning", () => {
const children: TreeNode[] = [
{ id: "conn:db:a", label: "A", type: "database" },
{ id: "conn:db:b", label: "B", type: "database" },
{ id: "conn:db:c", label: "C", type: "database" },
];
const tree: TreeNode[] = [{ id: "conn", label: "Connection", type: "connection", children }];
updatePinnedTreeNodeInPlace(tree, children[1], true);
expect(tree[0].children?.map((node) => node.id)).toEqual(["conn:db:b", "conn:db:a", "conn:db:c"]);
updatePinnedTreeNodeInPlace(tree, children[1], false);
expect(tree[0].children?.map((node) => node.id)).toEqual(["conn:db:a", "conn:db:b", "conn:db:c"]);
});
it("restores unpinned nodes while keeping other pinned nodes first", () => {
const children: TreeNode[] = [
{ id: "conn:db:a", label: "A", type: "database" },
{ id: "conn:db:b", label: "B", type: "database" },
{ id: "conn:db:c", label: "C", type: "database" },
];
const tree: TreeNode[] = [{ id: "conn", label: "Connection", type: "connection", children }];
updatePinnedTreeNodeInPlace(tree, children[1], true);
updatePinnedTreeNodeInPlace(tree, children[2], true);
updatePinnedTreeNodeInPlace(tree, children[1], false);
expect(tree[0].children?.map((node) => node.id)).toEqual(["conn:db:c", "conn:db:a", "conn:db:b"]);
updatePinnedTreeNodeInPlace(tree, children[2], false);
expect(tree[0].children?.map((node) => node.id)).toEqual(["conn:db:a", "conn:db:b", "conn:db:c"]);
});
it("reorders pinned root nodes in place", () => {
const tree: TreeNode[] = [
{ id: "group-a", label: "A", type: "connection-group" },
@@ -35,6 +69,48 @@ describe("sidebar pinned tree nodes", () => {
expect(tree[0].pinned).toBe(true);
});
it("restores the original root order after unpinning", () => {
const tree: TreeNode[] = [
{ id: "group-a", label: "A", type: "connection-group" },
{ id: "group-b", label: "B", type: "connection-group" },
{ id: "group-c", label: "C", type: "connection-group" },
];
const groupB = tree[1];
updatePinnedTreeNodeInPlace(tree, groupB, true);
updatePinnedTreeNodeInPlace(tree, groupB, false);
expect(tree.map((node) => node.id)).toEqual(["group-a", "group-b", "group-c"]);
});
it("restores order for Vue reactive tree nodes", () => {
const tree = reactive<TreeNode[]>([
{ id: "group-a", label: "A", type: "connection-group" },
{ id: "group-b", label: "B", type: "connection-group" },
{ id: "group-c", label: "C", type: "connection-group" },
]);
const groupB = tree[1];
updatePinnedTreeNodeInPlace(tree, groupB, true);
updatePinnedTreeNodeInPlace(tree, groupB, false);
expect(tree.map((node) => node.id)).toEqual(["group-a", "group-b", "group-c"]);
});
it("restores hidden children and keeps appended children in load order", () => {
const tableA: TreeNode = { id: "table-a", label: "A", type: "table", connectionId: "conn", database: "db" };
const tableB: TreeNode = { id: "table-b", label: "B", type: "table", connectionId: "conn", database: "db" };
const tableC: TreeNode = { id: "table-c", label: "C", type: "table", connectionId: "conn", database: "db" };
const database: TreeNode = { id: "db", label: "Database", type: "database", hiddenChildren: [tableA, tableB] };
const tree = [database];
syncPinnedTreeNodeStateInPlace(tree, new Set([treeNodePinKey(tableB)]));
database.hiddenChildren!.push(tableC);
syncPinnedTreeNodeStateInPlace(tree, new Set());
expect(database.hiddenChildren?.map((node) => node.id)).toEqual(["table-a", "table-b", "table-c"]);
});
it("scopes duplicate node ids by database when pinning", () => {
const databaseA: TreeNode = {
id: "conn:a",
@@ -97,5 +173,27 @@ describe("sidebar pinned tree nodes", () => {
expect(nodes.map((node) => node.id)).toEqual(["group-b", "group-a"]);
expect(nodes[0].pinned).toBe(true);
syncPinnedTreeNodeStateInPlace(nodes, new Set());
expect(nodes.map((node) => node.id)).toEqual(["group-a", "group-b"]);
});
it("retains the latest layout order through rebuilt node clones", () => {
const layout: SidebarLayout = {
groups: [
{ id: "group-a", name: "A", collapsed: false },
{ id: "group-b", name: "B", collapsed: false },
],
order: [
{ type: "group", id: "group-a", children: [] },
{ type: "group", id: "group-b", children: [] },
],
};
const built = buildTreeNodesFromLayout(layout, [], new Set(["group-b"]));
const rebuilt = built.map((node) => inheritNaturalTreeNodeOrder(node, { ...node }));
syncPinnedTreeNodeStateInPlace(rebuilt, new Set());
expect(rebuilt.map((node) => node.id)).toEqual(["group-a", "group-b"]);
});
});
+56 -8
View File
@@ -2,6 +2,25 @@ import type { TreeNode } from "@/types/database";
export type PinnedTreeNodeUpdateScope = "missing" | "root" | "siblings";
const NATURAL_TREE_NODE_ORDER = Symbol("naturalTreeNodeOrder");
type OrderedTreeNode = TreeNode & { [NATURAL_TREE_NODE_ORDER]?: number };
function naturalTreeNodeOrder(node: TreeNode): number | undefined {
return (node as OrderedTreeNode)[NATURAL_TREE_NODE_ORDER];
}
function setNaturalTreeNodeOrder(node: TreeNode, order: number): void {
// An enumerable symbol survives Vue proxies and object spreads while staying
// out of persisted JSON, so rebuilt nodes retain their layout position.
(node as OrderedTreeNode)[NATURAL_TREE_NODE_ORDER] = order;
}
export function inheritNaturalTreeNodeOrder(source: TreeNode, target: TreeNode): TreeNode {
const order = naturalTreeNodeOrder(source);
if (order !== undefined) setNaturalTreeNodeOrder(target, order);
return target;
}
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];
@@ -41,6 +60,36 @@ export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean):
return [...pinned, ...unpinned];
}
function rememberNaturalTreeNodeOrder(nodes: readonly TreeNode[]): void {
let nextOrder = 0;
for (const node of nodes) {
const order = naturalTreeNodeOrder(node);
if (order === undefined) continue;
nextOrder = Math.max(nextOrder, order + 1);
}
for (const node of nodes) {
if (naturalTreeNodeOrder(node) !== undefined) continue;
// Preserve the backend/layout order separately from the pinned presentation
// order so an unpinned node can return to its original sibling position.
setNaturalTreeNodeOrder(node, nextOrder++);
}
}
export function orderPinnedTreeNodes(nodes: TreeNode[]): TreeNode[] {
rememberNaturalTreeNodeOrder(nodes);
const pinned: TreeNode[] = [];
const unpinned: TreeNode[] = [];
for (const node of nodes) {
if (node.pinned) pinned.push(node);
else unpinned.push(node);
}
unpinned.sort((left, right) => naturalTreeNodeOrder(left)! - naturalTreeNodeOrder(right)!);
return [...pinned, ...unpinned];
}
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) {
@@ -59,7 +108,7 @@ export function updatePinnedTreeNodeInPlace(nodes: TreeNode[], target: TreeNode,
location.node.pinned = pinned;
const siblings = location.parent?.children ?? nodes;
const ordered = orderPinnedFirst(siblings, (node) => !!node.pinned);
const ordered = orderPinnedTreeNodes(siblings);
if (location.parent) {
location.parent.children = ordered;
@@ -78,16 +127,15 @@ function clonePinnedTreeNode(node: TreeNode, pinnedIds: Set<string>, clones: Wea
pinned: pinnedIds.has(treeNodePinKey(node)) || pinnedIds.has(node.id),
};
clones.set(node, clone);
inheritNaturalTreeNodeOrder(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) => clonePinnedTreeNode(node, pinnedIds, clones)),
(node) => !!node.pinned,
);
rememberNaturalTreeNodeOrder(nodes);
return orderPinnedTreeNodes(nodes.map((node) => clonePinnedTreeNode(node, pinnedIds, clones)));
}
export function applyPinnedTreeNodeState(nodes: TreeNode[], pinnedIds: Set<string>): TreeNode[] {
@@ -101,14 +149,14 @@ function syncPinnedTreeNodeStateInPlaceInternal(nodes: TreeNode[], pinnedIds: Se
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);
node.children = orderPinnedTreeNodes(node.children);
}
if (node.hiddenChildren) {
syncPinnedTreeNodeStateInPlaceInternal(node.hiddenChildren, pinnedIds, visited);
node.hiddenChildren = orderPinnedFirst(node.hiddenChildren, (child) => !!child.pinned);
node.hiddenChildren = orderPinnedTreeNodes(node.hiddenChildren);
}
}
nodes.splice(0, nodes.length, ...orderPinnedFirst(nodes, (node) => !!node.pinned));
nodes.splice(0, nodes.length, ...orderPinnedTreeNodes(nodes));
}
export function syncPinnedTreeNodeStateInPlace(nodes: TreeNode[], pinnedIds: Set<string>): void {
+3 -12
View File
@@ -1,5 +1,6 @@
import type { ConnectionConfig, ConnectionGroup, SidebarLayout, SidebarOrderEntry, TreeNode } from "@/types/database";
import { uuid } from "@/lib/common/utils";
import { orderPinnedTreeNodes } from "@/lib/app/pinnedItems";
export function emptyLayout(): SidebarLayout {
return { groups: [], order: [] };
@@ -89,16 +90,6 @@ function makeConnectionNode(config: ConnectionConfig, pinned: boolean): TreeNode
};
}
function orderPinnedFirst(nodes: TreeNode[]): TreeNode[] {
const pinned: TreeNode[] = [];
const unpinned: TreeNode[] = [];
for (const node of nodes) {
if (node.pinned) pinned.push(node);
else unpinned.push(node);
}
return [...pinned, ...unpinned];
}
export function buildTreeNodesFromLayout(layout: SidebarLayout, connections: ConnectionConfig[], pinnedIds: Set<string>): TreeNode[] {
const configMap = new Map(connections.map((connection) => [connection.id, connection]));
const groupMap = new Map(layout.groups.map((group) => [group.id, group]));
@@ -120,13 +111,13 @@ export function buildTreeNodesFromLayout(layout: SidebarLayout, connections: Con
type: "connection-group",
pinned: pinnedIds.has(group.id),
isExpanded: !group.collapsed,
children: orderPinnedFirst(build(entryChildren(entry))),
children: orderPinnedTreeNodes(build(entryChildren(entry))),
});
}
return nodes;
};
return orderPinnedFirst(build(layout.order));
return orderPinnedTreeNodes(build(layout.order));
}
export function findConnectionLocation(layout: SidebarLayout, connectionId: string): { entries: SidebarOrderEntry[]; entryIndex: number; groupId?: string } | null {
+5 -5
View File
@@ -2,7 +2,7 @@ import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref, computed, watch, markRaw } from "vue";
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database";
import { applyPinnedTreeNodeState, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey } from "@/lib/app/pinnedItems";
import { applyPinnedTreeNodeState, inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey } from "@/lib/app/pinnedItems";
import {
reconcileLayout,
buildTreeNodesFromLayout,
@@ -4767,18 +4767,18 @@ export const useConnectionStore = defineStore("connection", () => {
nodes.map((node) => {
const existing = existingNodesMap.get(node.id);
if (node.type === "connection-group") {
return { ...node, children: mergeState(node.children || []) };
return inheritNaturalTreeNodeOrder(node, { ...node, children: mergeState(node.children || []) });
}
if (existing && node.type === "connection") {
return {
return inheritNaturalTreeNodeOrder(node, {
...existing,
label: node.label,
pinned: node.pinned,
children: withSavedSqlRoot(node.connectionId!, existing.children || [], existing),
};
});
}
if (node.type === "connection" && node.connectionId) {
return { ...node, children: withSavedSqlRoot(node.connectionId, node.children || []) };
return inheritNaturalTreeNodeOrder(node, { ...node, children: withSavedSqlRoot(node.connectionId, node.children || []) });
}
return node;
});