fix(objects): add safe object browser batch empty action

This commit is contained in:
zipg authored and GitHub committed 2026-07-11 17:48:45 +08:00
1 parent cb51832d7b
commit 085d7661af
3 files changed
+118 -7

No files matched your search

@@ -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<BatchTableEmptyPlanItem<ObjectBrowserRow>[]>([]);
// Paste table dialog state
const showPasteDialog = ref(false);
const pasteTableMode = ref<PasteTableMode>("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[] {
<Scissors class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.truncateSelected") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
<Eraser class="mr-1.5 h-3.5 w-3.5" />
{{ t("contextMenu.batchEmpty", { count: selectedTableCount }) }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.dropSelected") }}
@@ -2774,6 +2842,15 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</template>
</DangerConfirmDialog>
<DangerConfirmDialog
v-model:open="showBatchEmptyConfirm"
:title="t('contextMenu.confirmBatchEmptyTitle', { count: batchEmptyPlan.length })"
:message="t('contextMenu.confirmBatchEmptyMessage', { count: batchEmptyPlan.length })"
:sql="batchEmptyPreviewSql"
:confirm-label="t('contextMenu.batchEmpty', { count: batchEmptyPlan.length })"
@confirm="confirmBatchEmptyTables"
/>
<Dialog v-model:open="showRenameDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>