fix(sidebar): restore table list after remote search

This commit is contained in:
Guoyu Su authored and GitHub committed 2026-09-04 12:41:05 +08:00
1 parent b6beefc89e
commit 299c342d6d
6 files changed
+137 -2

No files matched your search

@@ -429,9 +429,10 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch
if (searchableObjectGroupTypes.has(node.type)) {
if (searchAutoExpandedNodeIds.has(node.id)) {
// Search opened this group on the user's behalf to check for matches;
// once the query is gone there is nothing to show, so collapse it back
// instead of reloading and leaving it open.
// once the query is gone, drop the filtered projection and collapse it
// back. Its next explicit expansion will load the ordinary first page.
node.isExpanded = false;
store.discardFilteredTreeNodeChildren(node.id);
} else {
tasks.push(() => store.loadObjectGroupChildren(node, { force: true }));
}
@@ -715,6 +715,12 @@ async function openDirectNavigationNode(node: TreeNode, requestId: number) {
async function toggle(requestId = beginNavigationRequest()) {
const node = activeNode.value;
// Remote sidebar search intentionally descends into one active connection.
// Keep that scope aligned even when this toggle is satisfied entirely from
// already-loaded children and therefore performs no connection request.
if (node.connectionId && connectionStore.connectedIds.has(node.connectionId)) {
connectionStore.activeConnectionId = node.connectionId;
}
const treeLoadSearchOptions = sidebarTreeContext?.getTreeLoadSearchOptions?.(node);
if (isDirectNavigationTreeNode(node.type)) {
try {
@@ -18,6 +18,7 @@ describe("ConnectionTree global search loading", () => {
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;");
expect(source).toContain("store.discardFilteredTreeNodeChildren(node.id);");
});
it("limits concurrent metadata loads without dropping a task", async () => {
@@ -11,6 +11,8 @@ import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.
const connectionStore = {
treeNodes: [] as TreeNode[],
sidebarSearchQuery: "",
activeConnectionId: null as string | null,
connectedIds: new Set<string>(),
canUseLoadedTreeNodeToggle: vi.fn(() => true),
releaseCollapsedTreeNodeChildren: vi.fn(),
getConfig: vi.fn(() => ({ db_type: "mysql", name: "connection" })),
@@ -56,12 +58,47 @@ afterEach(() => {
document.body.innerHTML = "";
connectionStore.treeNodes = [];
connectionStore.sidebarSearchQuery = "";
connectionStore.activeConnectionId = null;
connectionStore.connectedIds.clear();
vi.clearAllMocks();
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true);
connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection" });
});
describe("SidebarTreeRuntimeHost expansion", () => {
it("activates the owning connection when a cached node toggles locally", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "mysql",
database: "basic",
isExpanded: false,
children: [{ id: "mysql:basic:orders", label: "orders", type: "table", connectionId: "mysql", database: "basic" }],
};
connectionStore.connectedIds.add("mysql");
connectionStore.activeConnectionId = "another-connection";
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(
defineComponent({
setup: () => () => 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 nextTick();
expect(connectionStore.activeConnectionId).toBe("mysql");
expect(group.isExpanded).toBe(true);
expect(connectionStore.loadObjectGroupChildren).not.toHaveBeenCalled();
});
it("publishes a rendered group collapse and synchronizes the live tree", async () => {
const liveGroup: TreeNode = {
id: "connection:database:__tables",
@@ -3326,6 +3326,76 @@ describe("connectionStore metadata loading", () => {
expect(listTables.mock.calls[1].slice(3, 6)).toEqual(["t", 3, 2]);
});
it("discards a collapsed remote-search projection so the next expansion reloads the ordinary page", async () => {
const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number) => {
if (searchFilter) {
return Promise.resolve([{ name: "zz_late_probe", table_type: "TABLE" as const, comment: null }]);
}
return Promise.resolve(
Array.from({ length: Math.min(limit ?? 1001, 1001) }, (_, index) => ({
name: `t_${String(index + 1).padStart(4, "0")}`,
table_type: "TABLE" as const,
comment: null,
})),
);
});
const deleteSchemaCachePrefix = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix,
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: 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 = 1000;
const connection = mysqlConnection();
const tablesGroup: TreeNode = {
id: "mysql-1:app:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: connection.id,
database: "app",
isExpanded: false,
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 = "zz_late_probe";
await store.loadObjectGroupChildren(tablesGroup, { force: true });
expect(listTables).toHaveBeenLastCalledWith(connection.id, "app", "app", "zz_late_probe", SIDEBAR_SEARCH_RESULT_BUDGET, undefined, ["TABLE"]);
expect(tablesGroup.children?.map((node) => node.label)).toEqual(["zz_late_probe"]);
expect(tablesGroup.objectCount).toBe(1);
expect(store.canUseLoadedTreeNodeToggle(tablesGroup)).toBe(true);
tablesGroup.isExpanded = false;
expect(store.discardFilteredTreeNodeChildren(tablesGroup.id)).toBe(true);
expect(tablesGroup.children).toEqual([]);
expect(tablesGroup.objectCount).toBeUndefined();
expect(store.canUseLoadedTreeNodeToggle(tablesGroup)).toBe(false);
expect(deleteSchemaCachePrefix).not.toHaveBeenCalled();
store.sidebarSearchQuery = "";
await store.loadObjectGroupChildren(tablesGroup);
expect(listTables).toHaveBeenLastCalledWith(connection.id, "app", "app", undefined, 1001, 0, ["TABLE"]);
expect(tablesGroup.children).toHaveLength(1001);
expect(tablesGroup.children?.at(-1)?.type).toBe("load-more");
});
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 }] : []));
@@ -2913,6 +2913,25 @@ export const useConnectionStore = defineStore("connection", () => {
return true;
}
// Remote sidebar search temporarily replaces an object group's ordinary
// children with a filtered projection. When search expanded a previously
// collapsed group, discard that projection on clear instead of letting the
// loaded marker make the next user expansion reuse incomplete children.
function discardFilteredTreeNodeChildren(nodeId: string): boolean {
if (!filteredObjectGroupChildrenIds.has(nodeId)) return false;
const node = findNode(treeNodes.value, nodeId);
if (!node) {
filteredObjectGroupChildrenIds.delete(nodeId);
return false;
}
clearLoadedChildrenCache(nodeId, { deletePersisted: false });
treeNodeLoads.invalidatePrefix(nodeId);
node.children = [];
node.objectCount = undefined;
node.isLoading = false;
return true;
}
function treeNodeInSidebarTree(node: TreeNode): TreeNode | null {
return findNode(treeNodes.value, node.id);
}
@@ -8928,6 +8947,7 @@ export const useConnectionStore = defineStore("connection", () => {
isTreeNodeChildrenLoaded,
canUseLoadedTreeNodeToggle,
releaseCollapsedTreeNodeChildren,
discardFilteredTreeNodeChildren,
cancelTreeNodeLoad,
setBeforeConnectHandler,
initFromDisk,