From 2faa755bfe07a0b117ae9f89f479f6d4ce4902b4 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 13 Aug 2026 07:52:59 +0000 Subject: [PATCH] fix(sidebar): restore complete object list after search Closes #6006 --- .../connectionStore.metadataLoading.spec.ts | 100 ++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 56 ++++++++-- 2 files changed, 145 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 708412d728..16776cfd25 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -2960,6 +2960,106 @@ describe("connectionStore metadata loading", () => { expect(tablesGroup.children?.some((child) => child.label?.startsWith("fresh_"))).toBe(true); }); + it("restores and drains unfiltered pages when expand-all races a search clear reload", async () => { + const tables = Array.from({ length: 5 }, (_, index) => ({ + name: `t_${String(index + 1).padStart(4, "0")}`, + table_type: "TABLE" as const, + comment: null, + })); + 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))); + }, + ); + + 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), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: 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], + }, + ], + }, + ]; + + store.sidebarSearchQuery = "0003"; + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + expect(tablesGroup.children?.map((child) => child.label)).toEqual(["t_0003"]); + expect(store.isTreeNodeChildrenLoaded(tablesGroup.id)).toBe(true); + + store.sidebarSearchQuery = ""; + const searchClearReload = store.loadObjectGroupChildren(tablesGroup, { force: true }); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(2)); + + await store.loadAllObjectGroupChildren(tablesGroup); + resolveSearchClearReload(tables.slice(0, 3)); + await searchClearReload; + + expect(tablesGroup.children?.map((child) => child.label)).toEqual(tables.map((table) => table.name)); + expect(tablesGroup.children?.some((child) => child.type === "load-more")).toBe(false); + expect(tablesGroup.objectCount).toBe(5); + expect(tablesGroup.isLoading).toBe(false); + expect(listTables.mock.calls.map((call) => [call[3], call[4], call[5]])).toEqual([ + ["0003", 2, undefined], + [undefined, 3, 0], + [undefined, 3, 0], + [undefined, 3, 2], + [undefined, 3, 4], + ]); + + await store.loadAllObjectGroupChildren(tablesGroup); + expect(listTables).toHaveBeenCalledTimes(5); + }); + 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 04849b9e03..9a2ee9df52 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -294,6 +294,10 @@ interface LoadTreeOptions { connectedOnly?: boolean; expectedSidebarSearchQuery?: string; searchFilter?: string; + // Explicit actions can load the unfiltered backing group while the global search + // continues to control presentation; normal watcher refreshes still reject mismatches. + allowGlobalSearchMismatch?: boolean; + onChildrenApplied?: (node: TreeNode) => void; sidebarTableSearchParentId?: string; expectedSidebarTableSearchQuery?: string; tableNameFilterScopeKey?: string; @@ -475,6 +479,7 @@ export const useConnectionStore = defineStore("connection", () => { const connectionStateRevisions = new Map(); const connectionErrorRevisions = new Map(); const treeNodeLoads = new TreeNodeLoadRegistry(); + const filteredObjectGroupChildrenIds = new Set(); const primaryVisibleObjectRefreshInFlight = new Set(); let nextLocalConnectionAttempt = 0; let beforeConnectHandler: BeforeConnectHandler | null = null; @@ -1237,6 +1242,7 @@ export const useConnectionStore = defineStore("connection", () => { /** Drop loaded/confirmed-empty markers, metadata caches, and generations for a discarded shell. */ function forgetTreeNodeLoadState(nodeId: string) { + filteredObjectGroupChildrenIds.delete(nodeId); clearLoadedChildrenCache(nodeId); treeNodeLoads.invalidatePrefix(nodeId); } @@ -2170,6 +2176,7 @@ export const useConnectionStore = defineStore("connection", () => { function isTreeLoadSearchChanged(searchFilter: string, options?: LoadTreeOptions): boolean { if (options?.sidebarTableSearchParentId) return isSidebarTableSearchQueryChanged(options); + if (options?.allowGlobalSearchMismatch) return false; return (sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options); } @@ -2262,6 +2269,11 @@ export const useConnectionStore = defineStore("connection", () => { confirmedEmptyTreeNodeIds.value.delete(id); } } + for (const id of filteredObjectGroupChildrenIds) { + if (id === prefix || id.startsWith(`${prefix}:`)) { + filteredObjectGroupChildrenIds.delete(id); + } + } invalidateMetadataCachesByTreePrefix(prefix); if (options?.deletePersisted === false) return; const rawPrefix = `${prefix}:`; @@ -4534,6 +4546,7 @@ export const useConnectionStore = defineStore("connection", () => { }); if (!options?.force && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) { if (await hydrateTreeNodeFromCache(node, objectGroupCacheKey(node))) { + filteredObjectGroupChildrenIds.delete(node.id); void loadObjectGroupChildren(node, { ...options, force: true }).catch(() => undefined); return; } @@ -4559,7 +4572,10 @@ export const useConnectionStore = defineStore("connection", () => { try { await ensureConnected(node.connectionId); load = reclaimTreeNodeLoad(load, node); - if (useCachedChildren(node, options, load)) return; + if (useCachedChildren(node, options, load)) { + filteredObjectGroupChildrenIds.delete(node.id); + return; + } const objectTypes = objectTypesForGroupNode(node.type); const parentNodeId = objectGroupRefreshParentId(node); if (!objectTypes || !parentNodeId) return; @@ -4580,6 +4596,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); if (cached.isStale) refreshStaleTreeNode(node); return; } @@ -4624,6 +4641,9 @@ export const useConnectionStore = defineStore("connection", () => { if (!targetNode) return; targetNode.objectCount = nextObjectCount; setChildren(targetNode, children); + if (searchFilter) filteredObjectGroupChildrenIds.add(targetNode.id); + else filteredObjectGroupChildrenIds.delete(targetNode.id); + options?.onChildrenApplied?.(targetNode); if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) { await savePersistedTreeChildren(cacheKey, children); } @@ -4664,7 +4684,7 @@ export const useConnectionStore = defineStore("connection", () => { } } - async function loadMoreObjectGroupChildren(node: TreeNode) { + async function loadMoreObjectGroupChildren(node: TreeNode, options?: { searchFilter?: string }) { if (node.type !== "load-more" || !node.loadMore) return; const loadMore = node.loadMore; const parent = findNode(treeNodes.value, node.loadMore.parentId); @@ -4743,6 +4763,7 @@ export const useConnectionStore = defineStore("connection", () => { objectTypes, offset: loadMore.offset, pageSize: loadMore.pageSize, + searchFilter: options?.searchFilter, force: false, }); const targetParent = treeNodeLoadRelatedTarget(load, parent); @@ -4759,6 +4780,7 @@ export const useConnectionStore = defineStore("connection", () => { objectTypes, offset: loadMore.offset, pageSize: loadMore.pageSize, + searchFilter: options?.searchFilter, force: false, }); const targetParent = treeNodeLoadRelatedTarget(load, parent); @@ -4929,20 +4951,32 @@ export const useConnectionStore = defineStore("connection", () => { async function loadAllObjectGroupChildren(parent: TreeNode) { if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent)) return; if (!objectTypesForGroupNode(parent.type)) return; - let load = beginTreeNodeLoad(parent); + const liveParent = treeNodeInSidebarTree(parent); + if (!liveParent) return; + if (!isTreeNodeChildrenLoaded(liveParent.id) || filteredObjectGroupChildrenIds.has(liveParent.id)) { + let isPreparedPageCurrent: (() => boolean) | undefined; + await loadObjectGroupChildren(liveParent, { + force: true, + searchFilter: "", + allowGlobalSearchMismatch: true, + onChildrenApplied: (targetNode) => { + const epoch = treeNodeLoads.observe(targetNode.id); + isPreparedPageCurrent = () => epoch.isCurrent(); + }, + }); + if (!isPreparedPageCurrent?.()) return; + } + + const refreshedParent = treeNodeInSidebarTree(parent); + if (!refreshedParent) return; + const load = beginTreeNodeLoad(refreshedParent); try { - await ensureConnected(parent.connectionId); - load = reclaimTreeNodeLoad(load, parent); const liveParent = treeNodeLoadTarget(load); if (!liveParent) return; - if (!isTreeNodeChildrenLoaded(liveParent.id)) { - await loadObjectGroupChildren(liveParent); - } - if (!load.isCurrent()) return; let loadMoreNode = findTreeNodes(liveParent.children ?? [], (child) => child.type === "load-more")[0]; while (loadMoreNode?.loadMore) { - await loadMoreObjectGroupChildren(loadMoreNode); + await loadMoreObjectGroupChildren(loadMoreNode, { searchFilter: "" }); if (!load.isCurrent()) return; const currentParent = treeNodeLoadTarget(load); if (!currentParent) return; @@ -4951,7 +4985,7 @@ export const useConnectionStore = defineStore("connection", () => { const finishedParent = treeNodeLoadTarget(load); if (finishedParent) finishedParent.isExpanded = true; } catch (e) { - recordMetadataLoadError(parent.connectionId, e, load); + recordMetadataLoadError(refreshedParent.connectionId!, e, load); throw e; } finally { finishTreeNodeLoad(load);