feat(sidebar): persist custom pinned item ordering

This commit is contained in:
zhangsan authored and GitHub committed 2026-07-26 10:30:09 +08:00
1 parent b635ea1466
commit 406b7acdbb
16 files changed
+1019 -82

No files matched your search

+121 -1
View File
@@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest";
import { reactive } from "vue";
import { inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import {
inheritNaturalTreeNodeOrder,
migrateLegacyPinnedTreeNodeIds,
migrateLegacyPinnedTreeNodeOrder,
removePinnedTreeNodesFromOrder,
reorderPinnedTreeNodeOrder,
replacePinnedTreeNodeInOrder,
syncPinnedTreeNodeStateInPlace,
treeNodePinKey,
updatePinnedTreeNodeInPlace,
} from "@/lib/app/pinnedItems";
import { buildTreeNodesFromLayout } from "@/lib/sidebar/sidebarLayout";
import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
@@ -146,6 +156,31 @@ describe("sidebar pinned tree nodes", () => {
expect(tableB.pinned).toBe(false);
});
it("removes a deleted table pin so a recreated or renamed table does not inherit it", () => {
const deletedTable: TreeNode = { id: "conn:db:public:users", label: "users", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "users" };
const anotherTable: TreeNode = { id: "conn:db:public:orders", label: "orders", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "orders" };
const pinOrder = [treeNodePinKey(deletedTable), treeNodePinKey(anotherTable)];
const remainingOrder = removePinnedTreeNodesFromOrder(pinOrder, [{ ...deletedTable, id: "object-browser-row-id" }]);
const recreatedTable: TreeNode = { ...deletedTable };
const renamedTable: TreeNode = { ...anotherTable, id: deletedTable.id, label: deletedTable.label, tableName: deletedTable.tableName };
expect(remainingOrder).toEqual([treeNodePinKey(anotherTable)]);
expect(remainingOrder).not.toContain(treeNodePinKey(recreatedTable));
expect(remainingOrder).not.toContain(treeNodePinKey(renamedTable));
});
it("moves a renamed pin to the new identity without retaining the old name", () => {
const users: TreeNode = { id: "conn:db:public:users", label: "users", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "users" };
const orders: TreeNode = { id: "conn:db:public:orders", label: "orders", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "orders" };
const accounts: TreeNode = { ...users, id: "conn:db:public:accounts", label: "accounts", tableName: "accounts" };
const renamedOrder = replacePinnedTreeNodeInOrder([treeNodePinKey(users), treeNodePinKey(orders)], users, accounts);
expect(renamedOrder).toEqual([treeNodePinKey(accounts), treeNodePinKey(orders)]);
expect(renamedOrder).not.toContain(treeNodePinKey(users));
});
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" };
@@ -156,6 +191,91 @@ describe("sidebar pinned tree nodes", () => {
expect(migrated.ids).toEqual(new Set([treeNodePinKey(tableA)]));
});
it("uses the persisted order for pinned siblings", () => {
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
const nodeB: TreeNode = { id: "db-b", label: "B", type: "database", connectionId: "conn", database: "b" };
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
const nodes = [nodeA, nodeB, nodeC];
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA)];
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order);
expect(nodes.map((node) => node.id)).toEqual(["db-c", "db-a", "db-b"]);
});
it("keeps the fixed default database before manually pinned siblings", () => {
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
const defaultNode: TreeNode = { id: "db-default", label: "Default", type: "database", connectionId: "conn", database: "default" };
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
const nodes = [nodeA, defaultNode, nodeC];
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA)];
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order, (node) => node.id === defaultNode.id);
expect(nodes.map((node) => node.id)).toEqual(["db-default", "db-c", "db-a"]);
expect(defaultNode.pinned).toBe(false);
});
it("keeps the fixed default database first even when it is also manually pinned", () => {
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
const defaultNode: TreeNode = { id: "db-default", label: "Default", type: "database", connectionId: "conn", database: "default" };
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
const nodes = [nodeA, defaultNode, nodeC];
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA), treeNodePinKey(defaultNode)];
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order, (node) => node.id === defaultNode.id);
expect(nodes.map((node) => node.id)).toEqual(["db-default", "db-c", "db-a"]);
expect(defaultNode.pinned).toBe(true);
});
it("reorders pinned keys before and after a sibling without dropping unrelated keys", () => {
const initial = ["scope:a", "other:x", "scope:b", "other:y", "scope:c"];
const before = reorderPinnedTreeNodeOrder(initial, "scope:c", "scope:a", "before");
expect(before).toEqual(["scope:c", "scope:a", "other:x", "scope:b", "other:y"]);
const after = reorderPinnedTreeNodeOrder(before, "scope:c", "scope:b", "after");
expect(after).toEqual(["scope:a", "other:x", "scope:b", "scope:c", "other:y"]);
expect(after.filter((key) => key.startsWith("other:"))).toEqual(["other:x", "other:y"]);
});
it("places a newly appended pin last within its sibling pin section", () => {
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
const nodeB: TreeNode = { id: "db-b", label: "B", type: "database", connectionId: "conn", database: "b" };
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
const nodes = [nodeA, nodeB, nodeC];
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA), treeNodePinKey(nodeB)];
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order);
expect(nodes.map((node) => node.id)).toEqual(["db-c", "db-a", "db-b"]);
});
it("migrates a legacy pin key in place without changing persisted order", () => {
const node: TreeNode = { id: "legacy-table", label: "users", type: "table", connectionId: "conn", database: "db" };
const migrated = migrateLegacyPinnedTreeNodeOrder([node], ["before", node.id, "after"]);
expect(migrated.changed).toBe(true);
expect(migrated.order).toEqual(["before", treeNodePinKey(node), "after"]);
});
it("removes explicitly supplied legacy keys for an unloaded node", () => {
const node: TreeNode = { id: "object-browser:app:events", label: "events", type: "table", connectionId: "conn", database: "app", schema: "public" };
const legacyKey = "conn:app:public:__tables:public:events";
expect(removePinnedTreeNodesFromOrder([legacyKey, "unrelated"], [node], undefined, [legacyKey])).toEqual(["unrelated"]);
});
it("replaces an explicitly supplied legacy key without losing its position", () => {
const oldNode: TreeNode = { id: "object-browser:app:events", label: "events", type: "table", connectionId: "conn", database: "app", schema: "public" };
const newNode: TreeNode = { id: "conn:app:public:__tables:public:renamed", label: "renamed", type: "table", connectionId: "conn", database: "app", schema: "public" };
const legacyKey = "conn:app:public:__tables:public:events";
expect(replacePinnedTreeNodeInOrder(["before", legacyKey, "after"], oldNode, newNode, undefined, [legacyKey])).toEqual(["before", treeNodePinKey(newNode), "after"]);
});
it("applies pinned state to connection groups when rebuilding from layout", () => {
const layout: SidebarLayout = {
groups: [
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildObjectBrowserRows } from "@/lib/table/objectBrowserRows";
import { orderItemsByPinnedTreeNodeOrder, removePinnedTreeNodesFromOrder, treeNodePinIdentity, treeNodePinKey } from "@/lib/app/pinnedItems";
import { buildObjectBrowserRows, canonicalizeObjectBrowserPinnedTreeNodeIdentity, objectBrowserRowLegacyPinnedTreeNodeIds, objectBrowserRowMatchesPinnedTreeNode, sortObjectBrowserRows, type ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
import type { TreeNode } from "@/types/database";
describe("buildObjectBrowserRows", () => {
it("preserves a resolved SQLite attached-database alias on every row", () => {
@@ -24,3 +26,96 @@ describe("buildObjectBrowserRows", () => {
expect(rows[0]?.schema).toBeUndefined();
});
});
describe("Object Browser pinned ordering", () => {
const context = { connectionId: "conn", database: "app", schema: "public" };
function tableNode(name: string, schema = "public"): TreeNode {
return {
id: `conn:app:${schema}:tables:${name}`,
label: name,
type: "table",
connectionId: "conn",
database: "app",
schema,
};
}
function orderRows(rows: ObjectBrowserRow[], pinnedNodes: TreeNode[], extraPinnedKeys: string[] = []): ObjectBrowserRow[] {
const pinnedOrder = [...extraPinnedKeys, ...pinnedNodes.map(treeNodePinKey)];
return orderItemsByPinnedTreeNodeOrder(rows, pinnedOrder, (row, identity) => objectBrowserRowMatchesPinnedTreeNode(row, identity, context));
}
it("uses the sidebar's persisted custom pin order before the selected name sort", () => {
const rows = buildObjectBrowserRows({
objects: ["a1", "a2", "a3", "a4", "a5", "aaa"].map((name) => ({ name, object_type: "TABLE", schema: "public" })),
database: "app",
fallbackSchema: "public",
rowSchema: "public",
});
const sorted = sortObjectBrowserRows(rows, "name", "asc");
const pinnedNodes = ["aaa", "a5", "a4", "a3", "a2", "a1"].map((name) => tableNode(name));
const ordered = orderRows(sorted, pinnedNodes, [treeNodePinKey({ ...tableNode("other"), connectionId: "other-connection" })]);
expect(ordered.map((row) => row.name)).toEqual(["aaa", "a5", "a4", "a3", "a2", "a1"]);
});
it("keeps unpinned rows in the Object Browser's selected column order", () => {
const rows: ObjectBrowserRow[] = [
{ id: "a1", name: "a1", displayName: "a1", schema: "public", type: "TABLE", totalBytes: 30 },
{ id: "a2", name: "a2", displayName: "a2", schema: "public", type: "TABLE", totalBytes: 40 },
{ id: "a3", name: "a3", displayName: "a3", schema: "public", type: "TABLE", totalBytes: 10 },
{ id: "a4", name: "a4", displayName: "a4", schema: "public", type: "TABLE", totalBytes: 20 },
];
const sorted = sortObjectBrowserRows(rows, "totalBytes", "desc");
const ordered = orderRows(sorted, [tableNode("a3"), tableNode("a1")]);
expect(ordered.map((row) => row.name)).toEqual(["a3", "a1", "a2", "a4"]);
});
it("matches a sidebar database child when Object Browser reports the database name as its schema", () => {
const row: ObjectBrowserRow = { id: "a1", name: "a1", displayName: "a1", schema: "app", type: "TABLE" };
const sidebarNode = tableNode("a1", "");
expect(objectBrowserRowMatchesPinnedTreeNode(row, treeNodePinIdentity(sidebarNode), { connectionId: "conn", database: "app", schema: "app" })).toBe(true);
});
it("clears the database-as-schema alias when an Object Browser object is deleted", () => {
const sidebarNode = tableNode("events", "");
const objectBrowserNode: TreeNode = { ...sidebarNode, id: "object-browser:app:events", schema: "app" };
const canonicalize = canonicalizeObjectBrowserPinnedTreeNodeIdentity({ connectionId: "conn", database: "app" });
const remainingOrder = removePinnedTreeNodesFromOrder([treeNodePinKey(sidebarNode)], [objectBrowserNode], canonicalize);
expect(remainingOrder).toEqual([]);
});
it("reconstructs legacy sidebar IDs for an unloaded Object Browser row", () => {
const row: ObjectBrowserRow = { id: "object-browser:events:0", name: "events", displayName: "events", schema: "public", type: "TABLE" };
const ids = objectBrowserRowLegacyPinnedTreeNodeIds(row, { connectionId: "conn", database: "app", schema: "public", sidebarParentId: "conn:app:public" });
expect(ids).toEqual(expect.arrayContaining(["conn:app:public:events", "conn:app:public:__tables:public:events"]));
});
it("does not confuse same-name objects across schemas or routine overloads", () => {
const tableRow: ObjectBrowserRow = { id: "orders", name: "orders", displayName: "orders", schema: "public", type: "TABLE" };
expect(objectBrowserRowMatchesPinnedTreeNode(tableRow, treeNodePinIdentity(tableNode("orders", "archive")), context)).toBe(false);
expect(objectBrowserRowMatchesPinnedTreeNode(tableRow, treeNodePinIdentity(tableNode("orders", "public")), context)).toBe(true);
const routineRow: ObjectBrowserRow = { id: "run-int", name: "run", displayName: "run(integer)", schema: "public", type: "FUNCTION", signature: "integer" };
const routineNode: TreeNode = {
id: "conn:app:public:functions:run:text",
label: "run(text)",
objectName: "run",
signature: "text",
type: "function",
connectionId: "conn",
database: "app",
schema: "public",
};
expect(objectBrowserRowMatchesPinnedTreeNode(routineRow, treeNodePinIdentity(routineNode), context)).toBe(false);
expect(objectBrowserRowMatchesPinnedTreeNode(routineRow, treeNodePinIdentity({ ...routineNode, id: "conn:app:public:functions:run:integer", label: "run(integer)", signature: "integer" }), context)).toBe(true);
});
});
+229 -32
View File
@@ -1,6 +1,19 @@
import type { TreeNode } from "@/types/database";
export type PinnedTreeNodeUpdateScope = "missing" | "root" | "siblings";
export type PinnedTreeNodeDropPosition = "before" | "after";
export type FixedTreeNodePriority = (node: TreeNode) => boolean;
export type PinnedTreeNodeIdentityCanonicalizer = (identity: PinnedTreeNodeIdentity) => PinnedTreeNodeIdentity;
export type PinnedTreeNodeIdentity = {
connectionId: string;
database: string;
schema: string;
catalog: string;
type: TreeNode["type"];
name: string;
signature: string;
id: string;
};
const NATURAL_TREE_NODE_ORDER = Symbol("naturalTreeNodeOrder");
type OrderedTreeNode = TreeNode & { [NATURAL_TREE_NODE_ORDER]?: number };
@@ -21,23 +34,129 @@ export function inheritNaturalTreeNodeOrder(source: TreeNode, target: TreeNode):
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];
return `${node.connectionId}:pin:v2:${encodeURIComponent(JSON.stringify(identity))}`;
export function treeNodePinIdentity(node: TreeNode): PinnedTreeNodeIdentity {
return {
connectionId: node.connectionId || "",
database: node.database || "",
schema: node.schema || "",
catalog: node.catalog || "",
type: node.type,
name: node.objectName || node.tableName || node.label,
signature: node.signature || "",
id: node.id,
};
}
export function migrateLegacyPinnedTreeNodeIds(nodes: readonly TreeNode[], pinnedIds: Set<string>): { ids: Set<string>; changed: boolean } {
const next = new Set(pinnedIds);
let changed = false;
export function treeNodePinKey(node: TreeNode): string {
if (!node.connectionId) return node.id;
const identity = treeNodePinIdentity(node);
const payload = [identity.database, identity.schema, identity.catalog, identity.type, identity.name, identity.signature, identity.id];
return `${identity.connectionId}:pin:v2:${encodeURIComponent(JSON.stringify(payload))}`;
}
export function parseTreeNodePinKey(key: string): PinnedTreeNodeIdentity | null {
const marker = ":pin:v2:";
const markerIndex = key.indexOf(marker);
if (markerIndex <= 0) return null;
try {
const payload = JSON.parse(decodeURIComponent(key.slice(markerIndex + marker.length)));
if (!Array.isArray(payload) || payload.length !== 7 || payload.some((value) => typeof value !== "string")) return null;
const [database, schema, catalog, type, name, signature, id] = payload;
return { connectionId: key.slice(0, markerIndex), database, schema, catalog, type: type as TreeNode["type"], name, signature, id };
} catch {
return null;
}
}
export function normalizePinnedTreeNodeOrder(ids: readonly string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
seen.add(id);
normalized.push(id);
}
return normalized;
}
export function pinnedTreeNodeIdentityMatches(left: PinnedTreeNodeIdentity, right: PinnedTreeNodeIdentity, canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity): boolean {
const canonicalLeft = canonicalize(left);
const canonicalRight = canonicalize(right);
return (
canonicalLeft.connectionId === canonicalRight.connectionId &&
canonicalLeft.database === canonicalRight.database &&
canonicalLeft.schema === canonicalRight.schema &&
canonicalLeft.catalog === canonicalRight.catalog &&
canonicalLeft.type === canonicalRight.type &&
canonicalLeft.name === canonicalRight.name &&
canonicalLeft.signature === canonicalRight.signature
);
}
function pinnedTreeNodeOrderKeyMatchesNode(key: string, node: TreeNode, canonicalize: PinnedTreeNodeIdentityCanonicalizer, legacyKeys: ReadonlySet<string> = new Set()): boolean {
if (key === treeNodePinKey(node) || key === node.id || legacyKeys.has(key)) return true;
const identity = parseTreeNodePinKey(key);
return !!identity && pinnedTreeNodeIdentityMatches(identity, treeNodePinIdentity(node), canonicalize);
}
export function removePinnedTreeNodesFromOrder(order: readonly string[], nodes: readonly TreeNode[], canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): string[] {
const removedKeys = new Set(legacyKeys);
const removedIdentities: PinnedTreeNodeIdentity[] = [];
const visited = new WeakSet<TreeNode>();
const visit = (items: readonly TreeNode[]) => {
for (const node of items) {
if (visited.has(node)) continue;
visited.add(node);
// Remove the scoped key and any remaining legacy key. Keeping either lets
// an object recreated with the same identity inherit a deleted pin.
removedKeys.add(treeNodePinKey(node));
removedKeys.add(node.id);
removedIdentities.push(treeNodePinIdentity(node));
if (node.children) visit(node.children);
if (node.hiddenChildren) visit(node.hiddenChildren);
}
};
visit(nodes);
return normalizePinnedTreeNodeOrder(order).filter((key) => {
if (removedKeys.has(key)) return false;
const identity = parseTreeNodePinKey(key);
return !identity || !removedIdentities.some((removed) => pinnedTreeNodeIdentityMatches(identity, removed, canonicalize));
});
}
/** Replaces a pinned object identity in place after a successful rename. */
export function replacePinnedTreeNodeInOrder(order: readonly string[], oldNode: TreeNode, newNode: TreeNode, canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): string[] {
const normalized = normalizePinnedTreeNodeOrder(order);
const legacyKeySet = new Set(legacyKeys);
const oldIndex = normalized.findIndex((key) => pinnedTreeNodeOrderKeyMatchesNode(key, oldNode, canonicalize, legacyKeySet));
if (oldIndex < 0) return normalized;
const shouldRemove = (key: string) => pinnedTreeNodeOrderKeyMatchesNode(key, oldNode, canonicalize, legacyKeySet) || pinnedTreeNodeOrderKeyMatchesNode(key, newNode, canonicalize);
const replacementIndex = normalized.slice(0, oldIndex).filter((key) => !shouldRemove(key)).length;
const next = normalized.filter((key) => !shouldRemove(key));
next.splice(replacementIndex, 0, treeNodePinKey(newNode));
return normalizePinnedTreeNodeOrder(next);
}
export function migrateLegacyPinnedTreeNodeOrder(nodes: readonly TreeNode[], pinnedOrder: readonly string[]): { order: string[]; ids: Set<string>; changed: boolean } {
const next = normalizePinnedTreeNodeOrder(pinnedOrder);
let changed = next.length !== pinnedOrder.length;
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);
const legacyIndex = pinKey === node.id ? -1 : next.indexOf(node.id);
if (legacyIndex >= 0) {
const scopedIndex = next.indexOf(pinKey);
if (scopedIndex < 0) {
// Replace in place so upgrading a legacy key never changes the user's
// persisted order. A colliding legacy id is claimed by the first
// matching loaded node, matching the previous migration behavior.
next[legacyIndex] = pinKey;
} else {
next.splice(legacyIndex, 1);
}
changed = true;
}
if (node.children) visit(node.children);
@@ -45,7 +164,23 @@ export function migrateLegacyPinnedTreeNodeIds(nodes: readonly TreeNode[], pinne
}
};
visit(nodes);
return { ids: next, changed };
const order = normalizePinnedTreeNodeOrder(next);
return { order, ids: new Set(order), changed: changed || order.length !== next.length };
}
export function migrateLegacyPinnedTreeNodeIds(nodes: readonly TreeNode[], pinnedIds: Set<string>): { ids: Set<string>; changed: boolean } {
const migrated = migrateLegacyPinnedTreeNodeOrder(nodes, [...pinnedIds]);
return { ids: migrated.ids, changed: migrated.changed };
}
export function reorderPinnedTreeNodeOrder(order: readonly string[], draggedKey: string, targetKey: string, position: PinnedTreeNodeDropPosition): string[] {
const normalized = normalizePinnedTreeNodeOrder(order);
if (draggedKey === targetKey || !normalized.includes(draggedKey) || !normalized.includes(targetKey)) return normalized;
const next = normalized.filter((key) => key !== draggedKey);
const targetIndex = next.indexOf(targetKey);
next.splice(position === "before" ? targetIndex : targetIndex + 1, 0, draggedKey);
return next;
}
export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
@@ -60,6 +195,51 @@ export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean):
return [...pinned, ...unpinned];
}
function loadedTreeNodePinIdentities(nodes: readonly TreeNode[]): Map<string, PinnedTreeNodeIdentity> {
const identities = new Map<string, PinnedTreeNodeIdentity>();
const visited = new WeakSet<TreeNode>();
const visit = (items: readonly TreeNode[]) => {
for (const node of items) {
if (visited.has(node)) continue;
visited.add(node);
const identity = treeNodePinIdentity(node);
identities.set(treeNodePinKey(node), identity);
if (!identities.has(node.id)) identities.set(node.id, identity);
if (node.children) visit(node.children);
if (node.hiddenChildren) visit(node.hiddenChildren);
}
};
visit(nodes);
return identities;
}
export function orderItemsByPinnedTreeNodeOrder<T>(items: readonly T[], pinnedOrder: readonly string[], matches: (item: T, identity: PinnedTreeNodeIdentity) => boolean, loadedNodes: readonly TreeNode[] = []): T[] {
const normalizedOrder = normalizePinnedTreeNodeOrder(pinnedOrder);
if (!items.length || !normalizedOrder.length) return [...items];
let loadedIdentities: Map<string, PinnedTreeNodeIdentity> | undefined;
const ranks: Array<number | undefined> = Array.from({ length: items.length });
normalizedOrder.forEach((key, rank) => {
const parsedIdentity = parseTreeNodePinKey(key);
if (!parsedIdentity && !loadedIdentities) loadedIdentities = loadedTreeNodePinIdentities(loadedNodes);
const identity = parsedIdentity ?? loadedIdentities?.get(key);
if (!identity) return;
items.forEach((item, index) => {
if (ranks[index] === undefined && matches(item, identity)) ranks[index] = rank;
});
});
const ranked: Array<{ item: T; index: number; rank: number }> = [];
const unpinned: T[] = [];
items.forEach((item, index) => {
const rank = ranks[index];
if (rank === undefined) unpinned.push(item);
else ranked.push({ item, index, rank });
});
ranked.sort((left, right) => left.rank - right.rank || left.index - right.index);
return [...ranked.map(({ item }) => item), ...unpinned];
}
function rememberNaturalTreeNodeOrder(nodes: readonly TreeNode[]): void {
let nextOrder = 0;
for (const node of nodes) {
@@ -76,18 +256,31 @@ function rememberNaturalTreeNodeOrder(nodes: readonly TreeNode[]): void {
}
}
export function orderPinnedTreeNodes(nodes: TreeNode[]): TreeNode[] {
export function orderPinnedTreeNodes(nodes: TreeNode[], pinnedOrder: readonly string[] = [], isFixedPriority: FixedTreeNodePriority = () => false): TreeNode[] {
rememberNaturalTreeNodeOrder(nodes);
const fixed: TreeNode[] = [];
const pinned: TreeNode[] = [];
const unpinned: TreeNode[] = [];
const orderByKey = new Map(normalizePinnedTreeNodeOrder(pinnedOrder).map((key, index) => [key, index] as const));
for (const node of nodes) {
if (node.pinned) pinned.push(node);
if (isFixedPriority(node)) fixed.push(node);
else if (node.pinned) pinned.push(node);
else unpinned.push(node);
}
unpinned.sort((left, right) => naturalTreeNodeOrder(left)! - naturalTreeNodeOrder(right)!);
return [...pinned, ...unpinned];
const naturalOrder = (left: TreeNode, right: TreeNode) => naturalTreeNodeOrder(left)! - naturalTreeNodeOrder(right)!;
fixed.sort(naturalOrder);
pinned.sort((left, right) => {
const leftRank = orderByKey.get(treeNodePinKey(left));
const rightRank = orderByKey.get(treeNodePinKey(right));
if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank;
if (leftRank !== undefined) return -1;
if (rightRank !== undefined) return 1;
return naturalOrder(left, right);
});
unpinned.sort(naturalOrder);
return [...fixed, ...pinned, ...unpinned];
}
function findTreeNodeLocation(nodes: TreeNode[], target: TreeNode, parent: TreeNode | null = null): { node: TreeNode; parent: TreeNode | null } | null {
@@ -119,7 +312,7 @@ export function updatePinnedTreeNodeInPlace(nodes: TreeNode[], target: TreeNode,
return "root";
}
function clonePinnedTreeNode(node: TreeNode, pinnedIds: Set<string>, clones: WeakMap<TreeNode, TreeNode>): TreeNode {
function clonePinnedTreeNode(node: TreeNode, pinnedIds: Set<string>, pinnedOrder: readonly string[], isFixedPriority: FixedTreeNodePriority, clones: WeakMap<TreeNode, TreeNode>): TreeNode {
const existing = clones.get(node);
if (existing) return existing;
const clone: TreeNode = {
@@ -128,37 +321,41 @@ function clonePinnedTreeNode(node: TreeNode, pinnedIds: Set<string>, clones: Wea
};
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);
if (node.children) clone.children = applyPinnedTreeNodeStateInternal(node.children, pinnedIds, pinnedOrder, isFixedPriority, clones);
if (node.hiddenChildren) clone.hiddenChildren = applyPinnedTreeNodeStateInternal(node.hiddenChildren, pinnedIds, pinnedOrder, isFixedPriority, clones);
return clone;
}
function applyPinnedTreeNodeStateInternal(nodes: TreeNode[], pinnedIds: Set<string>, clones: WeakMap<TreeNode, TreeNode>): TreeNode[] {
function applyPinnedTreeNodeStateInternal(nodes: TreeNode[], pinnedIds: Set<string>, pinnedOrder: readonly string[], isFixedPriority: FixedTreeNodePriority, clones: WeakMap<TreeNode, TreeNode>): TreeNode[] {
rememberNaturalTreeNodeOrder(nodes);
return orderPinnedTreeNodes(nodes.map((node) => clonePinnedTreeNode(node, pinnedIds, clones)));
return orderPinnedTreeNodes(
nodes.map((node) => clonePinnedTreeNode(node, pinnedIds, pinnedOrder, isFixedPriority, clones)),
pinnedOrder,
isFixedPriority,
);
}
export function applyPinnedTreeNodeState(nodes: TreeNode[], pinnedIds: Set<string>): TreeNode[] {
return applyPinnedTreeNodeStateInternal(nodes, pinnedIds, new WeakMap());
export function applyPinnedTreeNodeState(nodes: TreeNode[], pinnedIds: Set<string>, pinnedOrder: readonly string[] = [...pinnedIds], isFixedPriority: FixedTreeNodePriority = () => false): TreeNode[] {
return applyPinnedTreeNodeStateInternal(nodes, pinnedIds, pinnedOrder, isFixedPriority, new WeakMap());
}
function syncPinnedTreeNodeStateInPlaceInternal(nodes: TreeNode[], pinnedIds: Set<string>, visited: WeakSet<TreeNode>): void {
function syncPinnedTreeNodeStateInPlaceInternal(nodes: TreeNode[], pinnedIds: Set<string>, pinnedOrder: readonly string[], isFixedPriority: FixedTreeNodePriority, 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 = orderPinnedTreeNodes(node.children);
syncPinnedTreeNodeStateInPlaceInternal(node.children, pinnedIds, pinnedOrder, isFixedPriority, visited);
node.children = orderPinnedTreeNodes(node.children, pinnedOrder, isFixedPriority);
}
if (node.hiddenChildren) {
syncPinnedTreeNodeStateInPlaceInternal(node.hiddenChildren, pinnedIds, visited);
node.hiddenChildren = orderPinnedTreeNodes(node.hiddenChildren);
syncPinnedTreeNodeStateInPlaceInternal(node.hiddenChildren, pinnedIds, pinnedOrder, isFixedPriority, visited);
node.hiddenChildren = orderPinnedTreeNodes(node.hiddenChildren, pinnedOrder, isFixedPriority);
}
}
nodes.splice(0, nodes.length, ...orderPinnedTreeNodes(nodes));
nodes.splice(0, nodes.length, ...orderPinnedTreeNodes(nodes, pinnedOrder, isFixedPriority));
}
export function syncPinnedTreeNodeStateInPlace(nodes: TreeNode[], pinnedIds: Set<string>): void {
syncPinnedTreeNodeStateInPlaceInternal(nodes, pinnedIds, new WeakSet());
export function syncPinnedTreeNodeStateInPlace(nodes: TreeNode[], pinnedIds: Set<string>, pinnedOrder: readonly string[] = [...pinnedIds], isFixedPriority: FixedTreeNodePriority = () => false): void {
syncPinnedTreeNodeStateInPlaceInternal(nodes, pinnedIds, pinnedOrder, isFixedPriority, new WeakSet());
}
+111 -2
View File
@@ -1,5 +1,6 @@
import type { ObjectInfo } from "@/types/database";
import { compareDatabaseObjectNames, normalizeDatabaseObjectName } from "@/lib/table/tableTree";
import type { ObjectInfo, TreeNode, TreeNodeType } from "@/types/database";
import { pinnedTreeNodeIdentityMatches, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems";
import { buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, compareDatabaseObjectNames, normalizeDatabaseObjectName } from "@/lib/table/tableTree";
import { parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
export type ObjectBrowserRow = {
@@ -26,6 +27,114 @@ export type ObjectBrowserSortDirection = "asc" | "desc";
export type ObjectBrowserFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "triggers" | "sequences" | "packages" | "types";
export type ObjectBrowserFilterCounts = Record<ObjectBrowserFilter, number>;
export type ObjectBrowserPinnedTreeNodeContext = {
connectionId: string;
database: string;
schema?: string;
catalog?: string;
sidebarParentId?: string;
};
export function objectBrowserRowTreeNodeType(type: ObjectBrowserRow["type"]): TreeNodeType {
if (type === "TABLE") return "table";
if (type === "VIEW") return "view";
if (type === "MATERIALIZED_VIEW") return "materialized_view";
if (type === "PROCEDURE") return "procedure";
if (type === "FUNCTION") return "function";
if (type === "TRIGGER") return "trigger";
if (type === "SEQUENCE") return "sequence";
if (type === "PACKAGE_BODY") return "package-body";
if (type === "PACKAGE") return "package";
if (type === "TYPE_BODY") return "type-body";
return "type";
}
export function canonicalObjectBrowserPinnedTreeNodeIdentity(identity: PinnedTreeNodeIdentity, database: string): PinnedTreeNodeIdentity {
// Some drivers expose database-level objects with the selected database as
// their schema, while the sidebar represents the same objects without one.
// Canonicalize only this alias; real schemas remain distinct.
return identity.schema === database ? { ...identity, schema: "" } : identity;
}
export function canonicalizeObjectBrowserPinnedTreeNodeIdentity(context: ObjectBrowserPinnedTreeNodeContext): (identity: PinnedTreeNodeIdentity) => PinnedTreeNodeIdentity {
return (identity) => canonicalObjectBrowserPinnedTreeNodeIdentity(identity, context.database);
}
export function objectBrowserRowPinnedTreeNodeIdentity(row: ObjectBrowserRow, context: ObjectBrowserPinnedTreeNodeContext): PinnedTreeNodeIdentity {
return {
connectionId: context.connectionId,
database: context.database,
schema: row.schema ?? context.schema ?? "",
catalog: context.catalog || "",
type: objectBrowserRowTreeNodeType(row.type),
name: row.name,
signature: row.type === "FUNCTION" || row.type === "PROCEDURE" ? row.signature?.trim() || "" : "",
id: row.id,
};
}
export function objectBrowserRowMatchesPinnedTreeNode(row: ObjectBrowserRow, identity: PinnedTreeNodeIdentity, context: ObjectBrowserPinnedTreeNodeContext): boolean {
return pinnedTreeNodeIdentityMatches(identity, objectBrowserRowPinnedTreeNodeIdentity(row, context), canonicalizeObjectBrowserPinnedTreeNodeIdentity(context));
}
function flattenTreeNodeIds(nodes: readonly TreeNode[]): string[] {
return nodes.flatMap((node) => [node.id, ...(node.children ? flattenTreeNodeIds(node.children) : [])]);
}
function objectBrowserRowObjectInfo(row: ObjectBrowserRow, schema?: string): ObjectInfo {
return {
name: row.name,
object_type: row.type,
schema,
signature: row.signature || undefined,
parent_schema: row.partitionParentSchema,
parent_name: row.partitionParentName,
};
}
function legacySchemaCandidates(row: ObjectBrowserRow, context: ObjectBrowserPinnedTreeNodeContext): Array<string | undefined> {
const identity = objectBrowserRowPinnedTreeNodeIdentity(row, context);
const canonicalIdentity = canonicalizeObjectBrowserPinnedTreeNodeIdentity(context)(identity);
return [...new Set([row.schema, context.schema, canonicalIdentity.schema, undefined].map((schema) => schema?.trim() || undefined))];
}
/**
* Builds the historical bare IDs emitted by each sidebar layout. The current
* v2 key is identity-based, but old persisted pins contain only these IDs.
*/
export function objectBrowserRowLegacyPinnedTreeNodeIds(row: ObjectBrowserRow, context: ObjectBrowserPinnedTreeNodeContext): string[] {
const parentId = context.sidebarParentId || `${context.connectionId}:${context.database}`;
const legacyIds = new Set<string>();
for (const schema of legacySchemaCandidates(row, context)) {
const object = objectBrowserRowObjectInfo(row, schema);
const simpleNodes =
row.type === "TABLE" || row.type === "VIEW" || row.type === "MATERIALIZED_VIEW"
? buildTableTreeNodes({
nodeId: parentId,
connectionId: context.connectionId,
database: context.database,
schema,
tables: [
{
name: row.name,
table_type: row.type,
comment: row.comment,
parent_schema: row.partitionParentSchema,
parent_name: row.partitionParentName,
},
],
})
: buildSimpleObjectTreeNodes({ nodeId: parentId, connectionId: context.connectionId, database: context.database, schema, objects: [object] });
for (const id of flattenTreeNodeIds(simpleNodes)) legacyIds.add(id);
const groupedNodes = buildGroupedObjectTreeNodes({ nodeId: parentId, connectionId: context.connectionId, database: context.database, schema, objects: [object] });
for (const group of groupedNodes) {
for (const id of flattenTreeNodeIds(group.children || [])) legacyIds.add(id);
}
}
return [...legacyIds];
}
export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type"] {
const value = type.toUpperCase();
const normalized = value.replace(/[\s-]+/g, "_");