fix(sidebar): preserve objects for ancestor search matches

Closes #5613
This commit is contained in:
t8y2 committed 2026-08-13 12:15:46 +00:00
1 parent cce36ae058
commit e0a7bc996d
9 files changed
+282 -41

No files matched your search

@@ -8,7 +8,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import type { ObjectSourceKind, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards, reuseLiveSidebarTreeNodes } from "@/lib/sidebar/sidebarSearchTree";
import { createSidebarSearchSubtreePreserver, filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards, resolveSidebarObjectSearchFilter, reuseLiveSidebarTreeNodes } from "@/lib/sidebar/sidebarSearchTree";
import { matchSidebarLabel } from "@/lib/sidebar/sidebarSearch";
import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
@@ -229,8 +229,9 @@ watch(deferredSearchQuery, (newQuery, oldQuery) => {
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);
collectExpandedObjectSearchTargets(root, tasks, newQuery ? searchRefreshedNodeIds : undefined, preservesSearchSubtree);
}
if (!newQuery && oldQuery) {
searchRefreshedNodeIds.clear();
@@ -250,7 +251,8 @@ 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());
}
function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise<void>[], refreshedNodeIds?: Set<string>) {
function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise<void>[], refreshedNodeIds?: Set<string>, preservesNodeSubtree?: (node: TreeNode) => boolean, ancestorPreservesSearchSubtree = false) {
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));
@@ -258,15 +260,27 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise<void>
if (node.connectionId !== store.activeConnectionId) return;
}
if (refreshedNodeIds && isSimpleObjectSearchParent(node)) {
refreshedNodeIds.add(node.id);
tasks.push(store.refreshTreeNode(node));
if (preservesSearchSubtree) {
if (refreshedNodeIds.delete(node.id)) {
tasks.push(store.loadTreeNodeChildren(node, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery }));
}
} else {
refreshedNodeIds.add(node.id);
tasks.push(store.refreshTreeNode(node));
}
return;
}
if (refreshedNodeIds && node.isExpanded && node.children) {
for (const child of node.children) {
if (child.connectionId && searchableObjectGroupTypes.has(child.type)) {
refreshedNodeIds.add(child.id);
tasks.push(store.loadObjectGroupChildren(child, { force: true }));
if (preservesSearchSubtree) {
if (refreshedNodeIds.delete(child.id)) {
tasks.push(store.loadObjectGroupChildren(child, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: store.sidebarSearchQuery }));
}
} else {
refreshedNodeIds.add(child.id);
tasks.push(store.loadObjectGroupChildren(child, { force: true }));
}
}
}
} else if (!refreshedNodeIds && searchRefreshedNodeIds.has(node.id)) {
@@ -278,7 +292,7 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise<void>
}
if (node.children) {
for (const child of node.children) {
collectExpandedObjectSearchTargets(child, tasks, refreshedNodeIds);
collectExpandedObjectSearchTargets(child, tasks, refreshedNodeIds, preservesNodeSubtree, preservesSearchSubtree);
}
}
}
@@ -938,6 +952,12 @@ const pasteHandlerRegistry = createSidebarPasteHandlerRegistry();
provide(sidebarTreeContextKey, {
getVisibleNodes: () => selectableVisibleNodes.value,
getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1,
getTreeLoadSearchOptions: (node) => {
const query = deferredSearchQuery.value;
if (!query) return undefined;
const searchFilter = resolveSidebarObjectSearchFilter(store.treeNodes, node.id, query, searchableNodeTypes.value);
return searchFilter ? undefined : { searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: query };
},
setTableSearchQuery: (parentNodeId, query, local) => {
const focusRestore = captureTableSearchFocus(parentNodeId);
latestTableSearchInteractionParentId = parentNodeId;
@@ -566,6 +566,7 @@ async function openDirectNavigationNode(node: TreeNode) {
async function toggle() {
const node = activeNode.value;
const treeLoadSearchOptions = sidebarTreeContext?.getTreeLoadSearchOptions?.(node);
if (isDirectNavigationTreeNode(node.type)) {
try {
await openDirectNavigationNode(node);
@@ -664,7 +665,7 @@ async function toggle() {
return;
}
if (await loadSidebarObjectGroup(node, connectionStore)) {
if (await loadSidebarObjectGroup(node, connectionStore, treeLoadSearchOptions)) {
emitNodeToggled(node, wasExpanded);
return;
}
@@ -697,11 +698,11 @@ async function toggle() {
} else if (config?.db_type === "mq") {
await connectionStore.loadMqTenants(node.connectionId);
} else if (config?.db_type === "nacos") {
await connectionStore.loadNacosNamespaces(node.connectionId);
await connectionStore.loadNacosNamespaces(node.connectionId, treeLoadSearchOptions);
} else if (config?.db_type === "mqtt") {
await connectionStore.loadMqttTopics(node.connectionId);
} else {
await connectionStore.loadDatabases(node.connectionId);
await connectionStore.loadDatabases(node.connectionId, treeLoadSearchOptions);
}
} else if (node.type === "redis-db" && node.connectionId && node.database) {
await connectionStore.ensureConnected(node.connectionId);
@@ -779,30 +780,30 @@ async function toggle() {
}
} else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
if (node.catalog && node.catalog !== "internal") {
await connectionStore.loadDorisCatalogTables(node);
await connectionStore.loadDorisCatalogTables(node, treeLoadSearchOptions);
} else {
const config = connectionStore.getConfig(node.connectionId);
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
if (config?.db_type === "sqlserver") {
await connectionStore.loadSqlServerDatabaseObjects(node.connectionId, node.database);
await connectionStore.loadSqlServerDatabaseObjects(node.connectionId, node.database, treeLoadSearchOptions);
} else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) {
await connectionStore.loadSchemas(node.connectionId, node.database);
await connectionStore.loadSchemas(node.connectionId, node.database, treeLoadSearchOptions);
} else {
await connectionStore.loadTables(node.connectionId, node.database);
await connectionStore.loadTables(node.connectionId, node.database, undefined, treeLoadSearchOptions);
}
}
} else if (node.type === "doris-catalog" && node.connectionId) {
await connectionStore.loadDorisCatalogDatabases(node);
await connectionStore.loadDorisCatalogDatabases(node, treeLoadSearchOptions);
} else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) {
await connectionStore.loadTables(node.connectionId, node.database, node.schema);
await connectionStore.loadTables(node.connectionId, node.database, node.schema, treeLoadSearchOptions);
} else if (node.type === "linked-server-root" && node.connectionId) {
await connectionStore.loadSqlServerLinkedServers(node.connectionId);
await connectionStore.loadSqlServerLinkedServers(node.connectionId, treeLoadSearchOptions);
} else if (node.type === "linked-server" && node.connectionId) {
await connectionStore.loadSqlServerLinkedServerCatalogs(node);
await connectionStore.loadSqlServerLinkedServerCatalogs(node, treeLoadSearchOptions);
} else if (node.type === "linked-server-catalog" && node.connectionId) {
await connectionStore.loadSqlServerLinkedServerSchemas(node);
await connectionStore.loadSqlServerLinkedServerSchemas(node, treeLoadSearchOptions);
} else if (node.type === "linked-server-schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) {
await connectionStore.loadTables(node.connectionId, node.database, node.schema);
await connectionStore.loadTables(node.connectionId, node.database, node.schema, treeLoadSearchOptions);
} else if ((node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId && hasTreeNodeDatabaseContext(node)) {
await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id, node.catalog);
} else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
@@ -1,10 +1,11 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { createApp, defineComponent, h, nextTick, provide, ref, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { TreeNode } from "@/types/database";
import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue";
const connectionStore = {
@@ -16,6 +17,9 @@ const connectionStore = {
loadPackageMembers: vi.fn(async (node: TreeNode) => {
node.isExpanded = true;
}),
loadObjectGroupChildren: vi.fn(async (node: TreeNode) => {
node.isExpanded = true;
}),
};
vi.mock("@/stores/connectionStore", () => ({
@@ -44,6 +48,7 @@ afterEach(() => {
connectionStore.treeNodes = [];
connectionStore.sidebarSearchQuery = "";
vi.clearAllMocks();
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true);
connectionStore.getConfig.mockReturnValue({ db_type: "mysql" });
});
@@ -116,4 +121,47 @@ describe("SidebarTreeRuntimeHost expansion", () => {
expect(packageNode.isExpanded).toBe(true);
expect(toggled).toHaveBeenCalledWith(packageNode, true);
});
it("expands an unloaded object group without leaking a matching connection name", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "mysql",
database: "basic",
isExpanded: false,
children: [],
};
connectionStore.sidebarSearchQuery = "60307";
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(false);
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(
defineComponent({
setup() {
provide(sidebarTreeContextKey, {
getVisibleNodes: () => [group],
getVisibleNodeIndex: () => 0,
getTreeLoadSearchOptions: () => ({ searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: "60307" }),
});
return () => 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 vi.waitFor(() =>
expect(connectionStore.loadObjectGroupChildren).toHaveBeenCalledWith(group, {
searchFilter: "",
allowGlobalSearchMismatch: true,
expectedSidebarSearchQuery: "60307",
}),
);
});
});