feat: support emptying multiple selected tables

* Feature: support emptying multiple selected tables

* ci: retrigger checks

* Fix batch empty safety and ClickHouse feedback

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipgandzipg authored and GitHub committed 2026-07-10 13:36:40 +08:00
1 parent 903ab26c9b
commit 7b2f969356
10 files changed
+204 -3

No files matched your search

@@ -146,6 +146,7 @@ import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selec
import { supportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
import { canCloseSidebarDatabaseConnection, isSidebarDatabaseOpened } from "@/lib/sidebar/sidebarDatabaseOpenState";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue";
@@ -1747,6 +1748,7 @@ async function duplicateConnection() {
const showDropTableConfirm = ref(false);
const showDropTableChildObjectConfirm = ref(false);
const showBatchDropConfirm = ref(false);
const showBatchEmptyConfirm = ref(false);
const showBatchTruncateConfirm = ref(false);
const showStructurePreviewDialog = ref(false);
const showStructureDocCopyDialog = ref(false);
@@ -1772,6 +1774,8 @@ const truncateTableCascade = ref(false);
const dropObjectPreviewSql = ref("");
const dropTableChildObjectPreviewSql = ref("");
const batchDropPreviewSql = ref("");
const batchEmptyPreviewSql = ref("");
const batchEmptyTargets = ref<TreeNode[]>([]);
const batchTruncatePreviewSql = ref("");
const batchTruncateCascade = ref(false);
const dropDatabasePreviewSql = ref("");
@@ -2077,6 +2081,10 @@ function selectedBatchTruncateTargets(): TreeNode[] {
return targets.every((node) => supportsTableTruncate(databaseTypeForNode(node))) ? targets : [];
}
function selectedBatchEmptyTargets(): TreeNode[] {
return selectedBatchTableTargets();
}
function selectedBatchMongoIndexTargets(): TreeNode[] {
const targets = selectedBatchDropTargets();
return targets.length > 1 && targets.every((node) => canDropMongoIndexNode(node)) ? targets : [];
@@ -2118,6 +2126,22 @@ function batchTruncateMenuLabel(): string {
return t("contextMenu.batchTruncate", { count: selectedBatchTruncateTargets().length });
}
function batchEmptyMenuLabel(): string {
return t("contextMenu.batchEmpty", { count: selectedBatchEmptyTargets().length });
}
function batchEmptyConfirmTitle(): string {
return t("contextMenu.confirmBatchEmptyTitle", { count: batchEmptyTargets.value.length });
}
function batchEmptyConfirmMessage(): string {
return t("contextMenu.confirmBatchEmptyMessage", { count: batchEmptyTargets.value.length });
}
function batchEmptyConfirmLabel(): string {
return t("contextMenu.batchEmpty", { count: batchEmptyTargets.value.length });
}
function batchTruncateConfirmTitle(): string {
return t("contextMenu.confirmBatchTruncateTitle", { count: selectedBatchTruncateTargets().length });
}
@@ -2155,6 +2179,15 @@ async function truncateSqlForTreeNode(node: TreeNode, options?: { cascade?: bool
});
}
async function emptySqlForTreeNode(node: TreeNode): Promise<string | null> {
if (node.type !== "table" || !node.connectionId || !node.database) return null;
return buildEmptyTableSql({
databaseType: databaseTypeForNode(node),
schema: node.schema,
tableName: node.label,
});
}
async function refreshBatchDropPreviewSql() {
const targets = selectedBatchDropTargets();
const mongoIndexTargets = selectedBatchMongoIndexTargets();
@@ -2182,6 +2215,15 @@ async function refreshBatchTruncatePreviewSql() {
batchTruncatePreviewSql.value = statements.join("\n");
}
async function refreshBatchEmptyPreviewSql(targets: TreeNode[]) {
const statements: string[] = [];
for (const target of targets) {
const sql = await emptySqlForTreeNode(target);
if (sql) statements.push(sql);
}
batchEmptyPreviewSql.value = statements.join("\n");
}
function requestBatchDrop() {
if (!selectedBatchDropTargets().length) return;
batchDropCascade.value = false;
@@ -2196,6 +2238,22 @@ function requestBatchTruncate() {
showBatchTruncateConfirm.value = true;
}
function requestBatchEmpty() {
const targets = selectedBatchEmptyTargets();
if (!targets.length) return;
batchEmptyTargets.value = targets.slice();
batchEmptyPreviewSql.value = "";
void refreshBatchEmptyPreviewSql(batchEmptyTargets.value)
.then(() => {
if (!batchEmptyPreviewSql.value.trim()) throw new Error("Empty table SQL preview is unavailable");
showBatchEmptyConfirm.value = true;
})
.catch((e: any) => {
batchEmptyTargets.value = [];
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
});
}
function requestDropSelectedNodes(): boolean {
const selected = selectedTreeNodesInVisibleOrder();
if (selected.length > 1 && selected.some((node) => node.id === props.node.id)) {
@@ -2424,6 +2482,34 @@ async function confirmBatchTruncate() {
}
}
async function confirmBatchEmpty() {
const targets = batchEmptyTargets.value.slice();
if (!targets.length) return;
const asynchronousMutation = targets.every((target) => databaseTypeForNode(target) === "clickhouse");
const result = await runBatchTableEmpty(targets, async (target) => {
if (!target.connectionId || !target.database) throw new Error("Missing table connection context");
await connectionStore.ensureConnected(target.connectionId);
const sql = await emptySqlForTreeNode(target);
if (!sql) throw new Error("Empty table SQL is unavailable");
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
});
for (const failure of result.failed) {
console.error(`Failed to empty table "${failure.target.label}":`, 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);
}
batchEmptyTargets.value = [];
showBatchEmptyConfirm.value = false;
}
const isTableNotView = computed(() => props.node.type === "table" && !isSqlServerLinkedNode(props.node));
const supportsTruncate = computed(() => {
@@ -2617,7 +2703,8 @@ async function confirmEmptyTable() {
await connectionStore.ensureConnected(node.connectionId);
const sql = emptyTablePreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions()));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
toast(t("contextMenu.emptyTableSuccess", { name: node.label }), 3000);
const messageKey = currentDatabaseType() === "clickhouse" ? "contextMenu.emptyTableSubmitted" : "contextMenu.emptyTableSuccess";
toast(t(messageKey, { name: node.label }), 3000);
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
}
@@ -4505,11 +4592,14 @@ function treeItemMenuItems(): ContextMenuItem[] {
const node = props.node;
const items: ContextMenuItem[] = [];
const batchDropCount = selectedBatchDropTargets().length;
const batchEmptyCount = selectedBatchEmptyTargets().length;
const batchTruncateCount = selectedBatchTruncateTargets().length;
const deleteMenuLabel = (singleLabel: string) => (batchDropCount > 1 ? batchDropMenuLabel() : singleLabel);
const deleteMenuAction = (singleAction: () => void) => (batchDropCount > 1 ? requestBatchDrop : singleAction);
const truncateMenuLabel = (singleLabel: string) => (batchTruncateCount > 1 ? batchTruncateMenuLabel() : singleLabel);
const truncateMenuAction = (singleAction: () => void) => (batchTruncateCount > 1 ? requestBatchTruncate : singleAction);
const emptyMenuLabel = (singleLabel: string) => (batchEmptyCount > 1 ? batchEmptyMenuLabel() : singleLabel);
const emptyMenuAction = (singleAction: () => void) => (batchEmptyCount > 1 ? requestBatchEmpty : singleAction);
// 1. Pin toggle
if (canPin.value) {
@@ -4934,8 +5024,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
});
}
destructiveActions.push({
label: t("contextMenu.emptyTable"),
action: emptyTable,
label: emptyMenuLabel(t("contextMenu.emptyTable")),
action: emptyMenuAction(emptyTable),
icon: Eraser,
variant: "destructive" as const,
});
@@ -5388,6 +5478,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
<DangerConfirmDialog v-model:open="showEmptyTableConfirm" :title="t('contextMenu.confirmEmptyTableTitle')" :message="t('contextMenu.confirmEmptyTableMessage', { name: node.label })" :sql="emptyTablePreviewSql" :confirm-label="t('contextMenu.emptyTable')" @confirm="confirmEmptyTable" />
<DangerConfirmDialog v-model:open="showBatchEmptyConfirm" :title="batchEmptyConfirmTitle()" :message="batchEmptyConfirmMessage()" :sql="batchEmptyPreviewSql" :confirm-label="batchEmptyConfirmLabel()" @confirm="confirmBatchEmpty" />
<DangerConfirmDialog
v-model:open="showTruncateTableConfirm"
:title="t('contextMenu.confirmTruncateTableTitle')"