mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 08:13:34 +00:00
fix(sidebar): restore node order after unpinning
This commit is contained in:
+99
-1
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user