fix(sidebar): refresh object counts after drop

This commit is contained in:
Freedom 2026-07-31 14:39:58 +08:00 committed by GitHub
parent 959af4d841
commit 2ec9328eea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 269 additions and 10 deletions

View File

@ -2030,15 +2030,17 @@ async function confirmDropObject() {
const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
toast(t(msgKey, { name: node.label }), 3000);
closeDroppedTableObjectTabsForNode(node);
// Procedure/function drops refresh their parent instead of removing this
// node directly, so clear their pin before the old identity can survive.
// Refresh the parent object list so group badges and children stay in sync.
// Clear the pin first refresh rebuilds nodes and the old identity must not survive.
connectionStore.removePinnedTreeNodes([node]);
if (node.type === "view" || node.type === "materialized_view") {
connectionStore.removeTreeNode(node.id);
releaseActiveNodeReference([node.id]);
} else {
try {
await refreshTableList(node);
} catch (error: any) {
// DROP already succeeded; keep the sidebar consistent if metadata refresh fails.
connectionStore.removeTreeNode(node.id);
toast(t("contextMenu.objectDropRefreshFailed", { message: error?.message || String(error) }), 5000);
}
releaseActiveNodeReference([node.id]);
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
}
@ -2134,6 +2136,7 @@ async function confirmBatchDrop() {
showBatchDropConfirm.value = false;
return;
}
const refreshScopes = new Map<string, TreeNode>();
for (const target of targets) {
if (!target.connectionId || !target.database) continue;
await connectionStore.ensureConnected(target.connectionId);
@ -2141,11 +2144,18 @@ async function confirmBatchDrop() {
if (!sql) continue;
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
closeDroppedTableObjectTabsForNode(target);
// Remove immediately so a later failure cannot leave dropped objects in the tree.
connectionStore.removeTreeNode(target.id);
releaseActiveNodeReference([target.id]);
refreshScopes.set(`${target.connectionId}:${target.database}:${target.schema ?? ""}`, target);
}
toast(t("contextMenu.batchDropSuccess", { count: targets.length }), 3000);
showBatchDropConfirm.value = false;
const refreshResults = await Promise.allSettled([...refreshScopes.values()].map((target) => refreshTableList(target)));
const refreshFailure = refreshResults.find((result): result is PromiseRejectedResult => result.status === "rejected");
if (refreshFailure) {
toast(t("contextMenu.objectDropRefreshFailed", { message: refreshFailure.reason?.message || String(refreshFailure.reason) }), 5000);
}
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
}

View File

@ -2166,6 +2166,7 @@ export default {
truncateTableSuccess: 'Table "{name}" truncated',
duplicateStructureSuccess: 'Table cloned as "{name}"',
tableOperationFailed: "Operation failed: {message}",
objectDropRefreshFailed: "Objects were deleted, but refreshing the sidebar failed: {message}",
duplicateNameTitle: "Clone as New Table",
duplicateNamePlaceholder: "New table name",
copyTable: "Copy Table",

View File

@ -2101,6 +2101,7 @@ export default withEnglishFallback({
truncateTableSuccess: 'Tabla "{name}" truncada',
duplicateStructureSuccess: 'Tabla clonada como "{name}"',
tableOperationFailed: "Error en la operación: {message}",
objectDropRefreshFailed: "Los objetos se eliminaron, pero no se pudo actualizar la barra lateral: {message}",
duplicateNameTitle: "Clonar como tabla nueva",
duplicateNamePlaceholder: "Nombre de la nueva tabla",
copyTable: "Copiar tabla",

View File

@ -2099,6 +2099,7 @@ export default withEnglishFallback({
truncateTableSuccess: 'Tabella "{name}" troncata',
duplicateStructureSuccess: 'Tabella clonata come "{name}"',
tableOperationFailed: "Operazione non riuscita: {message}",
objectDropRefreshFailed: "Gli oggetti sono stati eliminati, ma l'aggiornamento della barra laterale non è riuscito: {message}",
duplicateNameTitle: "Clona as Nuova Tabella",
duplicateNamePlaceholder: "Nuovo nome tabella",
copyTable: "Copia tabella",

View File

@ -2096,6 +2096,7 @@ export default withEnglishFallback({
truncateTableSuccess: "テーブル「{name}」をトランケートしました",
duplicateStructureSuccess: "テーブルを「{name}」として複製しました",
tableOperationFailed: "操作に失敗しました: {message}",
objectDropRefreshFailed: "オブジェクトは削除されましたが、サイドバーの更新に失敗しました: {message}",
duplicateNameTitle: "新しいテーブルとして複製",
duplicateNamePlaceholder: "新しいテーブル名",
copyTable: "テーブルをコピー",

View File

@ -2137,6 +2137,7 @@ export default withEnglishFallback({
truncateTableSuccess: '테이블 "{name}" TRUNCATE됨',
duplicateStructureSuccess: '테이블이 "{name}"(으)로 복제됨',
tableOperationFailed: "작업 실패: {message}",
objectDropRefreshFailed: "개체는 삭제되었지만 사이드바 새로 고침에 실패했습니다: {message}",
duplicateNameTitle: "새 테이블로 복제",
duplicateNamePlaceholder: "새 테이블 이름",
copyTable: "테이블 복사",

View File

@ -2101,6 +2101,7 @@ export default withEnglishFallback({
truncateTableSuccess: 'Tabela "{name}" truncada',
duplicateStructureSuccess: 'Tabela clonada como "{name}"',
tableOperationFailed: "Falha na operação: {message}",
objectDropRefreshFailed: "Os objetos foram excluídos, mas não foi possível atualizar a barra lateral: {message}",
duplicateNameTitle: "Clonar como Nova Tabela",
duplicateNamePlaceholder: "Nome da nova tabela",
copyTable: "Copiar tabela",

View File

@ -2167,6 +2167,7 @@ export default withEnglishFallback({
truncateTableSuccess: "表「{name}」已截断",
duplicateStructureSuccess: "已克隆为新表「{name}」",
tableOperationFailed: "操作失败:{message}",
objectDropRefreshFailed: "对象已删除,但侧边栏刷新失败:{message}",
duplicateNameTitle: "克隆为新表",
duplicateNamePlaceholder: "新表名",
copyTable: "复制表",

View File

@ -2100,6 +2100,7 @@ export default withEnglishFallback({
truncateTableSuccess: "資料表「{name}」已截斷",
duplicateStructureSuccess: "已克隆為新資料表「{name}」",
tableOperationFailed: "操作失敗:{message}",
objectDropRefreshFailed: "物件已刪除,但側邊欄重新整理失敗:{message}",
duplicateNameTitle: "克隆為新資料表",
duplicateNamePlaceholder: "新資料表名稱",
copyTable: "複製資料表",

View File

@ -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 }));

View File

@ -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);
});
});

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) {

View File

@ -6,6 +6,7 @@ const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTre
const connectionMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts", "utf8");
const databaseSpecificMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts", "utf8");
const tableMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarTableMutationRuntime.ts", "utf8");
const localeSources = Object.fromEntries(["en", "es", "it", "ja", "ko", "pt-BR", "zh-CN", "zh-TW"].map((locale) => [locale, readFileSync(`apps/desktop/src/i18n/locales/${locale}.ts`, "utf8")]));
function functionBody(name: string): string {
const source = [runtimeHost, connectionMutationRuntime, databaseSpecificMutationRuntime, tableMutationRuntime].find((candidate) => candidate.includes(`function ${name}(`));
@ -48,7 +49,6 @@ test("mutation families retain accepted targets, failures, and refresh work", ()
assert.match(dropMongoCollection, /onError:\s*toastMutationError/);
assert.match(dropMongoCollection, /api\.mongoDropCollection/);
const redis = functionBody("confirmFlushRedisDb");
assert.match(redis, /updateRedisDbKeyStats/);
assert.match(redis, /dbx-redis-db-flushed/);
@ -66,3 +66,29 @@ test("menu and dialog mutations resolve immutable accepted targets", () => {
assert.match(runtimeHost, /routedRequest\.confirm = async \(\) => \{[\s\S]*?activateActionTarget\(target\)/);
assert.match(runtimeHost, /createRoutedSidebarDialogController\(controller, \{[\s\S]*?wrapAction: \(action\) => \{[\s\S]*?activateActionTarget\(target\)/);
});
test("object drops distinguish completed deletes from sidebar refresh failures", () => {
const dropObject = functionBody("confirmDropObject");
assert.match(dropObject, /removePinnedTreeNodes\(\[node\]\)[\s\S]*?try \{[\s\S]*?await refreshTableList\(node\)[\s\S]*?catch \(error: any\) \{[\s\S]*?removeTreeNode\(node\.id\)[\s\S]*?objectDropRefreshFailed[\s\S]*?releaseActiveNodeReference\(\[node\.id\]\)/);
const batchDrop = functionBody("confirmBatchDrop");
const refreshBranch = batchDrop.slice(batchDrop.indexOf("const refreshScopes = new Map"));
const removeIndex = refreshBranch.indexOf("connectionStore.removeTreeNode(target.id)");
const successIndex = refreshBranch.indexOf('toast(t("contextMenu.batchDropSuccess"');
const closeIndex = refreshBranch.indexOf("showBatchDropConfirm.value = false", successIndex);
const refreshIndex = refreshBranch.indexOf("Promise.allSettled");
const warningIndex = refreshBranch.indexOf('toast(t("contextMenu.objectDropRefreshFailed"', refreshIndex);
assert.ok(removeIndex >= 0 && successIndex > removeIndex, "successful deletes must be reflected before the success toast");
assert.ok(closeIndex > successIndex && refreshIndex > closeIndex, "the completed delete UI must close before metadata refreshes settle");
assert.ok(warningIndex > refreshIndex, "refresh failures must be reported separately after deletion succeeds");
assert.doesNotMatch(refreshBranch, /for \(const target of refreshScopes\.values\(\)\) \{\s*await refreshTableList/);
});
test("all locales define the object drop refresh warning with its message placeholder", () => {
for (const [locale, source] of Object.entries(localeSources)) {
const keys = source.match(/\bobjectDropRefreshFailed\s*:/g) ?? [];
assert.equal(keys.length, 1, `${locale} must define objectDropRefreshFailed exactly once`);
assert.match(source, /objectDropRefreshFailed\s*:\s*["'][^\n]*\{message\}/, `${locale} must preserve the message placeholder`);
}
});