From 299c342d6d0ff4c7e55561d16d5f36efc8ae89c1 Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Fri, 4 Sep 2026 12:41:05 +0800 Subject: [PATCH] fix(sidebar): restore table list after remote search --- .../src/components/sidebar/ConnectionTree.vue | 5 +- .../sidebar/SidebarTreeRuntimeHost.vue | 6 ++ .../ConnectionTree.collapsedSearch.spec.ts | 1 + .../SidebarTreeRuntimeHost.expansion.spec.ts | 37 ++++++++++ .../connectionStore.metadataLoading.spec.ts | 70 +++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 20 ++++++ 6 files changed, 137 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 4037c70bcb..6c54e7f121 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -429,9 +429,10 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch if (searchableObjectGroupTypes.has(node.type)) { if (searchAutoExpandedNodeIds.has(node.id)) { // Search opened this group on the user's behalf to check for matches; - // once the query is gone there is nothing to show, so collapse it back - // instead of reloading and leaving it open. + // once the query is gone, drop the filtered projection and collapse it + // back. Its next explicit expansion will load the ordinary first page. node.isExpanded = false; + store.discardFilteredTreeNodeChildren(node.id); } else { tasks.push(() => store.loadObjectGroupChildren(node, { force: true })); } diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index e33b829014..c4c133d9c0 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -715,6 +715,12 @@ async function openDirectNavigationNode(node: TreeNode, requestId: number) { async function toggle(requestId = beginNavigationRequest()) { const node = activeNode.value; + // Remote sidebar search intentionally descends into one active connection. + // Keep that scope aligned even when this toggle is satisfied entirely from + // already-loaded children and therefore performs no connection request. + if (node.connectionId && connectionStore.connectedIds.has(node.connectionId)) { + connectionStore.activeConnectionId = node.connectionId; + } const treeLoadSearchOptions = sidebarTreeContext?.getTreeLoadSearchOptions?.(node); if (isDirectNavigationTreeNode(node.type)) { try { 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 181a367bf6..d87850d699 100644 --- a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts @@ -18,6 +18,7 @@ describe("ConnectionTree global search loading", () => { expect(source).toContain("while (deferredSearchQuery.value === query && store.sidebarSearchQuery === query)"); expect(source).toContain("const restoreTasks = !newQuery && oldQuery ? restoreTrackedSearchTargets() : [];"); expect(source).toContain("if (shouldCollapse) node.isExpanded = false;"); + expect(source).toContain("store.discardFilteredTreeNodeChildren(node.id);"); }); it("limits concurrent metadata loads without dropping a task", async () => { 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 3972d1a9e4..4d31788d14 100644 --- a/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/SidebarTreeRuntimeHost.expansion.spec.ts @@ -11,6 +11,8 @@ import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost. const connectionStore = { treeNodes: [] as TreeNode[], sidebarSearchQuery: "", + activeConnectionId: null as string | null, + connectedIds: new Set(), canUseLoadedTreeNodeToggle: vi.fn(() => true), releaseCollapsedTreeNodeChildren: vi.fn(), getConfig: vi.fn(() => ({ db_type: "mysql", name: "connection" })), @@ -56,12 +58,47 @@ afterEach(() => { document.body.innerHTML = ""; connectionStore.treeNodes = []; connectionStore.sidebarSearchQuery = ""; + connectionStore.activeConnectionId = null; + connectionStore.connectedIds.clear(); vi.clearAllMocks(); connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true); connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection" }); }); describe("SidebarTreeRuntimeHost expansion", () => { + it("activates the owning connection when a cached node toggles locally", async () => { + const group: TreeNode = { + id: "mysql:basic:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: "mysql", + database: "basic", + isExpanded: false, + children: [{ id: "mysql:basic:orders", label: "orders", type: "table", connectionId: "mysql", database: "basic" }], + }; + connectionStore.connectedIds.add("mysql"); + connectionStore.activeConnectionId = "another-connection"; + + const host = ref | null>(null); + const app = createApp( + defineComponent({ + setup: () => () => 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 nextTick(); + + expect(connectionStore.activeConnectionId).toBe("mysql"); + expect(group.isExpanded).toBe(true); + expect(connectionStore.loadObjectGroupChildren).not.toHaveBeenCalled(); + }); + it("publishes a rendered group collapse and synchronizes the live tree", async () => { const liveGroup: TreeNode = { id: "connection:database:__tables", diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 1e942c07d7..54c04f5627 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -3326,6 +3326,76 @@ describe("connectionStore metadata loading", () => { expect(listTables.mock.calls[1].slice(3, 6)).toEqual(["t", 3, 2]); }); + it("discards a collapsed remote-search projection so the next expansion reloads the ordinary page", async () => { + const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number) => { + if (searchFilter) { + return Promise.resolve([{ name: "zz_late_probe", table_type: "TABLE" as const, comment: null }]); + } + return Promise.resolve( + Array.from({ length: Math.min(limit ?? 1001, 1001) }, (_, index) => ({ + name: `t_${String(index + 1).padStart(4, "0")}`, + table_type: "TABLE" as const, + comment: null, + })), + ); + }); + const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix, + 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 = 1000; + + const connection = mysqlConnection(); + const tablesGroup: TreeNode = { + id: "mysql-1:app:__tables", + label: "tree.tables", + type: "group-tables", + connectionId: connection.id, + database: "app", + isExpanded: false, + 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 = "zz_late_probe"; + await store.loadObjectGroupChildren(tablesGroup, { force: true }); + + expect(listTables).toHaveBeenLastCalledWith(connection.id, "app", "app", "zz_late_probe", SIDEBAR_SEARCH_RESULT_BUDGET, undefined, ["TABLE"]); + expect(tablesGroup.children?.map((node) => node.label)).toEqual(["zz_late_probe"]); + expect(tablesGroup.objectCount).toBe(1); + expect(store.canUseLoadedTreeNodeToggle(tablesGroup)).toBe(true); + + tablesGroup.isExpanded = false; + expect(store.discardFilteredTreeNodeChildren(tablesGroup.id)).toBe(true); + expect(tablesGroup.children).toEqual([]); + expect(tablesGroup.objectCount).toBeUndefined(); + expect(store.canUseLoadedTreeNodeToggle(tablesGroup)).toBe(false); + expect(deleteSchemaCachePrefix).not.toHaveBeenCalled(); + + store.sidebarSearchQuery = ""; + await store.loadObjectGroupChildren(tablesGroup); + + expect(listTables).toHaveBeenLastCalledWith(connection.id, "app", "app", undefined, 1001, 0, ["TABLE"]); + expect(tablesGroup.children).toHaveLength(1001); + expect(tablesGroup.children?.at(-1)?.type).toBe("load-more"); + }); + 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 }] : [])); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index e34f4cc391..f0be7f6ed0 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2913,6 +2913,25 @@ export const useConnectionStore = defineStore("connection", () => { return true; } + // Remote sidebar search temporarily replaces an object group's ordinary + // children with a filtered projection. When search expanded a previously + // collapsed group, discard that projection on clear instead of letting the + // loaded marker make the next user expansion reuse incomplete children. + function discardFilteredTreeNodeChildren(nodeId: string): boolean { + if (!filteredObjectGroupChildrenIds.has(nodeId)) return false; + const node = findNode(treeNodes.value, nodeId); + if (!node) { + filteredObjectGroupChildrenIds.delete(nodeId); + return false; + } + clearLoadedChildrenCache(nodeId, { deletePersisted: false }); + treeNodeLoads.invalidatePrefix(nodeId); + node.children = []; + node.objectCount = undefined; + node.isLoading = false; + return true; + } + function treeNodeInSidebarTree(node: TreeNode): TreeNode | null { return findNode(treeNodes.value, node.id); } @@ -8928,6 +8947,7 @@ export const useConnectionStore = defineStore("connection", () => { isTreeNodeChildrenLoaded, canUseLoadedTreeNodeToggle, releaseCollapsedTreeNodeChildren, + discardFilteredTreeNodeChildren, cancelTreeNodeLoad, setBeforeConnectHandler, initFromDisk,