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",
}),
);
});
});
@@ -4,7 +4,13 @@ import { objectTypesForGroupNode } from "@/lib/table/tableTree";
export interface SidebarObjectGroupLoaders {
loadTriggers(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string): Promise<void>;
loadObjectGroupChildren(node: TreeNode): Promise<void>;
loadObjectGroupChildren(node: TreeNode, options?: SidebarObjectGroupLoadOptions): Promise<void>;
}
export interface SidebarObjectGroupLoadOptions {
searchFilter?: string;
allowGlobalSearchMismatch?: boolean;
expectedSidebarSearchQuery?: string;
}
/**
@@ -12,13 +18,13 @@ export interface SidebarObjectGroupLoaders {
* Table trigger groups are special because they are scoped to one table;
* schema-level trigger groups are regular database object groups.
*/
export async function loadSidebarObjectGroup(node: TreeNode, loaders: SidebarObjectGroupLoaders): Promise<boolean> {
export async function loadSidebarObjectGroup(node: TreeNode, loaders: SidebarObjectGroupLoaders, options?: SidebarObjectGroupLoadOptions): Promise<boolean> {
if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
await loaders.loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
return true;
}
if (!objectTypesForGroupNode(node.type)) return false;
await loaders.loadObjectGroupChildren(node);
await loaders.loadObjectGroupChildren(node, options);
return true;
}
@@ -22,6 +22,36 @@ function normalizedLabel(node: TreeNode): string {
return node.label;
}
function findNodePath(nodes: readonly TreeNode[], targetNodeId: string, ancestors: readonly TreeNode[] = []): readonly TreeNode[] | undefined {
for (const node of nodes) {
const path = [...ancestors, node];
if (node.id === targetNodeId) return path;
if (node.children) {
const childPath = findNodePath(node.children, targetNodeId, path);
if (childPath) return childPath;
}
}
return undefined;
}
function nodePreservesSearchSubtree(node: TreeNode, matchLabel: SidebarLabelMatcher, searchableNodeTypes?: ReadonlySet<TreeNodeType>): boolean {
if (searchableNodeTypes && !searchableNodeTypes.has(node.type)) return false;
return preserveMatchedSubtreeTypes.has(node.type) && !!bestMatch(matchLabel, normalizedLabel(node), node.comment, node.searchAliases);
}
export function createSidebarSearchSubtreePreserver(query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>): (node: TreeNode) => boolean {
const matchLabel = query ? createSidebarLabelMatcher(query) : undefined;
return (node) => !!matchLabel && nodePreservesSearchSubtree(node, matchLabel, searchableNodeTypes);
}
export function resolveSidebarObjectSearchFilter(nodes: readonly TreeNode[], targetNodeId: string, query: string, searchableNodeTypes?: ReadonlySet<TreeNodeType>): string {
if (!query) return query;
const matchLabel = createSidebarLabelMatcher(query);
const path = findNodePath(nodes, targetNodeId);
const preservesSubtree = path?.some((node) => nodePreservesSearchSubtree(node, matchLabel, searchableNodeTypes));
return preservesSubtree ? "" : query;
}
export function filterSidebarTree(nodes: TreeNode[], query: string, collapsedIds: ReadonlySet<string>, searchableNodeTypes?: ReadonlySet<TreeNodeType>): TreeNode[] {
const matchLabel = query ? createSidebarLabelMatcher(query) : undefined;
if (!matchLabel && searchableNodeTypes === undefined) return nodes;
@@ -4,6 +4,7 @@ import type { TreeNode } from "@/types/database";
export interface SidebarTreeContext {
getVisibleNodes: () => TreeNode[];
getVisibleNodeIndex: (id: string) => number;
getTreeLoadSearchOptions?: (node: TreeNode) => { searchFilter: string; allowGlobalSearchMismatch: true; expectedSidebarSearchQuery: string } | undefined;
setTableSearchQuery?: (parentNodeId: string, query: string, local: boolean) => void;
refreshTableSearchIndex?: (parentNodeId: string) => void;
registerPasteHandler?: (nodeId: string, callback: () => void) => () => void;
@@ -2968,17 +2968,15 @@ describe("connectionStore metadata loading", () => {
}));
let resolveSearchClearReload!: (tables: TableInfo[]) => void;
let unfilteredFirstPageCalls = 0;
const listTables = vi.fn(
(_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number, offset?: number) => {
if (searchFilter) return Promise.resolve(tables.filter((table) => table.name.includes(searchFilter)));
if ((offset ?? 0) === 0 && ++unfilteredFirstPageCalls === 1) {
return new Promise<TableInfo[]>((resolve) => {
resolveSearchClearReload = resolve;
});
}
return Promise.resolve(tables.slice(offset ?? 0, (offset ?? 0) + (limit ?? tables.length)));
},
);
const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number, offset?: number) => {
if (searchFilter) return Promise.resolve(tables.filter((table) => table.name.includes(searchFilter)));
if ((offset ?? 0) === 0 && ++unfilteredFirstPageCalls === 1) {
return new Promise<TableInfo[]>((resolve) => {
resolveSearchClearReload = resolve;
});
}
return Promise.resolve(tables.slice(offset ?? 0, (offset ?? 0) + (limit ?? tables.length)));
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
@@ -3060,6 +3058,75 @@ describe("connectionStore metadata loading", () => {
expect(listTables).toHaveBeenCalledTimes(5);
});
it("keeps connection-name matches out of object metadata filters", async () => {
const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string) => Promise.resolve(searchFilter === "orders" || !searchFilter ? [{ name: "orders", table_type: "TABLE" as const, comment: null }] : []));
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
const settingsStore = useSettingsStore();
settingsStore.editorSettings.sidebarObjectDisplay = "grouped";
settingsStore.desktopSettings.sidebar_table_page_size = 200;
const connection = { ...mysqlConnection(), name: "60307" };
const tablesGroup: TreeNode = {
id: "mysql-1:basic:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: connection.id,
database: "basic",
isExpanded: true,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [tablesGroup] }];
store.sidebarSearchQuery = "60307";
await store.loadObjectGroupChildren(tablesGroup, { force: true, searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: "60307" });
expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", undefined, 201, 0, ["TABLE"]);
expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]);
store.sidebarSearchQuery = "orders";
await store.loadObjectGroupChildren(tablesGroup, { force: true });
expect(listTables).toHaveBeenLastCalledWith(connection.id, "basic", "basic", "orders", 200, undefined, ["TABLE"]);
expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]);
let resolveAncestorLoad!: (tables: TableInfo[]) => void;
listTables.mockImplementationOnce(
() =>
new Promise<TableInfo[]>((resolve) => {
resolveAncestorLoad = resolve;
}),
);
store.sidebarSearchQuery = "60307";
const ancestorLoad = store.loadObjectGroupChildren(tablesGroup, {
force: true,
searchFilter: "",
allowGlobalSearchMismatch: true,
expectedSidebarSearchQuery: "60307",
});
await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(3));
store.sidebarSearchQuery = "customers";
resolveAncestorLoad([{ name: "stale_table", table_type: "TABLE", comment: null }]);
await ancestorLoad;
expect(tablesGroup.children?.map((node) => node.label)).toEqual(["orders"]);
});
it("merges load-more table pages onto the live group node after an in-tree replacement", async () => {
const firstPage = Array.from({ length: 201 }, (_, index) => ({
name: `t_${String(index + 1).padStart(4, "0")}`,
+4 -3
View File
@@ -2176,8 +2176,9 @@ export const useConnectionStore = defineStore("connection", () => {
function isTreeLoadSearchChanged(searchFilter: string, options?: LoadTreeOptions): boolean {
if (options?.sidebarTableSearchParentId) return isSidebarTableSearchQueryChanged(options);
if (isSidebarSearchQueryChanged(options)) return true;
if (options?.allowGlobalSearchMismatch) return false;
return (sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options);
return (sidebarSearchQuery.value || "") !== searchFilter;
}
function isTreeNodeChildrenLoaded(nodeId: string): boolean {
@@ -4476,7 +4477,7 @@ export const useConnectionStore = defineStore("connection", () => {
nonTableObjectTypes,
offset: 0,
pageSize,
searchFilter: searchFilter || undefined,
searchFilter: options?.searchFilter === "" ? "" : searchFilter || undefined,
force: options?.force,
});
children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize), page.loadMoreParent) : page.children;
@@ -4614,7 +4615,7 @@ export const useConnectionStore = defineStore("connection", () => {
objectTypes,
offset: 0,
pageSize: sidebarObjectGroupPageSize(),
searchFilter: searchFilter || undefined,
searchFilter: options?.searchFilter === "" ? "" : searchFilter || undefined,
force: options?.force,
});
children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, sidebarObjectGroupPageSize()), page.loadMoreParent) : page.children;