diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index b41f0c3b1b..690308159d 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -56,6 +56,7 @@ import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntim import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry"; import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl"; import { createSidebarTableSearchDebouncer, invalidateSidebarTableSearchBuild, loadOrBuildSidebarTableSearchIndex, scheduleExclusiveSidebarTableSearchDebounce } from "@/lib/sidebar/sidebarTableSearchIndex"; +import { runSidebarSearchTasks, type SidebarSearchTask } from "./sidebarSearchTaskRunner"; import TreeItem from "./TreeItem.vue"; import ActiveConnectionFilterButton from "./ActiveConnectionFilterButton.vue"; import SidebarListOptionsIcon from "./SidebarListOptionsIcon.vue"; @@ -297,7 +298,7 @@ watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, was const restoreTasks = restoreTrackedSearchTargets(); const searchGeneration = sidebarSearchLoadingTracker.begin(); isSidebarSearchLoading.value = true; - void Promise.allSettled([loadRegexTableSearchIndexes(), ...restoreTasks]) + void Promise.allSettled([loadRegexTableSearchIndexes(), runSidebarSearchTasks(restoreTasks)]) .then(() => { if (restoreTasks.length > 0) refreshActiveSidebarTableSearches(); }) @@ -311,29 +312,24 @@ watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, was isSidebarSearchLoading.value = false; const restoreTasks = restoreTrackedSearchTargets(); if (restoreTasks.length > 0) { - void Promise.all(restoreTasks) + void runSidebarSearchTasks(restoreTasks) .then(() => refreshActiveSidebarTableSearches()) .catch(() => {}); } return; } - const tasks: Promise[] = []; const preservesSearchSubtree = newQuery ? createSidebarSearchSubtreePreserver(newQuery, searchableNodeTypes.value) : undefined; - for (const root of store.treeNodes) { - collectExpandedObjectSearchTargets(root, tasks, newQuery ? searchRefreshedNodeIds : undefined, preservesSearchSubtree); - } - if (!newQuery && oldQuery) { - searchExpansionState.clear(); - } + const restoreTasks = !newQuery && oldQuery ? restoreTrackedSearchTargets() : []; let searchGeneration = -1; - if (newQuery && tasks.length > 0) { + if (newQuery) { searchGeneration = sidebarSearchLoadingTracker.begin(); isSidebarSearchLoading.value = true; } else { sidebarSearchLoadingTracker.cancel(); isSidebarSearchLoading.value = false; } - void Promise.allSettled(tasks) + const searchWork = newQuery ? loadSidebarSearchTargets(newQuery, preservesSearchSubtree) : runSidebarSearchTasks(restoreTasks); + void searchWork .then(() => { if (!newQuery && oldQuery) refreshActiveSidebarTableSearches(); }) @@ -358,41 +354,74 @@ const searchableObjectGroupTypes = new Set([ "group-types", ]); const simpleObjectParentTypes = new Set(["database", "schema", "linked-server-schema"]); -const simpleObjectChildTypes = new Set(["table", "view", "materialized_view", "procedure", "function", "trigger", "event", "sequence", "synonym", "job", "package", "package-body", "type", "type-body", "load-more"]); 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()); + return settingsStore.editorSettings.sidebarObjectDisplay === "simple" && simpleObjectParentTypes.has(node.type) && node.connectionId != null && node.database != null; } -function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise[], refreshedNodeIds?: Set, preservesNodeSubtree?: (node: TreeNode) => boolean, ancestorPreservesSearchSubtree = false) { +function isSidebarSearchContainer(node: TreeNode): boolean { + return simpleObjectParentTypes.has(node.type) && node.connectionId != null && node.database != null; +} + +async function loadSidebarSearchTargets(query: string, preservesNodeSubtree?: (node: TreeNode) => boolean) { + if (!query) return; + const refreshedNodeIds = searchRefreshedNodeIds; + const scheduledNodeIds = new Set(); + let tasks: SidebarSearchTask[] = []; + do { + tasks = []; + for (const root of store.treeNodes) { + collectExpandedObjectSearchTargets(root, tasks, refreshedNodeIds, preservesNodeSubtree, false, scheduledNodeIds); + } + if (tasks.length === 0) return; + await runSidebarSearchTasks(tasks); + } while (deferredSearchQuery.value === query && store.sidebarSearchQuery === query); +} + +function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearchTask[], refreshedNodeIds?: Set, preservesNodeSubtree?: (node: TreeNode) => boolean, ancestorPreservesSearchSubtree = false, scheduledNodeIds?: Set) { 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)); + if (store.connectedIds.has(node.connectionId) && (!scheduledNodeIds || !scheduledNodeIds.has(node.id))) { + const connectionId = node.connectionId; + scheduledNodeIds?.add(node.id); + tasks.push(() => store.loadConnectedConnectionRootForSidebarSearch(connectionId)); } if (node.connectionId !== store.activeConnectionId) return; } if (refreshedNodeIds && isSimpleObjectSearchParent(node)) { - if (preservesSearchSubtree) { - if (refreshedNodeIds.delete(node.id)) { - tasks.push(store.loadTreeNodeChildren(node, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); + if (!scheduledNodeIds || !scheduledNodeIds.has(node.id)) { + scheduledNodeIds?.add(node.id); + if (preservesSearchSubtree) { + if (refreshedNodeIds.delete(node.id)) { + tasks.push(() => store.loadTreeNodeChildren(node, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); + } + } else { + const wasCollapsed = node.isExpanded !== true; + refreshedNodeIds.add(node.id); + if (wasCollapsed) searchExpansionState.markFiltered(node.id, true); + tasks.push(() => store.refreshTreeNode(node)); } - } else { - refreshedNodeIds.add(node.id); - tasks.push(store.refreshTreeNode(node)); } return; } - if (refreshedNodeIds && node.isExpanded && node.children) { + if (refreshedNodeIds && isSidebarSearchContainer(node) && !node.children?.length && (!scheduledNodeIds || !scheduledNodeIds.has(node.id))) { + scheduledNodeIds?.add(node.id); + const wasCollapsed = node.isExpanded !== true; + searchExpansionState.markFiltered(node.id, wasCollapsed); + tasks.push(() => store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); + } + if (refreshedNodeIds && node.children) { for (const child of node.children) { if (child.connectionId && searchableObjectGroupTypes.has(child.type)) { + if (scheduledNodeIds?.has(child.id)) continue; + scheduledNodeIds?.add(child.id); if (preservesSearchSubtree) { if (searchExpansionState.markUnfiltered(child.id)) { - tasks.push(store.loadObjectGroupChildren(child, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); + tasks.push(() => store.loadObjectGroupChildren(child, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })); } } else { searchExpansionState.markFiltered(child.id, !child.isExpanded); - tasks.push(store.loadObjectGroupChildren(child, { force: true })); + tasks.push(() => store.loadObjectGroupChildren(child, { force: true })); } } } @@ -404,22 +433,26 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise // instead of reloading and leaving it open. node.isExpanded = false; } else { - tasks.push(store.loadObjectGroupChildren(node, { force: true })); + tasks.push(() => store.loadObjectGroupChildren(node, { force: true })); } } else if (simpleObjectParentTypes.has(node.type)) { - tasks.push(store.refreshTreeNode(node)); + const shouldCollapse = searchAutoExpandedNodeIds.has(node.id); + tasks.push(async () => { + await store.refreshTreeNode(node); + if (shouldCollapse) node.isExpanded = false; + }); } } if (node.children) { for (const child of node.children) { - collectExpandedObjectSearchTargets(child, tasks, refreshedNodeIds, preservesNodeSubtree, preservesSearchSubtree); + collectExpandedObjectSearchTargets(child, tasks, refreshedNodeIds, preservesNodeSubtree, preservesSearchSubtree, scheduledNodeIds); } } } -function restoreTrackedSearchTargets(): Promise[] { +function restoreTrackedSearchTargets(): SidebarSearchTask[] { if (!searchExpansionState.hasTrackedNodes()) return []; - const tasks: Promise[] = []; + const tasks: SidebarSearchTask[] = []; for (const root of store.treeNodes) { collectExpandedObjectSearchTargets(root, tasks); } diff --git a/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts new file mode 100644 index 0000000000..181a367bf6 --- /dev/null +++ b/apps/desktop/src/components/sidebar/__tests__/ConnectionTree.collapsedSearch.spec.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { runSidebarSearchTasks } from "../sidebarSearchTaskRunner"; + +const source = readFileSync(new URL("../ConnectionTree.vue", import.meta.url), "utf8"); + +describe("ConnectionTree global search loading", () => { + it("discovers collapsed database and schema containers before searching object groups", () => { + expect(source).toContain("function isSidebarSearchContainer(node: TreeNode)"); + expect(source).toMatch(/isSidebarSearchContainer\(node\) && !node\.children\?\.length/); + expect(source).toContain("store.loadTreeNodeChildren(node, { force: true, expectedSidebarSearchQuery: store.sidebarSearchQuery })"); + expect(source).toContain("searchExpansionState.markFiltered(node.id, wasCollapsed)"); + expect(source).toMatch(/if \(refreshedNodeIds && node\.children\) \{[\s\S]*?searchableObjectGroupTypes\.has\(child\.type\)/); + }); + + it("deduplicates each node within a search round and restores tracked nodes", () => { + expect(source).toContain("const scheduledNodeIds = new Set();"); + 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;"); + }); + + it("limits concurrent metadata loads without dropping a task", async () => { + let activeTasks = 0; + let maximumActiveTasks = 0; + let completedTasks = 0; + const tasks = Array.from({ length: 8 }, () => async () => { + activeTasks += 1; + maximumActiveTasks = Math.max(maximumActiveTasks, activeTasks); + await new Promise((resolve) => setTimeout(resolve, 0)); + activeTasks -= 1; + completedTasks += 1; + }); + + await runSidebarSearchTasks(tasks, 2); + + expect(maximumActiveTasks).toBe(2); + expect(completedTasks).toBe(tasks.length); + }); +}); diff --git a/apps/desktop/src/components/sidebar/sidebarSearchTaskRunner.ts b/apps/desktop/src/components/sidebar/sidebarSearchTaskRunner.ts new file mode 100644 index 0000000000..5e79e5e0fd --- /dev/null +++ b/apps/desktop/src/components/sidebar/sidebarSearchTaskRunner.ts @@ -0,0 +1,18 @@ +export type SidebarSearchTask = () => Promise; + +export const SIDEBAR_SEARCH_TASK_CONCURRENCY = 4; + +export async function runSidebarSearchTasks(tasks: readonly SidebarSearchTask[], concurrency = SIDEBAR_SEARCH_TASK_CONCURRENCY): Promise { + if (tasks.length === 0) return; + const workerCount = Math.min(Math.max(1, concurrency), tasks.length); + let nextTaskIndex = 0; + const workers = Array.from({ length: workerCount }, async () => { + while (nextTaskIndex < tasks.length) { + const task = tasks[nextTaskIndex++]; + try { + await task(); + } catch {} + } + }); + await Promise.all(workers); +}