fix(sidebar): restore complete object list after search

Closes #6006
This commit is contained in:
t8y2 committed 2026-08-13 07:56:32 +00:00
1 parent c228078e25
commit 2faa755bfe
2 files changed
+145 -11

No files matched your search

@@ -2960,6 +2960,106 @@ describe("connectionStore metadata loading", () => {
expect(tablesGroup.children?.some((child) => child.label?.startsWith("fresh_"))).toBe(true);
});
it("restores and drains unfiltered pages when expand-all races a search clear reload", async () => {
const tables = Array.from({ length: 5 }, (_, index) => ({
name: `t_${String(index + 1).padStart(4, "0")}`,
table_type: "TABLE" as const,
comment: null,
}));
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)));
},
);
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),
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 = 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],
},
],
},
];
store.sidebarSearchQuery = "0003";
await store.loadObjectGroupChildren(tablesGroup, { force: true });
expect(tablesGroup.children?.map((child) => child.label)).toEqual(["t_0003"]);
expect(store.isTreeNodeChildrenLoaded(tablesGroup.id)).toBe(true);
store.sidebarSearchQuery = "";
const searchClearReload = store.loadObjectGroupChildren(tablesGroup, { force: true });
await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(2));
await store.loadAllObjectGroupChildren(tablesGroup);
resolveSearchClearReload(tables.slice(0, 3));
await searchClearReload;
expect(tablesGroup.children?.map((child) => child.label)).toEqual(tables.map((table) => table.name));
expect(tablesGroup.children?.some((child) => child.type === "load-more")).toBe(false);
expect(tablesGroup.objectCount).toBe(5);
expect(tablesGroup.isLoading).toBe(false);
expect(listTables.mock.calls.map((call) => [call[3], call[4], call[5]])).toEqual([
["0003", 2, undefined],
[undefined, 3, 0],
[undefined, 3, 0],
[undefined, 3, 2],
[undefined, 3, 4],
]);
await store.loadAllObjectGroupChildren(tablesGroup);
expect(listTables).toHaveBeenCalledTimes(5);
});
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")}`,
+45 -11
View File
@@ -294,6 +294,10 @@ interface LoadTreeOptions {
connectedOnly?: boolean;
expectedSidebarSearchQuery?: string;
searchFilter?: string;
// Explicit actions can load the unfiltered backing group while the global search
// continues to control presentation; normal watcher refreshes still reject mismatches.
allowGlobalSearchMismatch?: boolean;
onChildrenApplied?: (node: TreeNode) => void;
sidebarTableSearchParentId?: string;
expectedSidebarTableSearchQuery?: string;
tableNameFilterScopeKey?: string;
@@ -475,6 +479,7 @@ export const useConnectionStore = defineStore("connection", () => {
const connectionStateRevisions = new Map<string, number>();
const connectionErrorRevisions = new Map<string, number>();
const treeNodeLoads = new TreeNodeLoadRegistry();
const filteredObjectGroupChildrenIds = new Set<string>();
const primaryVisibleObjectRefreshInFlight = new Set<string>();
let nextLocalConnectionAttempt = 0;
let beforeConnectHandler: BeforeConnectHandler | null = null;
@@ -1237,6 +1242,7 @@ export const useConnectionStore = defineStore("connection", () => {
/** Drop loaded/confirmed-empty markers, metadata caches, and generations for a discarded shell. */
function forgetTreeNodeLoadState(nodeId: string) {
filteredObjectGroupChildrenIds.delete(nodeId);
clearLoadedChildrenCache(nodeId);
treeNodeLoads.invalidatePrefix(nodeId);
}
@@ -2170,6 +2176,7 @@ export const useConnectionStore = defineStore("connection", () => {
function isTreeLoadSearchChanged(searchFilter: string, options?: LoadTreeOptions): boolean {
if (options?.sidebarTableSearchParentId) return isSidebarTableSearchQueryChanged(options);
if (options?.allowGlobalSearchMismatch) return false;
return (sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options);
}
@@ -2262,6 +2269,11 @@ export const useConnectionStore = defineStore("connection", () => {
confirmedEmptyTreeNodeIds.value.delete(id);
}
}
for (const id of filteredObjectGroupChildrenIds) {
if (id === prefix || id.startsWith(`${prefix}:`)) {
filteredObjectGroupChildrenIds.delete(id);
}
}
invalidateMetadataCachesByTreePrefix(prefix);
if (options?.deletePersisted === false) return;
const rawPrefix = `${prefix}:`;
@@ -4534,6 +4546,7 @@ export const useConnectionStore = defineStore("connection", () => {
});
if (!options?.force && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
if (await hydrateTreeNodeFromCache(node, objectGroupCacheKey(node))) {
filteredObjectGroupChildrenIds.delete(node.id);
void loadObjectGroupChildren(node, { ...options, force: true }).catch(() => undefined);
return;
}
@@ -4559,7 +4572,10 @@ export const useConnectionStore = defineStore("connection", () => {
try {
await ensureConnected(node.connectionId);
load = reclaimTreeNodeLoad(load, node);
if (useCachedChildren(node, options, load)) return;
if (useCachedChildren(node, options, load)) {
filteredObjectGroupChildrenIds.delete(node.id);
return;
}
const objectTypes = objectTypesForGroupNode(node.type);
const parentNodeId = objectGroupRefreshParentId(node);
if (!objectTypes || !parentNodeId) return;
@@ -4580,6 +4596,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);
if (cached.isStale) refreshStaleTreeNode(node);
return;
}
@@ -4624,6 +4641,9 @@ export const useConnectionStore = defineStore("connection", () => {
if (!targetNode) return;
targetNode.objectCount = nextObjectCount;
setChildren(targetNode, children);
if (searchFilter) filteredObjectGroupChildrenIds.add(targetNode.id);
else filteredObjectGroupChildrenIds.delete(targetNode.id);
options?.onChildrenApplied?.(targetNode);
if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) {
await savePersistedTreeChildren(cacheKey, children);
}
@@ -4664,7 +4684,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadMoreObjectGroupChildren(node: TreeNode) {
async function loadMoreObjectGroupChildren(node: TreeNode, options?: { searchFilter?: string }) {
if (node.type !== "load-more" || !node.loadMore) return;
const loadMore = node.loadMore;
const parent = findNode(treeNodes.value, node.loadMore.parentId);
@@ -4743,6 +4763,7 @@ export const useConnectionStore = defineStore("connection", () => {
objectTypes,
offset: loadMore.offset,
pageSize: loadMore.pageSize,
searchFilter: options?.searchFilter,
force: false,
});
const targetParent = treeNodeLoadRelatedTarget(load, parent);
@@ -4759,6 +4780,7 @@ export const useConnectionStore = defineStore("connection", () => {
objectTypes,
offset: loadMore.offset,
pageSize: loadMore.pageSize,
searchFilter: options?.searchFilter,
force: false,
});
const targetParent = treeNodeLoadRelatedTarget(load, parent);
@@ -4929,20 +4951,32 @@ export const useConnectionStore = defineStore("connection", () => {
async function loadAllObjectGroupChildren(parent: TreeNode) {
if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
if (!objectTypesForGroupNode(parent.type)) return;
let load = beginTreeNodeLoad(parent);
const liveParent = treeNodeInSidebarTree(parent);
if (!liveParent) return;
if (!isTreeNodeChildrenLoaded(liveParent.id) || filteredObjectGroupChildrenIds.has(liveParent.id)) {
let isPreparedPageCurrent: (() => boolean) | undefined;
await loadObjectGroupChildren(liveParent, {
force: true,
searchFilter: "",
allowGlobalSearchMismatch: true,
onChildrenApplied: (targetNode) => {
const epoch = treeNodeLoads.observe(targetNode.id);
isPreparedPageCurrent = () => epoch.isCurrent();
},
});
if (!isPreparedPageCurrent?.()) return;
}
const refreshedParent = treeNodeInSidebarTree(parent);
if (!refreshedParent) return;
const load = beginTreeNodeLoad(refreshedParent);
try {
await ensureConnected(parent.connectionId);
load = reclaimTreeNodeLoad(load, parent);
const liveParent = treeNodeLoadTarget(load);
if (!liveParent) return;
if (!isTreeNodeChildrenLoaded(liveParent.id)) {
await loadObjectGroupChildren(liveParent);
}
if (!load.isCurrent()) return;
let loadMoreNode = findTreeNodes(liveParent.children ?? [], (child) => child.type === "load-more")[0];
while (loadMoreNode?.loadMore) {
await loadMoreObjectGroupChildren(loadMoreNode);
await loadMoreObjectGroupChildren(loadMoreNode, { searchFilter: "" });
if (!load.isCurrent()) return;
const currentParent = treeNodeLoadTarget(load);
if (!currentParent) return;
@@ -4951,7 +4985,7 @@ export const useConnectionStore = defineStore("connection", () => {
const finishedParent = treeNodeLoadTarget(load);
if (finishedParent) finishedParent.isExpanded = true;
} catch (e) {
recordMetadataLoadError(parent.connectionId, e, load);
recordMetadataLoadError(refreshedParent.connectionId!, e, load);
throw e;
} finally {
finishTreeNodeLoad(load);