diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index b6fe513379..053da81cff 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -8,7 +8,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore"; 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, reuseLiveSidebarTreeNodes } from "@/lib/sidebar/sidebarSearchTree"; +import { createSidebarSearchSubtreePreserver, filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards, resolveSidebarObjectSearchFilter, reuseLiveSidebarTreeNodes } from "@/lib/sidebar/sidebarSearchTree"; import { matchSidebarLabel } from "@/lib/sidebar/sidebarSearch"; import { buildTableTreeNodes } from "@/lib/table/tableTree"; import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts"; @@ -229,8 +229,9 @@ watch(deferredSearchQuery, (newQuery, oldQuery) => { return; } const tasks: Promise[] = []; + const preservesSearchSubtree = newQuery ? createSidebarSearchSubtreePreserver(newQuery, searchableNodeTypes.value) : undefined; for (const root of store.treeNodes) { - collectExpandedObjectSearchTargets(root, tasks, newQuery ? searchRefreshedNodeIds : undefined); + collectExpandedObjectSearchTargets(root, tasks, newQuery ? searchRefreshedNodeIds : undefined, preservesSearchSubtree); } if (!newQuery && oldQuery) { searchRefreshedNodeIds.clear(); @@ -250,7 +251,8 @@ function isSimpleObjectSearchParent(node: TreeNode): boolean { return settingsStore.editorSettings.sidebarObjectDisplay === "simple" && simpleObjectParentTypes.has(node.type) && node.isExpanded === true && (!!node.children?.some((child) => simpleObjectChildTypes.has(child.type)) || !!store.sidebarTableSearchQueries[node.id]?.trim()); } -function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise[], refreshedNodeIds?: Set) { +function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise[], refreshedNodeIds?: Set, preservesNodeSubtree?: (node: TreeNode) => boolean, ancestorPreservesSearchSubtree = false) { + const preservesSearchSubtree = ancestorPreservesSearchSubtree || (!!refreshedNodeIds && !!preservesNodeSubtree?.(node)); if (refreshedNodeIds && node.type === "connection" && node.connectionId) { if (store.connectedIds.has(node.connectionId)) { tasks.push(store.loadConnectedConnectionRootForSidebarSearch(node.connectionId)); @@ -258,15 +260,27 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise if (node.connectionId !== store.activeConnectionId) return; } if (refreshedNodeIds && isSimpleObjectSearchParent(node)) { - refreshedNodeIds.add(node.id); - tasks.push(store.refreshTreeNode(node)); + if (preservesSearchSubtree) { + if (refreshedNodeIds.delete(node.id)) { + tasks.push(store.loadTreeNodeChildren(node, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); + } + } else { + refreshedNodeIds.add(node.id); + tasks.push(store.refreshTreeNode(node)); + } return; } if (refreshedNodeIds && node.isExpanded && node.children) { for (const child of node.children) { if (child.connectionId && searchableObjectGroupTypes.has(child.type)) { - refreshedNodeIds.add(child.id); - tasks.push(store.loadObjectGroupChildren(child, { force: true })); + if (preservesSearchSubtree) { + if (refreshedNodeIds.delete(child.id)) { + tasks.push(store.loadObjectGroupChildren(child, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); + } + } else { + refreshedNodeIds.add(child.id); + tasks.push(store.loadObjectGroupChildren(child, { force: true })); + } } } } else if (!refreshedNodeIds && searchRefreshedNodeIds.has(node.id)) { @@ -278,7 +292,7 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise } if (node.children) { for (const child of node.children) { - collectExpandedObjectSearchTargets(child, tasks, refreshedNodeIds); + collectExpandedObjectSearchTargets(child, tasks, refreshedNodeIds, preservesNodeSubtree, preservesSearchSubtree); } } } @@ -938,6 +952,12 @@ const pasteHandlerRegistry = createSidebarPasteHandlerRegistry(); provide(sidebarTreeContextKey, { getVisibleNodes: () => selectableVisibleNodes.value, getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1, + getTreeLoadSearchOptions: (node) => { + const query = deferredSearchQuery.value; + if (!query) return undefined; + const searchFilter = resolveSidebarObjectSearchFilter(store.treeNodes, node.id, query, searchableNodeTypes.value); + return searchFilter ? undefined : { searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: query }; + }, setTableSearchQuery: (parentNodeId, query, local) => { const focusRestore = captureTableSearchFocus(parentNodeId); latestTableSearchInteractionParentId = parentNodeId; diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index d11227e560..f53a013763 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -566,6 +566,7 @@ async function openDirectNavigationNode(node: TreeNode) { async function toggle() { const node = activeNode.value; + const treeLoadSearchOptions = sidebarTreeContext?.getTreeLoadSearchOptions?.(node); if (isDirectNavigationTreeNode(node.type)) { try { await openDirectNavigationNode(node); @@ -664,7 +665,7 @@ async function toggle() { return; } - if (await loadSidebarObjectGroup(node, connectionStore)) { + if (await loadSidebarObjectGroup(node, connectionStore, treeLoadSearchOptions)) { emitNodeToggled(node, wasExpanded); return; } @@ -697,11 +698,11 @@ async function toggle() { } else if (config?.db_type === "mq") { await connectionStore.loadMqTenants(node.connectionId); } else if (config?.db_type === "nacos") { - await connectionStore.loadNacosNamespaces(node.connectionId); + await connectionStore.loadNacosNamespaces(node.connectionId, treeLoadSearchOptions); } else if (config?.db_type === "mqtt") { await connectionStore.loadMqttTopics(node.connectionId); } else { - await connectionStore.loadDatabases(node.connectionId); + await connectionStore.loadDatabases(node.connectionId, treeLoadSearchOptions); } } else if (node.type === "redis-db" && node.connectionId && node.database) { await connectionStore.ensureConnected(node.connectionId); @@ -779,30 +780,30 @@ async function toggle() { } } else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) { if (node.catalog && node.catalog !== "internal") { - await connectionStore.loadDorisCatalogTables(node); + await connectionStore.loadDorisCatalogTables(node, treeLoadSearchOptions); } else { const config = connectionStore.getConfig(node.connectionId); const effectiveDbType = effectiveDatabaseTypeForConnection(config); if (config?.db_type === "sqlserver") { - await connectionStore.loadSqlServerDatabaseObjects(node.connectionId, node.database); + await connectionStore.loadSqlServerDatabaseObjects(node.connectionId, node.database, treeLoadSearchOptions); } else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) { - await connectionStore.loadSchemas(node.connectionId, node.database); + await connectionStore.loadSchemas(node.connectionId, node.database, treeLoadSearchOptions); } else { - await connectionStore.loadTables(node.connectionId, node.database); + await connectionStore.loadTables(node.connectionId, node.database, undefined, treeLoadSearchOptions); } } } else if (node.type === "doris-catalog" && node.connectionId) { - await connectionStore.loadDorisCatalogDatabases(node); + await connectionStore.loadDorisCatalogDatabases(node, treeLoadSearchOptions); } else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { - await connectionStore.loadTables(node.connectionId, node.database, node.schema); + await connectionStore.loadTables(node.connectionId, node.database, node.schema, treeLoadSearchOptions); } else if (node.type === "linked-server-root" && node.connectionId) { - await connectionStore.loadSqlServerLinkedServers(node.connectionId); + await connectionStore.loadSqlServerLinkedServers(node.connectionId, treeLoadSearchOptions); } else if (node.type === "linked-server" && node.connectionId) { - await connectionStore.loadSqlServerLinkedServerCatalogs(node); + await connectionStore.loadSqlServerLinkedServerCatalogs(node, treeLoadSearchOptions); } else if (node.type === "linked-server-catalog" && node.connectionId) { - await connectionStore.loadSqlServerLinkedServerSchemas(node); + await connectionStore.loadSqlServerLinkedServerSchemas(node, treeLoadSearchOptions); } else if (node.type === "linked-server-schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) { - await connectionStore.loadTables(node.connectionId, node.database, node.schema); + await connectionStore.loadTables(node.connectionId, node.database, node.schema, treeLoadSearchOptions); } else if ((node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId && hasTreeNodeDatabaseContext(node)) { await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id, node.catalog); } else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { diff --git a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts index 4198c5b550..a44ea25c49 100644 --- a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts @@ -1,10 +1,11 @@ // @vitest-environment happy-dom -import { createApp, defineComponent, h, nextTick, ref, type App } from "vue"; +import { createApp, defineComponent, h, nextTick, provide, ref, type App } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; import type { TreeNode } from "@/types/database"; import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion"; +import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue"; const connectionStore = { @@ -16,6 +17,9 @@ const connectionStore = { loadPackageMembers: vi.fn(async (node: TreeNode) => { node.isExpanded = true; }), + loadObjectGroupChildren: vi.fn(async (node: TreeNode) => { + node.isExpanded = true; + }), }; vi.mock("@/stores/connectionStore", () => ({ @@ -44,6 +48,7 @@ afterEach(() => { connectionStore.treeNodes = []; connectionStore.sidebarSearchQuery = ""; vi.clearAllMocks(); + connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true); connectionStore.getConfig.mockReturnValue({ db_type: "mysql" }); }); @@ -116,4 +121,47 @@ describe("SidebarTreeRuntimeHost expansion", () => { expect(packageNode.isExpanded).toBe(true); expect(toggled).toHaveBeenCalledWith(packageNode, true); }); + + it("expands an unloaded object group without leaking a matching connection name", async () => { + const group: TreeNode = { + id: "mysql:basic:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: "mysql", + database: "basic", + isExpanded: false, + children: [], + }; + connectionStore.sidebarSearchQuery = "60307"; + connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(false); + + const host = ref | null>(null); + const app = createApp( + defineComponent({ + setup() { + provide(sidebarTreeContextKey, { + getVisibleNodes: () => [group], + getVisibleNodeIndex: () => 0, + getTreeLoadSearchOptions: () => ({ searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: "60307" }), + }); + return () => h(SidebarTreeRuntimeHost, { ref: host, node: group, depth: 0 }); + }, + }), + ); + mountedApps.push(app); + const container = document.createElement("div"); + document.body.append(container); + app.use(i18n); + app.mount(container); + + host.value?.toggleNode(group); + + await vi.waitFor(() => + expect(connectionStore.loadObjectGroupChildren).toHaveBeenCalledWith(group, { + searchFilter: "", + allowGlobalSearchMismatch: true, + expectedSidebarSearchQuery: "60307", + }), + ); + }); }); diff --git a/apps/desktop/src/lib/sidebar/sidebarObjectGroupRouting.ts b/apps/desktop/src/lib/sidebar/sidebarObjectGroupRouting.ts index cc18ce1a96..cacb543c3f 100644 --- a/apps/desktop/src/lib/sidebar/sidebarObjectGroupRouting.ts +++ b/apps/desktop/src/lib/sidebar/sidebarObjectGroupRouting.ts @@ -4,7 +4,13 @@ import { objectTypesForGroupNode } from "@/lib/table/tableTree"; export interface SidebarObjectGroupLoaders { loadTriggers(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string): Promise; - loadObjectGroupChildren(node: TreeNode): Promise; + loadObjectGroupChildren(node: TreeNode, options?: SidebarObjectGroupLoadOptions): Promise; +} + +export interface SidebarObjectGroupLoadOptions { + searchFilter?: string; + allowGlobalSearchMismatch?: boolean; + expectedSidebarSearchQuery?: string; } /** @@ -12,13 +18,13 @@ export interface SidebarObjectGroupLoaders { * Table trigger groups are special because they are scoped to one table; * schema-level trigger groups are regular database object groups. */ -export async function loadSidebarObjectGroup(node: TreeNode, loaders: SidebarObjectGroupLoaders): Promise { +export async function loadSidebarObjectGroup(node: TreeNode, loaders: SidebarObjectGroupLoaders, options?: SidebarObjectGroupLoadOptions): Promise { if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) { await loaders.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog); return true; } if (!objectTypesForGroupNode(node.type)) return false; - await loaders.loadObjectGroupChildren(node); + await loaders.loadObjectGroupChildren(node, options); return true; } diff --git a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts index 6f1c6a41f7..abe6f888cd 100644 --- a/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts +++ b/apps/desktop/src/lib/sidebar/sidebarSearchTree.ts @@ -22,6 +22,36 @@ function normalizedLabel(node: TreeNode): string { return node.label; } +function findNodePath(nodes: readonly TreeNode[], targetNodeId: string, ancestors: readonly TreeNode[] = []): readonly TreeNode[] | undefined { + for (const node of nodes) { + const path = [...ancestors, node]; + if (node.id === targetNodeId) return path; + if (node.children) { + const childPath = findNodePath(node.children, targetNodeId, path); + if (childPath) return childPath; + } + } + return undefined; +} + +function nodePreservesSearchSubtree(node: TreeNode, matchLabel: SidebarLabelMatcher, searchableNodeTypes?: ReadonlySet): boolean { + if (searchableNodeTypes && !searchableNodeTypes.has(node.type)) return false; + return preserveMatchedSubtreeTypes.has(node.type) && !!bestMatch(matchLabel, normalizedLabel(node), node.comment, node.searchAliases); +} + +export function createSidebarSearchSubtreePreserver(query: string, searchableNodeTypes?: ReadonlySet): (node: TreeNode) => boolean { + const matchLabel = query ? createSidebarLabelMatcher(query) : undefined; + return (node) => !!matchLabel && nodePreservesSearchSubtree(node, matchLabel, searchableNodeTypes); +} + +export function resolveSidebarObjectSearchFilter(nodes: readonly TreeNode[], targetNodeId: string, query: string, searchableNodeTypes?: ReadonlySet): string { + if (!query) return query; + const matchLabel = createSidebarLabelMatcher(query); + const path = findNodePath(nodes, targetNodeId); + const preservesSubtree = path?.some((node) => nodePreservesSearchSubtree(node, matchLabel, searchableNodeTypes)); + return preservesSubtree ? "" : query; +} + export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet, searchableNodeTypes?: ReadonlySet): TreeNode[] { const matchLabel = query ? createSidebarLabelMatcher(query) : undefined; if (!matchLabel && searchableNodeTypes === undefined) return nodes; diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts b/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts index c852c34677..2b8306763a 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts @@ -4,6 +4,7 @@ import type { TreeNode } from "@/types/database"; export interface SidebarTreeContext { getVisibleNodes: () => TreeNode[]; getVisibleNodeIndex: (id: string) => number; + getTreeLoadSearchOptions?: (node: TreeNode) => { searchFilter: string; allowGlobalSearchMismatch: true; expectedSidebarSearchQuery: string } | undefined; setTableSearchQuery?: (parentNodeId: string, query: string, local: boolean) => void; refreshTableSearchIndex?: (parentNodeId: string) => void; registerPasteHandler?: (nodeId: string, callback: () => void) => () => void; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 16776cfd25..0c876dce20 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -2968,17 +2968,15 @@ describe("connectionStore metadata loading", () => { })); let resolveSearchClearReload!: (tables: TableInfo[]) => void; let unfilteredFirstPageCalls = 0; - const listTables = vi.fn( - (_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number, offset?: number) => { - if (searchFilter) return Promise.resolve(tables.filter((table) => table.name.includes(searchFilter))); - if ((offset ?? 0) === 0 && ++unfilteredFirstPageCalls === 1) { - return new Promise((resolve) => { - resolveSearchClearReload = resolve; - }); - } - return Promise.resolve(tables.slice(offset ?? 0, (offset ?? 0) + (limit ?? tables.length))); - }, - ); + const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number, offset?: number) => { + if (searchFilter) return Promise.resolve(tables.filter((table) => table.name.includes(searchFilter))); + if ((offset ?? 0) === 0 && ++unfilteredFirstPageCalls === 1) { + return new Promise((resolve) => { + resolveSearchClearReload = resolve; + }); + } + return Promise.resolve(tables.slice(offset ?? 0, (offset ?? 0) + (limit ?? tables.length))); + }); vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); vi.doMock("@/lib/backend/api", () => ({ @@ -3060,6 +3058,75 @@ describe("connectionStore metadata loading", () => { expect(listTables).toHaveBeenCalledTimes(5); }); + it("keeps connection-name matches out of object metadata filters", async () => { + const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string) => Promise.resolve(searchFilter === "orders" || !searchFilter ? [{ name: "orders", table_type: "TABLE" as const, comment: null }] : [])); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.editorSettings.sidebarObjectDisplay = "grouped"; + settingsStore.desktopSettings.sidebar_table_page_size = 200; + const connection = { ...mysqlConnection(), name: "60307" }; + const tablesGroup: TreeNode = { + id: "mysql-1:basic:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: "basic", + isExpanded: true, + children: [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tablesGroup] }]; + + store.sidebarSearchQuery = "60307"; + await store.loadObjectGroupChildren(tablesGroup, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: "60307" }); + + expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", undefined, 201, 0, ["TABLE"]); + expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]); + + store.sidebarSearchQuery = "orders"; + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + + expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", "orders", 200, undefined, ["TABLE"]); + expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]); + + let resolveAncestorLoad!: (tables: TableInfo[]) => void; + listTables.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveAncestorLoad = resolve; + }), + ); + store.sidebarSearchQuery = "60307"; + const ancestorLoad = store.loadObjectGroupChildren(tablesGroup, { + force: true, + searchFilter: "", + allowGlobalSearchMismatch: true, + expectedSidebarSearchQuery: "60307", + }); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(3)); + + store.sidebarSearchQuery = "customers"; + resolveAncestorLoad([{ name: "stale_table", table_type: "TABLE", comment: null }]); + await ancestorLoad; + + expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]); + }); + it("merges load-more table pages onto the live group node after an in-tree replacement", async () => { const firstPage = Array.from({ length: 201 }, (_, index) => ({ name: `t_${String(index + 1).padStart(4, "0")}`, diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 9a2ee9df52..43ec4cf681 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2176,8 +2176,9 @@ export const useConnectionStore = defineStore("connection", () => { function isTreeLoadSearchChanged(searchFilter: string, options?: LoadTreeOptions): boolean { if (options?.sidebarTableSearchParentId) return isSidebarTableSearchQueryChanged(options); + if (isSidebarSearchQueryChanged(options)) return true; if (options?.allowGlobalSearchMismatch) return false; - return (sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options); + return (sidebarSearchQuery.value || "") !== searchFilter; } function isTreeNodeChildrenLoaded(nodeId: string): boolean { @@ -4476,7 +4477,7 @@ export const useConnectionStore = defineStore("connection", () => { nonTableObjectTypes, offset: 0, pageSize, - searchFilter: searchFilter || undefined, + searchFilter: options?.searchFilter === "" ? "" : searchFilter || undefined, force: options?.force, }); children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize), page.loadMoreParent) : page.children; @@ -4614,7 +4615,7 @@ export const useConnectionStore = defineStore("connection", () => { objectTypes, offset: 0, pageSize: sidebarObjectGroupPageSize(), - searchFilter: searchFilter || undefined, + searchFilter: options?.searchFilter === "" ? "" : searchFilter || undefined, force: options?.force, }); children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, sidebarObjectGroupPageSize()), page.loadMoreParent) : page.children; diff --git a/packages/app-tests/sidebarSearchTree.test.ts b/packages/app-tests/sidebarSearchTree.test.ts index 35ec0137b2..089bd0218a 100644 --- a/packages/app-tests/sidebarSearchTree.test.ts +++ b/packages/app-tests/sidebarSearchTree.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, reuseLiveSidebarTreeNodes } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts"; -import type { TreeNode } from "../../apps/desktop/src/types/database.ts"; +import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, resolveSidebarObjectSearchFilter, reuseLiveSidebarTreeNodes } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts"; +import type { TreeNode, TreeNodeType } from "../../apps/desktop/src/types/database.ts"; test("preserves loaded table children when the table itself matches search", () => { const nodes: TreeNode[] = [ @@ -212,6 +212,73 @@ test("preserves loaded children when the connection itself matches search", () = assert.equal(filtered[0]?.children?.[0]?.children?.[0]?.label, "products"); }); +test("does not treat a matching connection name as an object-name filter", () => { + const tablesGroup: TreeNode = { + id: "conn:1:basic:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: "conn:1", + database: "basic", + isExpanded: true, + children: [], + }; + const nodes: TreeNode[] = [ + { + id: "conn:1", + label: "60307", + type: "connection", + connectionId: "conn:1", + isExpanded: true, + children: [ + { + id: "conn:1:basic", + label: "basic", + type: "database", + connectionId: "conn:1", + database: "basic", + isExpanded: true, + children: [tablesGroup], + }, + ], + }, + ]; + + assert.equal(resolveSidebarObjectSearchFilter(nodes, tablesGroup.id, "60307"), ""); + assert.equal(resolveSidebarObjectSearchFilter(nodes, tablesGroup.id, "orders"), "orders"); + assert.equal(resolveSidebarObjectSearchFilter(nodes, "conn:1:basic", "basic"), ""); +}); + +test("only lets searchable ancestor types suppress the object-name filter", () => { + const tablesGroup: TreeNode = { + id: "conn:1:basic:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: "conn:1", + database: "basic", + }; + const nodes: TreeNode[] = [ + { + id: "conn:1", + label: "60307", + type: "connection", + connectionId: "conn:1", + children: [ + { + id: "conn:1:basic", + label: "basic", + type: "database", + connectionId: "conn:1", + database: "basic", + children: [tablesGroup], + }, + ], + }, + ]; + + assert.equal(resolveSidebarObjectSearchFilter(nodes, tablesGroup.id, "60307", new Set(["table"])), "60307"); + assert.equal(resolveSidebarObjectSearchFilter(nodes, tablesGroup.id, "60307", new Set(["connection", "table"])), ""); +}); + test("matches connections by host and username search aliases", () => { const connection: TreeNode = { id: "conn:1",