fix(sidebar): support collapsed-node global search
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
+40
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user