From f82749163d0ebde234cff6dcf494ad949a2098f4 Mon Sep 17 00:00:00 2001 From: guoyongchang Date: Thu, 30 Jul 2026 14:47:35 +0800 Subject: [PATCH] fix(sidebar): preserve table details after filtering --- .../src/components/sidebar/ConnectionTree.vue | 7 +- .../src/lib/sidebar/sidebarSearchTree.ts | 10 ++- packages/app-tests/sidebarSearchTree.test.ts | 72 ++++++++++++++++++- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index dafa9f905b..10942d5305 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -7,7 +7,7 @@ import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import type { ObjectSourceKind, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database"; -import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards } from "@/lib/sidebar/sidebarSearchTree"; +import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards, reuseLiveSidebarTreeNodes } from "@/lib/sidebar/sidebarSearchTree"; import { matchSidebarLabel } from "@/lib/sidebar/sidebarSearch"; import { buildTableTreeNodes } from "@/lib/table/tableTree"; import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts"; @@ -382,7 +382,10 @@ function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] { indexed === null ? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label.toLowerCase(), query.toLowerCase())) : indexed - ? buildTableTreeNodes({ nodeId: node.id, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, tables: indexed.filter((entry) => !!matchSidebarLabel(entry.name.toLowerCase(), query.toLowerCase())) }) + ? reuseLiveSidebarTreeNodes( + buildTableTreeNodes({ nodeId: node.id, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, tables: indexed.filter((entry) => !!matchSidebarLabel(entry.name.toLowerCase(), query.toLowerCase())) }), + children, + ) : children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label.toLowerCase(), query.toLowerCase())); return { ...node, children: matchingChildren, isExpanded: true }; }); diff --git a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts index c3f3dd883e..690ff0c2cc 100644 --- a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts +++ b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts @@ -28,6 +28,11 @@ export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds return filterSidebarTreeWithMatcher(nodes, matchLabel, collapsedIds, searchableNodeTypes); } +export function reuseLiveSidebarTreeNodes(indexedNodes: TreeNode[], liveNodes: readonly TreeNode[]): TreeNode[] { + const liveNodesById = new Map(liveNodes.map((node) => [node.id, node])); + return indexedNodes.map((node) => liveNodesById.get(node.id) ?? node); +} + function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabelMatcher | undefined, collapsedIds: ReadonlySet, searchableNodeTypes?: ReadonlySet): TreeNode[] { const filteredNodes: { node: TreeNode; score: number }[] = []; @@ -49,10 +54,13 @@ function filterSidebarTreeWithMatcher(nodes: TreeNode[], matchLabel: SidebarLabe // Type-only filtering keeps matching rows and their ancestor path, but not // unrelated descendants that would make the selected type appear ignored. const preservesSubtree = !!matchLabel && !!selfMatch && preserveMatchedSubtreeTypes.has(node.type); + // A type-matched table keeps its loaded detail groups after the text query + // is cleared instead of being rebuilt with an empty filtered child list. + const preservesTypeMatchedTable = !matchLabel && !!selfMatch && node.type === "table"; const filteredChildren = preservesSubtree ? node.children : node.children ? filterSidebarTreeWithMatcher(node.children, matchLabel, collapsedIds, searchableNodeTypes) : undefined; if (selfMatch || (filteredChildren && filteredChildren.length > 0)) { - if (!node.children) { + if (!node.children || preservesTypeMatchedTable) { filteredNodes.push({ node, score: selfMatch?.score ?? 0 }); } else { const children = filteredChildren ?? []; diff --git a/packages/app-tests/sidebarSearchTree.test.ts b/packages/app-tests/sidebarSearchTree.test.ts index e3e1b25f31..0b491b6ef4 100644 --- a/packages/app-tests/sidebarSearchTree.test.ts +++ b/packages/app-tests/sidebarSearchTree.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { filterSidebarSearchRootsByConnectionState, filterSidebarTree } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts"; +import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, reuseLiveSidebarTreeNodes } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts"; import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; test("preserves loaded table children when the table itself matches search", () => { @@ -350,6 +350,76 @@ test("filters the sidebar by node type without a text query", () => { ); }); +test("preserves an expanded type-filtered table after the text query is cleared", () => { + const table: TreeNode = { + id: "conn:db:orders", + label: "orders", + type: "table", + connectionId: "conn", + database: "inventory", + isExpanded: true, + children: [ + { + id: "conn:db:orders:__columns", + label: "tree.columns", + type: "group-columns", + connectionId: "conn", + database: "inventory", + tableName: "orders", + isExpanded: false, + children: [], + }, + ], + }; + + const [filteredTable] = filterSidebarTree([table], "", new Set(), new Set(["table"])); + + assert.equal(filteredTable, table); + assert.equal(filteredTable?.isExpanded, true); + assert.equal(filteredTable?.children?.[0]?.type, "group-columns"); +}); + +test("indexed table search reuses loaded live node state before type filtering", () => { + const liveTable: TreeNode = { + id: "conn:db:orders", + label: "orders", + type: "table", + connectionId: "conn", + database: "inventory", + isExpanded: true, + isLoading: true, + children: [ + { + id: "conn:db:orders:__columns", + label: "tree.columns", + type: "group-columns", + connectionId: "conn", + database: "inventory", + tableName: "orders", + children: [], + }, + ], + }; + const indexedTable: TreeNode = { ...liveTable, isExpanded: false, isLoading: false, children: [] }; + const indexedOnly: TreeNode = { + id: "conn:db:archive", + label: "archive", + type: "table", + connectionId: "conn", + database: "inventory", + children: [], + }; + + const merged = reuseLiveSidebarTreeNodes([indexedTable, indexedOnly], [liveTable]); + const filtered = filterSidebarTree(merged, "", new Set(), new Set(["table"])); + + assert.equal(filtered[0], liveTable); + assert.equal(filtered[0]?.children?.[0]?.type, "group-columns"); + assert.equal(filtered[0]?.isExpanded, true); + assert.equal(filtered[0]?.isLoading, true); + assert.equal(filtered[1], indexedOnly); +}); + test("combines text search with the selected node types", () => { const filtered = filterSidebarTree(scopedSearchNodes(), "order", new Set(), new Set(["schema"]));