diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 109b3ec539..89cc5995ac 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -434,7 +434,9 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch // back. Its next explicit expansion will load the ordinary first page. node.isExpanded = false; store.discardFilteredTreeNodeChildren(node.id); - } else { + } else if (!store.restoreFilteredObjectGroupChildren(node)) { + // Nothing was captured because the group had not been loaded before the + // search, so there is no previous list to put back. tasks.push(() => store.loadObjectGroupChildren(node, { force: true })); } } else if (simpleObjectParentTypes.has(node.type)) { diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts index d87850d699..31f054abd4 100644 --- a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts @@ -21,6 +21,13 @@ describe("ConnectionTree global search loading", () => { expect(source).toContain("store.discardFilteredTreeNodeChildren(node.id);"); }); + it("restores the pre-search children of an expanded group instead of reloading page one", () => { + // Clearing a remote search must put back the pages the user had loaded + // through "load more"; only an uncaptured group falls back to a reload. + expect(source).toContain("} else if (!store.restoreFilteredObjectGroupChildren(node)) {"); + expect(source).toMatch(/restoreFilteredObjectGroupChildren\(node\)\) \{[\s\S]*?tasks\.push\(\(\) => store\.loadObjectGroupChildren\(node, \{ force: true \}\)\);/); + }); + it("limits concurrent metadata loads without dropping a task", async () => { let activeTasks = 0; let maximumActiveTasks = 0; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 6b175c790e..ff505e9e36 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -3268,6 +3268,98 @@ describe("connectionStore metadata loading", () => { expect(listTables).toHaveBeenCalledTimes(5); }); + it("restores the pre-search pages when a remote sidebar search is cleared", async () => { + const tables = Array.from({ length: 5 }, (_, index) => ({ + name: `t_${String(index + 1).padStart(4, "0")}`, + table_type: "TABLE" as const, + comment: null, + })); + const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number, offset?: number) => { + const matches = searchFilter ? tables.filter((table) => table.name.includes(searchFilter)) : tables; + const start = offset ?? 0; + return Promise.resolve(matches.slice(start, start + (limit ?? matches.length))); + }); + + 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 = 2; + + const connection = mysqlConnection(); + const tablesGroup: TreeNode = { + id: "mysql-1:app:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [], + }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: "mysql-1:app", + label: "app", + type: "database", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [tablesGroup], + }, + ], + }, + ]; + + // The user expands the group and pages through "load more" until every table is in memory. + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + while (tablesGroup.children?.some((child) => child.type === "load-more")) { + await store.loadMoreObjectGroupChildren(tablesGroup.children.find((child) => child.type === "load-more")!); + } + expect(tablesGroup.children?.map((child) => child.label)).toEqual(tables.map((table) => table.name)); + expect(tablesGroup.objectCount).toBe(5); + const unfilteredPageOneCalls = listTables.mock.calls.filter((call) => !call[3] && (call[5] ?? 0) === 0).length; + + // Typing a search swaps the children for a filtered projection. + store.sidebarSearchQuery = "0003"; + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + expect(tablesGroup.children?.map((child) => child.label)).toEqual(["t_0003"]); + + // Clearing the query must put the previously loaded pages back, without + // dropping the user back to the first page through a fresh reload. + store.sidebarSearchQuery = ""; + expect(store.restoreFilteredObjectGroupChildren(tablesGroup)).toBe(true); + expect(tablesGroup.children?.map((child) => child.label)).toEqual(tables.map((table) => table.name)); + expect(tablesGroup.objectCount).toBe(5); + expect(tablesGroup.isExpanded).toBe(true); + expect(store.isTreeNodeChildrenLoaded(tablesGroup.id)).toBe(true); + expect(listTables.mock.calls.filter((call) => !call[3] && (call[5] ?? 0) === 0).length).toBe(unfilteredPageOneCalls); + + // With no captured list (a group that was never loaded) the caller falls back to a reload. + const untouchedGroup: TreeNode = { ...tablesGroup, id: "mysql-1:app:__views", type: "group-views", children: [] }; + expect(store.restoreFilteredObjectGroupChildren(untouchedGroup)).toBe(false); + }); + it("pages table-scoped search results instead of truncating at the search budget", async () => { const tables = Array.from({ length: 5 }, (_, 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 871b0a30f2..fcc0f34943 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -662,6 +662,11 @@ export const useConnectionStore = defineStore("connection", () => { const tableListSourceRevisions = new Map(); const treeNodeLoads = new TreeNodeLoadRegistry(); const filteredObjectGroupChildrenIds = new Set(); + // A remote sidebar search swaps an object group's children for a filtered + // projection. The pre-search children — including extra pages the user loaded + // through "load more" — are captured here so clearing the query restores the + // browsing state instead of silently dropping those pages back to page one. + const filteredObjectGroupChildrenSnapshots = new Map(); const primaryVisibleObjectRefreshInFlight = new Set(); let nextLocalConnectionAttempt = 0; let beforeConnectHandler: BeforeConnectHandler | null = null; @@ -1743,9 +1748,16 @@ export const useConnectionStore = defineStore("connection", () => { } } + // Forget both the filtered marker and its captured pre-search children, so a + // later restore cannot resurrect a stale projection or an outdated list. + function forgetFilteredObjectGroupChildren(nodeId: string) { + filteredObjectGroupChildrenIds.delete(nodeId); + filteredObjectGroupChildrenSnapshots.delete(nodeId); + } + /** Drop loaded/confirmed-empty markers, metadata caches, and generations for a discarded shell. */ function forgetTreeNodeLoadState(nodeId: string) { - filteredObjectGroupChildrenIds.delete(nodeId); + forgetFilteredObjectGroupChildren(nodeId); clearLoadedChildrenCache(nodeId); treeNodeLoads.invalidatePrefix(nodeId); } @@ -3306,6 +3318,25 @@ export const useConnectionStore = defineStore("connection", () => { return true; } + // Remote sidebar search is a temporary projection over an object group. When the + // query is cleared, put the captured pre-search children back — including the + // pages loaded through "load more" — instead of refetching page one. Returns + // false when nothing was captured (the group was never loaded before the + // search), so the caller can fall back to a normal load. + function restoreFilteredObjectGroupChildren(node: TreeNode): boolean { + const snapshot = filteredObjectGroupChildrenSnapshots.get(node.id); + const liveNode = treeNodeInSidebarTree(node); + if (!snapshot || !liveNode) { + if (!liveNode) forgetFilteredObjectGroupChildren(node.id); + return false; + } + setChildren(liveNode, snapshot.children); + liveNode.objectCount = snapshot.objectCount; + liveNode.isExpanded = true; + forgetFilteredObjectGroupChildren(liveNode.id); + return true; + } + function treeNodeInSidebarTree(node: TreeNode): TreeNode | null { return findNode(treeNodes.value, node.id); } @@ -3354,6 +3385,11 @@ export const useConnectionStore = defineStore("connection", () => { filteredObjectGroupChildrenIds.delete(id); } } + for (const id of filteredObjectGroupChildrenSnapshots.keys()) { + if (id === prefix || id.startsWith(`${prefix}:`)) { + filteredObjectGroupChildrenSnapshots.delete(id); + } + } invalidateMetadataCachesByTreePrefix(prefix); if (options?.deletePersisted === false) return; const rawPrefix = `${prefix}:`; @@ -6125,7 +6161,7 @@ export const useConnectionStore = defineStore("connection", () => { }); if (!options?.force && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) { if (await hydrateTreeNodeFromCache(node, objectGroupCacheKey(node))) { - filteredObjectGroupChildrenIds.delete(node.id); + forgetFilteredObjectGroupChildren(node.id); void loadObjectGroupChildren(node, { ...options, force: true }).catch(() => undefined); return; } @@ -6152,7 +6188,7 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(node.connectionId); load = reclaimTreeNodeLoad(load, node); if (useCachedChildren(node, options, load)) { - filteredObjectGroupChildrenIds.delete(node.id); + forgetFilteredObjectGroupChildren(node.id); return; } const objectTypes = objectTypesForGroupNode(node.type); @@ -6175,7 +6211,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!options?.force && !searchFilter && !tableNameFilter) { const cached = await loadPersistedTreeChildren(node, cacheKey, load); if (cached.hit) { - filteredObjectGroupChildrenIds.delete(node.id); + forgetFilteredObjectGroupChildren(node.id); if (cached.isStale) refreshStaleTreeNode(node); return; } @@ -6219,10 +6255,18 @@ export const useConnectionStore = defineStore("connection", () => { if (!tableNameFilterRevisionMatches(options)) return; const targetNode = treeNodeLoadTarget(load); if (!targetNode) return; + if (searchFilter && !filteredObjectGroupChildrenIds.has(targetNode.id) && isTreeNodeChildrenLoaded(targetNode.id)) { + // Capture the first transition into a filtered projection only; later + // keystrokes re-filter the same group and must keep the original list. + filteredObjectGroupChildrenSnapshots.set(targetNode.id, { + children: targetNode.children ?? [], + objectCount: targetNode.objectCount, + }); + } targetNode.objectCount = nextObjectCount; setChildren(targetNode, children); if (searchFilter) filteredObjectGroupChildrenIds.add(targetNode.id); - else filteredObjectGroupChildrenIds.delete(targetNode.id); + else forgetFilteredObjectGroupChildren(targetNode.id); options?.onChildrenApplied?.(targetNode); if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); @@ -9615,6 +9659,7 @@ export const useConnectionStore = defineStore("connection", () => { canUseLoadedTreeNodeToggle, releaseCollapsedTreeNodeChildren, discardFilteredTreeNodeChildren, + restoreFilteredObjectGroupChildren, cancelTreeNodeLoad, setBeforeConnectHandler, initFromDisk, diff --git a/docs/screenshots/issue-9089-after-cleared-1201.png b/docs/screenshots/issue-9089-after-cleared-1201.png new file mode 100644 index 0000000000..3311b6fb95 Binary files /dev/null and b/docs/screenshots/issue-9089-after-cleared-1201.png differ diff --git a/docs/screenshots/issue-9089-before-cleared-1000.png b/docs/screenshots/issue-9089-before-cleared-1000.png new file mode 100644 index 0000000000..f4b2f66922 Binary files /dev/null and b/docs/screenshots/issue-9089-before-cleared-1000.png differ diff --git a/docs/screenshots/issue-9089-before-search-t_1150.png b/docs/screenshots/issue-9089-before-search-t_1150.png new file mode 100644 index 0000000000..7b12143102 Binary files /dev/null and b/docs/screenshots/issue-9089-before-search-t_1150.png differ