diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index b042bd0cd7..dddfaea8e6 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -21,6 +21,7 @@ import { } from "@/lib/sidebar/sidebarSearchTree"; import { createSidebarLabelMatcher, matchSidebarLabel } from "@/lib/sidebar/sidebarSearch"; import { collectSidebarRegexIndexScopes, resolveSidebarRemoteSearchQuery, resolveSidebarSearchDispatchMode } from "@/lib/sidebar/sidebarRegexSearchIndex"; +import { createSidebarSearchExpansionState } from "@/lib/sidebar/sidebarSearchExpansionState"; import { buildTableTreeNodes } from "@/lib/table/tableTree"; import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts"; import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut"; @@ -136,7 +137,13 @@ const sidebarObjectSourceFormatDialect = computed(() => sqlFormatDialectForDbTyp type SearchScope = "connection" | "database" | "schema" | "table" | "view"; const selectedSearchScopes = ref([]); const searchCollapsedIds = ref>(new Set()); -const searchRefreshedNodeIds = new Set(); +const searchExpansionState = createSidebarSearchExpansionState(); +const searchRefreshedNodeIds = searchExpansionState.filteredNodeIds; +// Group nodes that search force-expanded on the user's behalf (they were +// collapsed before the query started). Cleared alongside searchRefreshedNodeIds +// so clearing the search box collapses them back instead of leaving every +// touched group open and reloading it again. +const searchAutoExpandedNodeIds = searchExpansionState.autoExpandedNodeIds; let searchTimer: number | undefined; const tableSearchTimers = new Map(); const tableSearchFocusRestoreTokens = new Map(); @@ -253,11 +260,21 @@ watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, was if (dispatchMode === "regex") { // Regex search is a read-only projection over live nodes and the local // table index. It must never trigger ensureConnected/listTables. - void loadRegexTableSearchIndexes(); + const restoreTasks = restoreTrackedSearchTargets(); + void Promise.all([loadRegexTableSearchIndexes(), ...restoreTasks]) + .then(() => { + if (restoreTasks.length > 0) refreshActiveSidebarTableSearches(); + }) + .catch(() => {}); return; } if (dispatchMode === "none") { - if (!wasRegexMode && !newQuery && oldQuery) searchRefreshedNodeIds.clear(); + const restoreTasks = restoreTrackedSearchTargets(); + if (restoreTasks.length > 0) { + void Promise.all(restoreTasks) + .then(() => refreshActiveSidebarTableSearches()) + .catch(() => {}); + } return; } const tasks: Promise[] = []; @@ -266,7 +283,7 @@ watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, was collectExpandedObjectSearchTargets(root, tasks, newQuery ? searchRefreshedNodeIds : undefined, preservesSearchSubtree); } if (!newQuery && oldQuery) { - searchRefreshedNodeIds.clear(); + searchExpansionState.clear(); } Promise.all(tasks) .then(() => { @@ -306,18 +323,25 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise for (const child of node.children) { if (child.connectionId && searchableObjectGroupTypes.has(child.type)) { if (preservesSearchSubtree) { - if (refreshedNodeIds.delete(child.id)) { + if (searchExpansionState.markUnfiltered(child.id)) { tasks.push(store.loadObjectGroupChildren(child, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); } } else { - refreshedNodeIds.add(child.id); + searchExpansionState.markFiltered(child.id, !child.isExpanded); tasks.push(store.loadObjectGroupChildren(child, { force: true })); } } } - } else if (!refreshedNodeIds && searchRefreshedNodeIds.has(node.id)) { + } else if (!refreshedNodeIds && searchExpansionState.shouldRestore(node.id)) { if (searchableObjectGroupTypes.has(node.type)) { - tasks.push(store.loadObjectGroupChildren(node, { force: true })); + 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. + node.isExpanded = false; + } else { + tasks.push(store.loadObjectGroupChildren(node, { force: true })); + } } else if (simpleObjectParentTypes.has(node.type)) { tasks.push(store.refreshTreeNode(node)); } @@ -329,6 +353,16 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise } } +function restoreTrackedSearchTargets(): Promise[] { + if (!searchExpansionState.hasTrackedNodes()) return []; + const tasks: Promise[] = []; + for (const root of store.treeNodes) { + collectExpandedObjectSearchTargets(root, tasks); + } + searchExpansionState.clear(); + return tasks; +} + const sidebarFilterGuards = computed(() => resolveSidebarFilterGuards(showConnectedConnectionsOnly.value, searchQuery.value, hasSearchScopeFilter.value)); // Connected-only filtering changes only root visibility, so descendant-local // features stay available while operations requiring the full root list pause. diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarSearchExpansionState.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarSearchExpansionState.spec.ts new file mode 100644 index 0000000000..34cc6d8913 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarSearchExpansionState.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { createSidebarSearchExpansionState } from "@/lib/sidebar/sidebarSearchExpansionState"; + +describe("sidebar search expansion state", () => { + it("keeps auto-expanded ownership after a filtered group becomes an unfiltered ancestor match", () => { + const state = createSidebarSearchExpansionState(); + + state.markFiltered("group-tables", true); + expect(state.markUnfiltered("group-tables")).toBe(true); + + expect(state.filteredNodeIds.has("group-tables")).toBe(false); + expect(state.autoExpandedNodeIds.has("group-tables")).toBe(true); + expect(state.shouldRestore("group-tables")).toBe(true); + }); + + it("clears tracked ownership when ordinary search exits through another mode", () => { + const state = createSidebarSearchExpansionState(); + state.markFiltered("group-tables", true); + + state.clear(); + + expect(state.hasTrackedNodes()).toBe(false); + expect(state.shouldRestore("group-tables")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarSearchExpansionState.ts b/apps/desktop/src/lib/sidebar/sidebarSearchExpansionState.ts new file mode 100644 index 0000000000..afb114bc48 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/sidebarSearchExpansionState.ts @@ -0,0 +1,36 @@ +export interface SidebarSearchExpansionState { + readonly filteredNodeIds: Set; + readonly autoExpandedNodeIds: Set; + markFiltered(nodeId: string, autoExpanded: boolean): void; + markUnfiltered(nodeId: string): boolean; + shouldRestore(nodeId: string): boolean; + hasTrackedNodes(): boolean; + clear(): void; +} + +export function createSidebarSearchExpansionState(): SidebarSearchExpansionState { + const filteredNodeIds = new Set(); + const autoExpandedNodeIds = new Set(); + + return { + filteredNodeIds, + autoExpandedNodeIds, + markFiltered(nodeId, autoExpanded) { + filteredNodeIds.add(nodeId); + if (autoExpanded) autoExpandedNodeIds.add(nodeId); + }, + markUnfiltered(nodeId) { + return filteredNodeIds.delete(nodeId); + }, + shouldRestore(nodeId) { + return filteredNodeIds.has(nodeId) || autoExpandedNodeIds.has(nodeId); + }, + hasTrackedNodes() { + return filteredNodeIds.size > 0 || autoExpandedNodeIds.size > 0; + }, + clear() { + filteredNodeIds.clear(); + autoExpandedNodeIds.clear(); + }, + }; +}