mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(sidebar): restore loaded pages after clearing search
This commit is contained in:
@@ -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)) {
|
||||
|
||||
+7
@@ -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")}`,
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user