fix(sidebar): refresh object counts after drop

This commit is contained in:
Freedom authored and GitHub committed 2026-07-31 14:39:58 +08:00
1 parent 959af4d841
commit 2ec9328eea
13 files changed
+269 -10

No files matched your search

@@ -58,6 +58,60 @@ describe("connectionStore pinned tree node removal", () => {
expect(store.isTreeNodePinned(replacement)).toBe(false);
});
it("recounts parent objectCount from remaining children when a child is removed", async () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const view1 = {
id: "conn:db:public:v1",
label: "v1",
type: "view" as const,
connectionId: "conn",
database: "db",
schema: "public",
};
const view2 = {
id: "conn:db:public:v2",
label: "v2",
type: "view" as const,
connectionId: "conn",
database: "db",
schema: "public",
};
const loadMore = {
id: "conn:db:public:__views:__load_more",
label: "Load more",
type: "load-more" as const,
connectionId: "conn",
database: "db",
};
const viewsGroup: TreeNode = {
id: "conn:db:public:__views",
label: "Views",
type: "group-views",
connectionId: "conn",
database: "db",
schema: "public",
objectCount: 99,
children: [view1, view2, loadMore],
};
store.treeNodes = [
{
id: "conn",
label: "Connection",
type: "connection",
connectionId: "conn",
children: [viewsGroup],
},
];
store.removeTreeNode(view1.id);
expect(viewsGroup.children?.map((child) => child.id)).toEqual([view2.id, loadMore.id]);
expect(viewsGroup.objectCount).toBe(1);
});
it("serializes desktop pin saves so an older reorder cannot overwrite the latest one", async () => {
const savePinnedTreeNodeIds = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
@@ -0,0 +1,134 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, TreeNode } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function postgresConnection(): ConnectionConfig {
return {
id: "pg-1",
name: "Postgres",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "",
database: "app",
} as ConnectionConfig;
}
function schemaNode(children: TreeNode[]): TreeNode {
return {
id: "pg-1:app:public",
label: "public",
type: "schema",
connectionId: "pg-1",
database: "app",
schema: "public",
isExpanded: true,
children,
};
}
function objectGroup(type: "group-views" | "group-procedures", key: string, child: TreeNode): TreeNode {
return {
id: `pg-1:app:public:${key}`,
label: type === "group-views" ? "tree.views" : "tree.procedures",
type,
connectionId: "pg-1",
database: "app",
schema: "public",
isExpanded: true,
objectCount: 7,
children: [child],
};
}
function installApiMocks(options?: { listTables?: ReturnType<typeof vi.fn>; listObjects?: ReturnType<typeof vi.fn> }) {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
listObjects: options?.listObjects ?? vi.fn().mockResolvedValue([]),
listTables: options?.listTables ?? vi.fn().mockResolvedValue([]),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
}
async function createStore(root: TreeNode) {
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped";
const connection = postgresConnection();
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, isExpanded: true, children: [root] }];
return store;
}
describe("connectionStore tree refresh state", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("preserves an object group count while rebuilding grouped placeholders", async () => {
installApiMocks();
const oldView: TreeNode = { id: "pg-1:app:public:old_view", label: "old_view", type: "view", connectionId: "pg-1", database: "app", schema: "public" };
const root = schemaNode([objectGroup("group-views", "__views", oldView)]);
root.children![0]!.isExpanded = false;
const store = await createStore(root);
await store.loadTreeNodeChildren(root, { force: true });
expect(root.children?.find((child) => child.type === "group-views")?.objectCount).toBe(7);
});
it("rolls back tree data and loaded markers when an expanded child refresh fails", async () => {
const listTables = vi.fn().mockResolvedValue([{ name: "fresh_view", table_type: "VIEW", comment: null }]);
const listObjects = vi.fn().mockRejectedValue(new Error("metadata denied"));
installApiMocks({ listTables, listObjects });
const oldView: TreeNode = { id: "pg-1:app:public:old_view", label: "old_view", type: "view", connectionId: "pg-1", database: "app", schema: "public" };
const oldProcedure: TreeNode = { id: "pg-1:app:public:old_procedure", label: "old_procedure", type: "procedure", connectionId: "pg-1", database: "app", schema: "public" };
const root = schemaNode([objectGroup("group-views", "__views", oldView), objectGroup("group-procedures", "__procedures", oldProcedure)]);
const hiddenChildren: TreeNode[] = [{ id: "pg-1:app:public:hidden", label: "hidden", type: "view", connectionId: "pg-1", database: "app", schema: "public" }];
root.hiddenChildren = hiddenChildren;
root.objectCount = 42;
const store = await createStore(root);
await store.loadTreeNodeChildren(root, { force: true });
const previousChildren = root.children;
const viewsGroupId = root.children!.find((child) => child.type === "group-views")!.id;
const proceduresGroupId = root.children!.find((child) => child.type === "group-procedures")!.id;
expect(store.isTreeNodeChildrenLoaded(root.id)).toBe(true);
expect(store.isTreeNodeChildrenLoaded(viewsGroupId)).toBe(false);
await expect(store.refreshTreeNode(root)).rejects.toThrow("metadata denied");
expect(listTables).toHaveBeenCalledOnce();
expect(listObjects).toHaveBeenCalledOnce();
expect(root.children).toBe(previousChildren);
expect(root.hiddenChildren).toBe(hiddenChildren);
expect(root.objectCount).toBe(42);
expect(root.children?.find((child) => child.type === "group-views")?.children?.map((child) => child.label)).toEqual(["old_view"]);
expect(root.children?.find((child) => child.type === "group-procedures")?.children?.map((child) => child.label)).toEqual(["old_procedure"]);
expect(store.isTreeNodeChildrenLoaded(root.id)).toBe(true);
expect(store.isTreeNodeChildrenLoaded(viewsGroupId)).toBe(false);
expect(store.isTreeNodeChildrenLoaded(proceduresGroupId)).toBe(false);
});
});
+30 -3
View File
@@ -1244,14 +1244,19 @@ export const useConnectionStore = defineStore("connection", () => {
const isExpanded = old.isExpanded;
const isLoading = old.isLoading;
const oldChildren = old.children;
const objectCount = child.objectCount ?? old.objectCount;
Object.assign(old, child);
old.isExpanded = isExpanded;
old.isLoading = isLoading;
old.children = oldChildren;
old.objectCount = objectCount;
return old;
}
if (old?.isExpanded) {
return { ...child, isExpanded: true, children: old.children };
return { ...child, isExpanded: true, children: old.children, objectCount: child.objectCount ?? old.objectCount };
}
if (old && objectTypesForGroupNode(old.type)) {
return { ...child, objectCount: child.objectCount ?? old.objectCount };
}
// Same-id collapsed database/schema shell replace (e.g. DDL → force loadDatabases):
// prior confirmed-empty markers belong to the discarded instance and must not skip
@@ -1304,6 +1309,13 @@ export const useConnectionStore = defineStore("connection", () => {
const parent = findParentNode(treeNodes.value, nodeId);
if (parent?.children) {
parent.children = parent.children.filter((c) => c.id !== nodeId);
// Keep the group badge in sync with remaining real children (exclude load-more).
if (parent.objectCount != null) {
parent.objectCount = withoutLoadMoreNodes(parent.children).length;
}
}
if (parent?.hiddenChildren) {
parent.hiddenChildren = parent.hiddenChildren.filter((child) => child.id !== nodeId);
}
if (selectedTreeNodeId.value === nodeId) selectedTreeNodeId.value = null;
selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => id !== nodeId);
@@ -5116,13 +5128,28 @@ export const useConnectionStore = defineStore("connection", () => {
if (node.connectionId && !connectedIds.value.has(node.connectionId)) return;
const expandedIds = collectExpandedNodeIds([node]);
expandedIds.add(node.id);
const previousChildren = node.children;
const previousHiddenChildren = node.hiddenChildren;
const previousObjectCount = node.objectCount;
const previousLoadedIds = [...loadedTreeNodeChildrenIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`));
const previousConfirmedEmptyIds = [...confirmedEmptyTreeNodeIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`));
await clearPersistedTreeCacheForNode(node);
clearLoadedChildrenCache(node.id);
if (node.type !== "connection-group") {
node.children = [];
}
await loadTreeNodeChildren(node, { force: true });
await restoreExpandedChildren(node, expandedIds, { force: true });
try {
await loadTreeNodeChildren(node, { force: true });
await restoreExpandedChildren(node, expandedIds, { force: true });
} catch (error) {
node.children = previousChildren;
node.hiddenChildren = previousHiddenChildren;
node.objectCount = previousObjectCount;
clearLoadedChildrenCache(node.id, { deletePersisted: false });
for (const id of previousLoadedIds) loadedTreeNodeChildrenIds.value.add(id);
for (const id of previousConfirmedEmptyIds) confirmedEmptyTreeNodeIds.value.add(id);
throw error;
}
}
async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) {