fix(sidebar): support collapsed-node global search

This commit is contained in:
zipg authored and GitHub committed 2026-09-02 00:27:44 +08:00
1 parent bd7ed63468
commit e9f2cc15b7
3 files changed
+121 -30

No files matched your search

@@ -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<void>[] = [];
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<TreeNodeType>([
"group-types",
]);
const simpleObjectParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema"]);
const simpleObjectChildTypes = new Set<TreeNodeType>(["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<void>[], refreshedNodeIds?: Set<string>, 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<string>();
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<string>, preservesNodeSubtree?: (node: TreeNode) => boolean, ancestorPreservesSearchSubtree = false, scheduledNodeIds?: Set<string>) {
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<void>
// 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<void>[] {
function restoreTrackedSearchTargets(): SidebarSearchTask[] {
if (!searchExpansionState.hasTrackedNodes()) return [];
const tasks: Promise<void>[] = [];
const tasks: SidebarSearchTask[] = [];
for (const root of store.treeNodes) {
collectExpandedObjectSearchTargets(root, tasks);
}
@@ -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<string>();");
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);
});
});
@@ -0,0 +1,18 @@
export type SidebarSearchTask = () => Promise<void>;
export const SIDEBAR_SEARCH_TASK_CONCURRENCY = 4;
export async function runSidebarSearchTasks(tasks: readonly SidebarSearchTask[], concurrency = SIDEBAR_SEARCH_TASK_CONCURRENCY): Promise<void> {
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);
}