From 085d7661afb1de801317efd000132894b5a2da21 Mon Sep 17 00:00:00 2001 From: zipg Date: Sat, 11 Jul 2026 17:48:45 +0800 Subject: [PATCH] fix(objects): add safe object browser batch empty action --- .../src/components/objects/ObjectBrowser.vue | 89 +++++++++++++++++-- .../__tests__/sidebar/batchTableEmpty.spec.ts | 21 ++++- .../src/lib/sidebar/batchTableEmpty.ts | 15 ++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 1ef4c8a19c..00def092c7 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -80,6 +80,7 @@ import QueryEditor from "@/components/editor/QueryEditor.vue"; import DdlViewDialog from "./DdlViewDialog.vue"; import { formatSqlForDisplay, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; +import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { buildObjectBrowserRows, @@ -202,6 +203,9 @@ const batchDropPreviewSql = ref(""); const showBatchTruncateConfirm = ref(false); const batchTruncatePreviewSql = ref(""); const batchTruncateCascade = ref(false); +const showBatchEmptyConfirm = ref(false); +const batchEmptyPreviewSql = ref(""); +const batchEmptyPlan = ref[]>([]); // Paste table dialog state const showPasteDialog = ref(false); const pasteTableMode = ref("structure-and-data"); @@ -1455,6 +1459,57 @@ async function confirmBatchTruncateTables() { } } +async function refreshBatchEmptyPreviewSql(targets: ObjectBrowserRow[]) { + const plan = await buildBatchTableEmptyPlan(targets, (row) => buildEmptyTableSql(tableAdminSqlOptions(row))); + // Freeze the reviewed SQL with its target so confirmation cannot execute a different destructive statement. + batchEmptyPlan.value = plan; + batchEmptyPreviewSql.value = plan.map(({ sql }) => sql).join("\n"); +} + +function requestBatchEmptyTables() { + const targets = [...selectedTableRows.value]; + if (targets.length === 0) return; + batchEmptyPlan.value = []; + batchEmptyPreviewSql.value = ""; + void refreshBatchEmptyPreviewSql(targets) + .then(() => { + showBatchEmptyConfirm.value = true; + }) + .catch((e: any) => { + batchEmptyPlan.value = []; + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + }); +} + +async function confirmBatchEmptyTables() { + const plan = batchEmptyPlan.value.slice(); + if (plan.length === 0) return; + const asynchronousMutation = effectiveDatabaseType.value === "clickhouse"; + const result = await runBatchTableEmpty(plan, async ({ sql }) => { + await api.executeQuery(props.connection.id, props.database, sql); + }); + for (const failure of result.failed) { + console.error(`Failed to empty table "${failure.target.target.name}":`, failure.error); + } + const feedback = batchTableEmptyFeedback(result, asynchronousMutation); + if (feedback === "success") { + toast(t("contextMenu.batchEmptySuccess", { count: result.succeeded.length }), 3000); + } else if (feedback === "submitted") { + toast(t("contextMenu.batchEmptySubmitted", { count: result.succeeded.length }), 3000); + } else if (feedback === "submitted-partial") { + toast(t("contextMenu.batchEmptySubmittedPartial", { success: result.succeeded.length, failed: result.failed.length }), 5000); + } else { + toast(t("contextMenu.batchEmptyPartialFail", { success: result.succeeded.length, failed: result.failed.length }), 5000); + } + batchEmptyPlan.value = []; + showBatchEmptyConfirm.value = false; + if (result.succeeded.length > 0) { + clearTableSelection(); + await reload(); + await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value); + } +} + async function exportStructure(row: ObjectBrowserRow) { try { const schema = row.schema || selectedSchema.value || props.database; @@ -2075,7 +2130,16 @@ function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem { }; } +function isSelectedBatchTableContext(item: ObjectBrowserRow): boolean { + return item.type === "TABLE" && selectedTableCount.value > 1 && selectedTableIds.value.has(item.id); +} + +function selectedBatchTableCountLabel(key: "batchDrop" | "batchTruncate" | "batchEmpty"): string { + return t(`contextMenu.${key}`, { count: selectedTableCount.value }); +} + function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { + const useBatchActions = isSelectedBatchTableContext(item); return [ { label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 }, { @@ -2103,22 +2167,22 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { ...(supportsTruncateTable.value ? [ { - label: t("contextMenu.truncateTable"), - action: () => requestTruncateTable(item), + label: useBatchActions ? selectedBatchTableCountLabel("batchTruncate") : t("contextMenu.truncateTable"), + action: useBatchActions ? requestBatchTruncateTables : () => requestTruncateTable(item), icon: Scissors, variant: "destructive" as const, }, ] : []), { - label: t("contextMenu.emptyTable"), - action: () => requestEmptyTable(item), + label: useBatchActions ? selectedBatchTableCountLabel("batchEmpty") : t("contextMenu.emptyTable"), + action: useBatchActions ? requestBatchEmptyTables : () => requestEmptyTable(item), icon: Eraser, variant: "destructive" as const, }, { - label: t("contextMenu.dropTable"), - action: () => requestDrop(item), + label: useBatchActions ? selectedBatchTableCountLabel("batchDrop") : t("contextMenu.dropTable"), + action: useBatchActions ? requestBatchDropTables : () => requestDrop(item), icon: Trash2, variant: "destructive" as const, }, @@ -2293,6 +2357,10 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { {{ t("objects.truncateSelected") }} +