From 39aa506e44541cbd5a170e49a7482c86d0263162 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 14 Jul 2026 18:39:44 +0800 Subject: [PATCH] fix(sidebar): restore node order after unpinning --- .../src/lib/__tests__/app/pinnedItems.spec.ts | 100 +++++++++++++++++- apps/desktop/src/lib/app/pinnedItems.ts | 64 +++++++++-- apps/desktop/src/lib/sidebar/sidebarLayout.ts | 15 +-- apps/desktop/src/stores/connectionStore.ts | 10 +- 4 files changed, 163 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/app/pinnedItems.spec.ts b/apps/desktop/src/lib/__tests__/app/pinnedItems.spec.ts index 828496eaed..44cc42b00c 100644 --- a/apps/desktop/src/lib/__tests__/app/pinnedItems.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/pinnedItems.spec.ts @@ -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([ + { 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"]); }); }); diff --git a/apps/desktop/src/lib/app/pinnedItems.ts b/apps/desktop/src/lib/app/pinnedItems.ts index 0c419cc87e..ffa5706b32 100644 --- a/apps/desktop/src/lib/app/pinnedItems.ts +++ b/apps/desktop/src/lib/app/pinnedItems.ts @@ -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(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, 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, clones: WeakMap): 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): 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): void { diff --git a/apps/desktop/src/lib/sidebar/sidebarLayout.ts b/apps/desktop/src/lib/sidebar/sidebarLayout.ts index ecb15758d3..fcf48a8004 100644 --- a/apps/desktop/src/lib/sidebar/sidebarLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarLayout.ts @@ -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): 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 { diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 518b1addf8..97ffa59847 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -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; });