diff --git a/src/components/sidebar/ConnectionTree.vue b/src/components/sidebar/ConnectionTree.vue index 94560ebd56..c279644866 100644 --- a/src/components/sidebar/ConnectionTree.vue +++ b/src/components/sidebar/ConnectionTree.vue @@ -4,7 +4,7 @@ import { useI18n } from "vue-i18n"; import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next"; import { useConnectionStore } from "@/stores/connectionStore"; import type { TreeNode } from "@/types/database"; -import { matchSidebarLabel } from "@/lib/sidebarSearch"; +import { filterSidebarTree } from "@/lib/sidebarSearchTree"; import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts"; import { SIDEBAR_TREE_ROW_HEIGHT, @@ -90,54 +90,6 @@ function clearTypeFilter() { selectedTypes.value = []; } -const normalizedLabelCache = new WeakMap(); - -function normalizedLabel(node: TreeNode): string { - const cached = normalizedLabelCache.get(node); - if (cached?.label === node.label) return cached.normalized; - - const normalized = node.label.toLowerCase(); - normalizedLabelCache.set(node, { label: node.label, normalized }); - return normalized; -} - -function filterTree(nodes: TreeNode[], q: string): TreeNode[] { - const filteredNodes: { node: TreeNode; score: number }[] = []; - - for (const node of nodes) { - if (node.type === "object-browser" && node.hiddenChildren) { - const matches = node.hiddenChildren - .map((child) => ({ node: child, score: matchSidebarLabel(normalizedLabel(child), q)?.score ?? 0 })) - .filter((m) => m.score > 0); - filteredNodes.push(...matches); - continue; - } - - const label = normalizedLabel(node); - const selfMatch = matchSidebarLabel(label, q); - const filteredChildren = node.children ? filterTree(node.children, q) : undefined; - - if (selfMatch || (filteredChildren && filteredChildren.length > 0)) { - if (!node.children) { - filteredNodes.push({ node, score: selfMatch?.score ?? 0 }); - } else { - const children = filteredChildren ?? []; - filteredNodes.push({ - node: { - ...node, - children, - isExpanded: children.length > 0 && !searchCollapsedIds.value.has(node.id), - }, - score: selfMatch?.score ?? 0, - }); - } - } - } - - filteredNodes.sort((a, b) => b.score - a.score); - return filteredNodes.map((m) => m.node); -} - function matchesType(node: TreeNode): boolean { if (node.type === "connection-group") { return node.children?.some(matchesType) ?? false; @@ -163,7 +115,7 @@ const filteredNodes = computed(() => { const q = deferredSearchQuery.value; if (q) { - nodes = filterTree(nodes, q); + nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value); } return nodes; diff --git a/src/lib/sidebarSearchTree.ts b/src/lib/sidebarSearchTree.ts new file mode 100644 index 0000000000..a2cba04dd9 --- /dev/null +++ b/src/lib/sidebarSearchTree.ts @@ -0,0 +1,57 @@ +import type { TreeNode } from "@/types/database"; +import { matchSidebarLabel } from "@/lib/sidebarSearch"; + +const preserveMatchedSubtreeTypes = new Set(["table", "view"]); + +const normalizedLabelCache = new WeakMap(); + +function normalizedLabel(node: TreeNode): string { + const cached = normalizedLabelCache.get(node); + if (cached?.label === node.label) return cached.normalized; + + const normalized = node.label.toLowerCase(); + normalizedLabelCache.set(node, { label: node.label, normalized }); + return normalized; +} + +export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet): TreeNode[] { + const filteredNodes: { node: TreeNode; score: number }[] = []; + + for (const node of nodes) { + if (node.type === "object-browser" && node.hiddenChildren) { + const matches = node.hiddenChildren + .map((child) => ({ node: child, score: matchSidebarLabel(normalizedLabel(child), query)?.score ?? 0 })) + .filter((match) => match.score > 0); + filteredNodes.push(...matches); + continue; + } + + const label = normalizedLabel(node); + const selfMatch = matchSidebarLabel(label, query); + const preservesSubtree = !!selfMatch && preserveMatchedSubtreeTypes.has(node.type); + const filteredChildren = preservesSubtree + ? node.children + : node.children + ? filterSidebarTree(node.children, query, collapsedIds) + : undefined; + + if (selfMatch || (filteredChildren && filteredChildren.length > 0)) { + if (!node.children) { + filteredNodes.push({ node, score: selfMatch?.score ?? 0 }); + } else { + const children = filteredChildren ?? []; + filteredNodes.push({ + node: { + ...node, + children, + isExpanded: children.length > 0 && !collapsedIds.has(node.id), + }, + score: selfMatch?.score ?? 0, + }); + } + } + } + + filteredNodes.sort((a, b) => b.score - a.score); + return filteredNodes.map((match) => match.node); +} diff --git a/tests/sidebarSearchTree.test.ts b/tests/sidebarSearchTree.test.ts new file mode 100644 index 0000000000..6202b6a4a9 --- /dev/null +++ b/tests/sidebarSearchTree.test.ts @@ -0,0 +1,55 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { filterSidebarTree } from "../src/lib/sidebarSearchTree.ts"; +import type { TreeNode } from "../src/types/database.ts"; + +test("preserves loaded table children when the table itself matches search", () => { + const nodes: TreeNode[] = [ + { + id: "conn:db", + label: "app", + type: "database", + connectionId: "conn", + database: "app", + isExpanded: true, + children: [ + { + id: "conn:db:orders", + label: "orders", + type: "table", + connectionId: "conn", + database: "app", + isExpanded: true, + children: [ + { + id: "conn:db:orders:__columns", + label: "tree.columns", + type: "group-columns", + connectionId: "conn", + database: "app", + tableName: "orders", + isExpanded: true, + children: [ + { + id: "conn:db:orders:__columns:id", + label: "id", + type: "column", + connectionId: "conn", + database: "app", + tableName: "orders", + }, + ], + }, + ], + }, + ], + }, + ]; + + const filtered = filterSidebarTree(nodes, "orders", new Set()); + + const table = filtered[0]?.children?.[0]; + assert.equal(table?.label, "orders"); + assert.equal(table?.children?.[0]?.label, "tree.columns"); + assert.equal(table?.children?.[0]?.children?.[0]?.label, "id"); +});