fix(sidebar): restore loaded pages after clearing search

This commit is contained in:
zipg
2026-09-20 19:36:31 +08:00
committed by GitHub
parent 04c55ad067
commit 786fea8418
7 changed files with 152 additions and 6 deletions
@@ -434,7 +434,9 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: SidebarSearch
// back. Its next explicit expansion will load the ordinary first page.
node.isExpanded = false;
store.discardFilteredTreeNodeChildren(node.id);
} else {
} else if (!store.restoreFilteredObjectGroupChildren(node)) {
// Nothing was captured because the group had not been loaded before the
// search, so there is no previous list to put back.
tasks.push(() => store.loadObjectGroupChildren(node, { force: true }));
}
} else if (simpleObjectParentTypes.has(node.type)) {
@@ -21,6 +21,13 @@ describe("ConnectionTree global search loading", () => {
expect(source).toContain("store.discardFilteredTreeNodeChildren(node.id);");
});
it("restores the pre-search children of an expanded group instead of reloading page one", () => {
// Clearing a remote search must put back the pages the user had loaded
// through "load more"; only an uncaptured group falls back to a reload.
expect(source).toContain("} else if (!store.restoreFilteredObjectGroupChildren(node)) {");
expect(source).toMatch(/restoreFilteredObjectGroupChildren\(node\)\) \{[\s\S]*?tasks\.push\(\(\) => store\.loadObjectGroupChildren\(node, \{ force: true \}\)\);/);
});
it("limits concurrent metadata loads without dropping a task", async () => {
let activeTasks = 0;
let maximumActiveTasks = 0;
@@ -3268,6 +3268,98 @@ describe("connectionStore metadata loading", () => {
expect(listTables).toHaveBeenCalledTimes(5);
});
it("restores the pre-search pages when a remote sidebar search is cleared", async () => {
const tables = Array.from({ length: 5 }, (_, index) => ({
name: `t_${String(index + 1).padStart(4, "0")}`,
table_type: "TABLE" as const,
comment: null,
}));
const listTables = vi.fn((_connectionId: string, _database: string, _schema: string, searchFilter?: string, limit?: number, offset?: number) => {
const matches = searchFilter ? tables.filter((table) => table.name.includes(searchFilter)) : tables;
const start = offset ?? 0;
return Promise.resolve(matches.slice(start, start + (limit ?? matches.length)));
});
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 = 2;
const connection = mysqlConnection();
const tablesGroup: TreeNode = {
id: "mysql-1:app:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: connection.id,
database: "app",
isExpanded: true,
children: [],
};
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
isExpanded: true,
children: [
{
id: "mysql-1:app",
label: "app",
type: "database",
connectionId: connection.id,
database: "app",
isExpanded: true,
children: [tablesGroup],
},
],
},
];
// The user expands the group and pages through "load more" until every table is in memory.
await store.loadObjectGroupChildren(tablesGroup, { force: true });
while (tablesGroup.children?.some((child) => child.type === "load-more")) {
await store.loadMoreObjectGroupChildren(tablesGroup.children.find((child) => child.type === "load-more")!);
}
expect(tablesGroup.children?.map((child) => child.label)).toEqual(tables.map((table) => table.name));
expect(tablesGroup.objectCount).toBe(5);
const unfilteredPageOneCalls = listTables.mock.calls.filter((call) => !call[3] && (call[5] ?? 0) === 0).length;
// Typing a search swaps the children for a filtered projection.
store.sidebarSearchQuery = "0003";
await store.loadObjectGroupChildren(tablesGroup, { force: true });
expect(tablesGroup.children?.map((child) => child.label)).toEqual(["t_0003"]);
// Clearing the query must put the previously loaded pages back, without
// dropping the user back to the first page through a fresh reload.
store.sidebarSearchQuery = "";
expect(store.restoreFilteredObjectGroupChildren(tablesGroup)).toBe(true);
expect(tablesGroup.children?.map((child) => child.label)).toEqual(tables.map((table) => table.name));
expect(tablesGroup.objectCount).toBe(5);
expect(tablesGroup.isExpanded).toBe(true);
expect(store.isTreeNodeChildrenLoaded(tablesGroup.id)).toBe(true);
expect(listTables.mock.calls.filter((call) => !call[3] && (call[5] ?? 0) === 0).length).toBe(unfilteredPageOneCalls);
// With no captured list (a group that was never loaded) the caller falls back to a reload.
const untouchedGroup: TreeNode = { ...tablesGroup, id: "mysql-1:app:__views", type: "group-views", children: [] };
expect(store.restoreFilteredObjectGroupChildren(untouchedGroup)).toBe(false);
});
it("pages table-scoped search results instead of truncating at the search budget", async () => {
const tables = Array.from({ length: 5 }, (_, index) => ({
name: `t_${String(index + 1).padStart(4, "0")}`,
+50 -5
View File
@@ -662,6 +662,11 @@ export const useConnectionStore = defineStore("connection", () => {
const tableListSourceRevisions = new Map<string, number>();
const treeNodeLoads = new TreeNodeLoadRegistry();
const filteredObjectGroupChildrenIds = new Set<string>();
// A remote sidebar search swaps an object group's children for a filtered
// projection. The pre-search children — including extra pages the user loaded
// through "load more" — are captured here so clearing the query restores the
// browsing state instead of silently dropping those pages back to page one.
const filteredObjectGroupChildrenSnapshots = new Map<string, { children: TreeNode[]; objectCount?: number }>();
const primaryVisibleObjectRefreshInFlight = new Set<string>();
let nextLocalConnectionAttempt = 0;
let beforeConnectHandler: BeforeConnectHandler | null = null;
@@ -1743,9 +1748,16 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
// Forget both the filtered marker and its captured pre-search children, so a
// later restore cannot resurrect a stale projection or an outdated list.
function forgetFilteredObjectGroupChildren(nodeId: string) {
filteredObjectGroupChildrenIds.delete(nodeId);
filteredObjectGroupChildrenSnapshots.delete(nodeId);
}
/** Drop loaded/confirmed-empty markers, metadata caches, and generations for a discarded shell. */
function forgetTreeNodeLoadState(nodeId: string) {
filteredObjectGroupChildrenIds.delete(nodeId);
forgetFilteredObjectGroupChildren(nodeId);
clearLoadedChildrenCache(nodeId);
treeNodeLoads.invalidatePrefix(nodeId);
}
@@ -3306,6 +3318,25 @@ export const useConnectionStore = defineStore("connection", () => {
return true;
}
// Remote sidebar search is a temporary projection over an object group. When the
// query is cleared, put the captured pre-search children back — including the
// pages loaded through "load more" — instead of refetching page one. Returns
// false when nothing was captured (the group was never loaded before the
// search), so the caller can fall back to a normal load.
function restoreFilteredObjectGroupChildren(node: TreeNode): boolean {
const snapshot = filteredObjectGroupChildrenSnapshots.get(node.id);
const liveNode = treeNodeInSidebarTree(node);
if (!snapshot || !liveNode) {
if (!liveNode) forgetFilteredObjectGroupChildren(node.id);
return false;
}
setChildren(liveNode, snapshot.children);
liveNode.objectCount = snapshot.objectCount;
liveNode.isExpanded = true;
forgetFilteredObjectGroupChildren(liveNode.id);
return true;
}
function treeNodeInSidebarTree(node: TreeNode): TreeNode | null {
return findNode(treeNodes.value, node.id);
}
@@ -3354,6 +3385,11 @@ export const useConnectionStore = defineStore("connection", () => {
filteredObjectGroupChildrenIds.delete(id);
}
}
for (const id of filteredObjectGroupChildrenSnapshots.keys()) {
if (id === prefix || id.startsWith(`${prefix}:`)) {
filteredObjectGroupChildrenSnapshots.delete(id);
}
}
invalidateMetadataCachesByTreePrefix(prefix);
if (options?.deletePersisted === false) return;
const rawPrefix = `${prefix}:`;
@@ -6125,7 +6161,7 @@ export const useConnectionStore = defineStore("connection", () => {
});
if (!options?.force && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
if (await hydrateTreeNodeFromCache(node, objectGroupCacheKey(node))) {
filteredObjectGroupChildrenIds.delete(node.id);
forgetFilteredObjectGroupChildren(node.id);
void loadObjectGroupChildren(node, { ...options, force: true }).catch(() => undefined);
return;
}
@@ -6152,7 +6188,7 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureConnected(node.connectionId);
load = reclaimTreeNodeLoad(load, node);
if (useCachedChildren(node, options, load)) {
filteredObjectGroupChildrenIds.delete(node.id);
forgetFilteredObjectGroupChildren(node.id);
return;
}
const objectTypes = objectTypesForGroupNode(node.type);
@@ -6175,7 +6211,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (!options?.force && !searchFilter && !tableNameFilter) {
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
if (cached.hit) {
filteredObjectGroupChildrenIds.delete(node.id);
forgetFilteredObjectGroupChildren(node.id);
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
@@ -6219,10 +6255,18 @@ export const useConnectionStore = defineStore("connection", () => {
if (!tableNameFilterRevisionMatches(options)) return;
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
if (searchFilter && !filteredObjectGroupChildrenIds.has(targetNode.id) && isTreeNodeChildrenLoaded(targetNode.id)) {
// Capture the first transition into a filtered projection only; later
// keystrokes re-filter the same group and must keep the original list.
filteredObjectGroupChildrenSnapshots.set(targetNode.id, {
children: targetNode.children ?? [],
objectCount: targetNode.objectCount,
});
}
targetNode.objectCount = nextObjectCount;
setChildren(targetNode, children);
if (searchFilter) filteredObjectGroupChildrenIds.add(targetNode.id);
else filteredObjectGroupChildrenIds.delete(targetNode.id);
else forgetFilteredObjectGroupChildren(targetNode.id);
options?.onChildrenApplied?.(targetNode);
if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) {
await savePersistedTreeChildren(cacheKey, children);
@@ -9615,6 +9659,7 @@ export const useConnectionStore = defineStore("connection", () => {
canUseLoadedTreeNodeToggle,
releaseCollapsedTreeNodeChildren,
discardFilteredTreeNodeChildren,
restoreFilteredObjectGroupChildren,
cancelTreeNodeLoad,
setBeforeConnectHandler,
initFromDisk,
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB