From 78a78cc209fcbbca6888842f40c7b417e9280266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sun, 13 Sep 2026 23:30:58 +0800 Subject: [PATCH] fix(sidebar): keep table search pins across index rebuilds Closes #8964 --- .../src/components/sidebar/ConnectionTree.vue | 29 +-------- .../src/lib/sidebar/sidebarSearchTree.ts | 35 ++++++++++- apps/desktop/src/lib/table/tableTree.ts | 4 +- .../connectionStorePinnedCleanup.test.ts | 60 +++++++++++++++++++ .../app-tests/sidebarTreeAffordances.test.ts | 5 +- 5 files changed, 100 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index ce71f33955..d6db48c2a7 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -10,6 +10,7 @@ import { useToast } from "@/composables/useToast"; import type { ObjectSourceKind, QueryTab, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database"; import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri"; import { + filterLocallySearchedTables, createSidebarSearchSubtreePreserver, filterSidebarSearchRootsByConnectionState, filterSidebarTree, @@ -18,15 +19,13 @@ import { mergeSidebarRegexIndexScopes, resolveSidebarFilterGuards, resolveSidebarObjectSearchFilter, - reuseLiveSidebarTreeNodes, type SidebarRegexIndexScope, type SidebarRegexScopeIdentity, } from "@/lib/sidebar/sidebarSearchTree"; -import { createSidebarLabelMatcher, matchSidebarLabel } from "@/lib/sidebar/sidebarSearch"; +import { createSidebarLabelMatcher } from "@/lib/sidebar/sidebarSearch"; import { collectSidebarRegexIndexScopes, resolveSidebarRemoteSearchQuery, resolveSidebarSearchDispatchMode } from "@/lib/sidebar/sidebarRegexSearchIndex"; import { createSidebarSearchExpansionState } from "@/lib/sidebar/sidebarSearchExpansionState"; import { createSidebarSearchLoadingTracker } from "@/lib/sidebar/sidebarSearchLoadingTracker"; -import { buildTableTreeNodes } from "@/lib/table/tableTree"; import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts"; import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut"; import { objectSourceTargetForTreeNode } from "@/lib/sidebar/treeNodeClick"; @@ -658,28 +657,6 @@ type InvalidatedTableSearchScope = SidebarRegexScopeIdentity & { parentNodeId: s const pendingInvalidatedTableSearchScopes = new Map(); const regexTableSearchScopes = shallowRef([]); -const localTableSearchParentTypes = new Set(["database", "schema", "linked-server-schema", "group-tables"]); -const localTableSearchChildTypes = new Set(["table", "view", "materialized_view"]); - -function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] { - return nodes.map((node) => { - const children = node.children ? filterLocallySearchedTables(node.children) : undefined; - const query = settingsStore.editorSettings.sidebarTableSearchLocal && localTableSearchParentTypes.has(node.type) ? store.sidebarTableSearchQueries[node.id]?.trim() : ""; - if (!query || !children) return children === node.children ? node : { ...node, children }; - - const indexed = localTableSearchResults.value[node.id]; - // matchSidebarLabel compares case-insensitively internally and needs the - // ORIGINAL label (and entry name) so camelCase boundaries stay detectable. - const matchingChildren = - indexed === null - ? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query)) - : indexed - ? reuseLiveSidebarTreeNodes(buildTableTreeNodes({ nodeId: node.id, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, tables: indexed.filter((entry) => !!matchSidebarLabel(entry.name, query)) }), children) - : children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query)); - return { ...node, children: matchingChildren }; - }); -} - async function loadRegexTableSearchIndexes() { if (!regexMode.value || !deferredSearchQuery.value) return; const loadedScopes = await collectSidebarRegexIndexScopes( @@ -781,7 +758,7 @@ const filteredNodes = computed(() => { nodes = filterSidebarTreeToConnectedConnections(nodes, store.connectedIds); } - nodes = filterLocallySearchedTables(nodes); + nodes = filterLocallySearchedTables(nodes, { enabled: settingsStore.editorSettings.sidebarTableSearchLocal, queries: store.sidebarTableSearchQueries, indexedResults: localTableSearchResults.value }); nodes = filterGloballyIndexedRegexTables(nodes); const q = deferredSearchQuery.value; diff --git a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts index 5f5bd366aa..fff5953aba 100644 --- a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts +++ b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts @@ -1,5 +1,5 @@ import type { TableInfo, TreeNode, TreeNodeType } from "@/types/database"; -import { createSidebarLabelMatcher, type SidebarLabelMatcher, type SidebarSearchMatcherOptions } from "@/lib/sidebar/sidebarSearch"; +import { createSidebarLabelMatcher, matchSidebarLabel, type SidebarLabelMatcher, type SidebarSearchMatcherOptions } from "@/lib/sidebar/sidebarSearch"; import { buildTableTreeNodes } from "@/lib/table/tableTree"; const preserveMatchedSubtreeTypes = new Set(["connection", "database", "schema", "table", "view", "mongo-db", "mongo-collection"]); @@ -72,6 +72,31 @@ export function reuseLiveSidebarTreeNodes(indexedNodes: TreeNode[], liveNodes: r return indexedNodes.map((node) => liveNodesById.get(node.id) ?? node); } +const localTableSearchParentTypes = new Set(["database", "schema", "linked-server-schema", "group-tables"]); +const localTableSearchChildTypes = new Set(["table", "view", "materialized_view"]); + +export function filterLocallySearchedTables(nodes: TreeNode[], options: { enabled: boolean; queries: Readonly>; indexedResults: Readonly> }): TreeNode[] { + return nodes.map((node) => { + const children = node.children ? filterLocallySearchedTables(node.children, options) : undefined; + const query = options.enabled && localTableSearchParentTypes.has(node.type) ? options.queries[node.id]?.trim() : ""; + if (!query || !children) return children === node.children ? node : { ...node, children }; + + const indexed = options.indexedResults[node.id]; + // matchSidebarLabel compares case-insensitively internally and needs the + // ORIGINAL label (and entry name) so camelCase boundaries stay detectable. + const matchingChildren = + indexed === null + ? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query)) + : indexed + ? reuseLiveSidebarTreeNodes( + buildSidebarIndexedTableNodes({ parentNodeId: node.id, nodeType: node.type, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, entries: indexed.filter((entry) => !!matchSidebarLabel(entry.name, query)) }), + children, + ) + : children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query)); + return { ...node, children: matchingChildren }; + }); +} + export interface SidebarRegexIndexScope { parentNodeId: string; connectionId: string; @@ -132,7 +157,7 @@ export function findNodePathByIdentity(nodes: readonly TreeNode[], id: string, i return undefined; } -function indexedTableChildren(scope: SidebarRegexIndexScope): TreeNode[] { +export function buildSidebarIndexedTableNodes(scope: SidebarRegexIndexScope): TreeNode[] { // Reuse the regular table-node builder so index hits share live-node id // rules, normalized object types, name sorting, catalog/schema fields, and // partition parent/child nesting instead of hand-rolled node literals. @@ -143,6 +168,10 @@ function indexedTableChildren(scope: SidebarRegexIndexScope): TreeNode[] { schema: scope.schema, catalog: scope.catalog, tables: scope.entries, + // Grouped object lists include the schema in each child id; simple table + // lists do not. Search must use the same id because persisted pin keys + // include it, even when an index hit has not been loaded into the live tree. + includeSchemaInId: scope.nodeType === "group-tables", }); } @@ -160,7 +189,7 @@ function syntheticRegexParent(scope: SidebarRegexIndexScope): TreeNode { ...(snapshot?.linkedCatalog ? { linkedCatalog: snapshot.linkedCatalog } : {}), ...(snapshot?.linkedSchema ? { linkedSchema: snapshot.linkedSchema } : {}), isExpanded: true, - children: indexedTableChildren(scope), + children: buildSidebarIndexedTableNodes(scope), }; } diff --git a/apps/desktop/src/lib/table/tableTree.ts b/apps/desktop/src/lib/table/tableTree.ts index 43dcb30716..8968ba8926 100644 --- a/apps/desktop/src/lib/table/tableTree.ts +++ b/apps/desktop/src/lib/table/tableTree.ts @@ -7,7 +7,7 @@ export function normalizeDatabaseObjectName(name: string): string { return name.trim(); } -export function buildTableTreeNodes({ nodeId, connectionId, database, schema, tables, catalog }: { nodeId: string; connectionId: string; database: string; schema?: string; tables: TableInfo[]; catalog?: string }): TreeNode[] { +export function buildTableTreeNodes({ nodeId, connectionId, database, schema, tables, catalog, includeSchemaInId = false }: { nodeId: string; connectionId: string; database: string; schema?: string; tables: TableInfo[]; catalog?: string; includeSchemaInId?: boolean }): TreeNode[] { const entries = tables.flatMap((table) => { const name = normalizeDatabaseObjectName(table.name); if (!name) return []; @@ -19,7 +19,7 @@ export function buildTableTreeNodes({ nodeId, connectionId, database, schema, ta connectionId, database, schema: childSchema, - includeSchemaInId: false, + includeSchemaInId, name, objectType, tableType: table.table_type, diff --git a/packages/app-tests/connectionStorePinnedCleanup.test.ts b/packages/app-tests/connectionStorePinnedCleanup.test.ts index edd9d4923b..dd8085f576 100644 --- a/packages/app-tests/connectionStorePinnedCleanup.test.ts +++ b/packages/app-tests/connectionStorePinnedCleanup.test.ts @@ -2,6 +2,9 @@ import { test } from "vitest"; import assert from "node:assert/strict"; import { createPinia, setActivePinia } from "pinia"; import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts"; +import { filterLocallySearchedTables } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts"; +import { buildGroupedObjectTreeNodes, buildTableTreeNodes } from "../../apps/desktop/src/lib/table/tableTree.ts"; +import { applyPinnedTreeNodeState } from "../../apps/desktop/src/lib/app/pinnedItems.ts"; import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts"; function installMemoryStorage(initial: Record = {}) { @@ -172,3 +175,60 @@ test("removeConnections clears persisted sidebar table filters for every removed storage.restore(); } }); + +test.each([ + { label: "PostgreSQL grouped", schema: "public", grouped: true }, + { label: "PostgreSQL simple", schema: "public", grouped: false }, + { label: "MySQL grouped", schema: undefined, grouped: true }, +])("$label keeps a search pin after clearing search and allows unpinning", ({ schema, grouped }) => { + const storage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + const store = useConnectionStore(); + const context = { nodeId: "conn:app", connectionId: "conn", database: "app", schema }; + const tables = ["aaa", "orders"].map((name) => ({ name, table_type: "TABLE" })); + const buildGroup = () => grouped + ? buildGroupedObjectTreeNodes({ ...context, objects: tables.map((table) => ({ name: table.name, object_type: table.table_type, schema })) })[0] + : { id: context.nodeId, label: "app", type: "schema" as const, ...context, children: buildTableTreeNodes({ ...context, tables }) }; + const group = buildGroup(); + store.treeNodes = [group]; + const liveGroup = store.treeNodes[0]; + const target = liveGroup.children!.find((node) => node.label === "orders")!; + const project = (enabled = true) => filterLocallySearchedTables(store.treeNodes, { + enabled, queries: store.sidebarTableSearchQueries, indexedResults: { [group.id]: tables }, + })[0].children!; + + store.setSidebarTableSearchQuery(group.id, "orders"); + assert.deepEqual(project().map((node) => node.label), ["orders"]); + assert.deepEqual(project(false).map((node) => node.label), ["aaa", "orders"]); + store.toggleTreeNodePin(project()[0]); + store.setSidebarTableSearchQuery(group.id, ""); + const cleared = project(); + assert.deepEqual(cleared.map((node) => node.label), ["orders", "aaa"]); + assert.equal(cleared[0].pinned, true); + assert.equal(target.pinned, true); + assert.equal(store.isTreeNodePinned(cleared[0]), true); + + store.setSidebarTableSearchQuery(group.id, "orders"); + assert.equal(project()[0].pinned, true); + store.toggleTreeNodePin(project()[0]); + store.setSidebarTableSearchQuery(group.id, ""); + assert.deepEqual(project().map((node) => node.label), ["aaa", "orders"]); + assert.equal(target.pinned, false); + assert.deepEqual(JSON.parse(storage.values.get("dbx-pinned-tree-nodes")!), []); + + // Separate from clearing search: an index-only hit survives metadata reload. + liveGroup.children = liveGroup.children!.filter((node) => node.label !== "orders"); + store.setSidebarTableSearchQuery(group.id, "orders"); + store.toggleTreeNodePin(project()[0]); + const saved = new Set(JSON.parse(storage.values.get("dbx-pinned-tree-nodes")!)); + const restored = applyPinnedTreeNodeState(buildGroup().children!, saved); + assert.equal(restored[0].label, "orders"); + assert.equal(restored[0].pinned, true); + assert.equal(store.isTreeNodePinned(restored[0]), true); + store.toggleTreeNodePin(restored[0]); + assert.deepEqual(JSON.parse(storage.values.get("dbx-pinned-tree-nodes")!), []); + } finally { + storage.restore(); + } +}); diff --git a/packages/app-tests/sidebarTreeAffordances.test.ts b/packages/app-tests/sidebarTreeAffordances.test.ts index 73002d4d33..dab550d7db 100644 --- a/packages/app-tests/sidebarTreeAffordances.test.ts +++ b/packages/app-tests/sidebarTreeAffordances.test.ts @@ -7,6 +7,7 @@ import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8"); const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8"); +const sidebarSearchTree = readFileSync("apps/desktop/src/lib/sidebar/sidebarSearchTree.ts", "utf8"); const connectionStore = readFileSync("apps/desktop/src/stores/connectionStore.ts", "utf8"); test("sidebar rows retain database-specific node affordances", () => { @@ -87,8 +88,8 @@ test("async tree expansion does not restore a stale rendered clone state", () => }); test("tree filters retain a temporary expansion state", () => { - assert.match(connectionTree, /return \{ \.\.\.node, children: matchingChildren \};/); - assert.doesNotMatch(connectionTree, /children: matchingChildren,\s*isExpanded:\s*true/); + assert.match(sidebarSearchTree, /return \{ \.\.\.node, children: matchingChildren \};/); + assert.doesNotMatch(sidebarSearchTree, /children: matchingChildren,\s*isExpanded:\s*true/); assert.match(connectionTree, /function onSearchToggle\(node: TreeNode\) \{\s*if \(!isTreeSearchFiltering\.value \|\| !node\.children\) return;/); // The search guard must stay the first thing in onNodeToggled (filter toggles // must never sync back to the live tree); side-effect-free diagnostics may be