feat(desktop): show batch drop progress

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-29 00:33:48 +08:00
1 parent cadcd163f9
commit ed9dd1072e
11 files changed
+453 -37

No files matched your search

@@ -93,6 +93,7 @@ import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
import { runBatchTableDrop } from "@/lib/table/batchTableDrop";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
buildObjectBrowserRows,
@@ -224,6 +225,8 @@ const procedureExecutionTarget = ref<ObjectBrowserRow | null>(null);
const selectedTableIds = ref<Set<string>>(new Set());
const expandedPartitionParentIds = ref<Set<string>>(new Set());
const showBatchDropConfirm = ref(false);
const batchDropExecuting = ref(false);
const batchDropProgress = ref({ completed: 0, total: 0 });
const batchDropPreviewSql = ref("");
const showBatchTruncateConfirm = ref(false);
const batchTruncatePreviewSql = ref("");
@@ -544,6 +547,7 @@ const selectedTableCount = computed(() => selectedTableRows.value.length);
const canBatchDropCascade = computed(() => selectedTableCount.value > 0 && supportsDropTableCascade(effectiveDatabaseType.value));
const canBatchTruncateCascade = computed(() => selectedTableCount.value > 0 && supportsTruncateTableCascade(effectiveDatabaseType.value));
const allVisibleTablesSelected = computed(() => visibleSelectableRows.value.length > 0 && visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id)));
const batchDropProgressPercent = computed(() => (batchDropProgress.value.total > 0 ? Math.round((batchDropProgress.value.completed / batchDropProgress.value.total) * 100) : 0));
function iconFor(row: ObjectBrowserRow) {
if (row.type === "VIEW" || row.type === "MATERIALIZED_VIEW") return Eye;
@@ -1514,37 +1518,55 @@ async function refreshBatchDropPreviewSql() {
function requestBatchDropTables() {
if (selectedTableCount.value === 0) return;
batchDropCascade.value = false;
batchDropProgress.value = { completed: 0, total: 0 };
batchDropPreviewSql.value = "";
void refreshBatchDropPreviewSql();
showBatchDropConfirm.value = true;
}
async function confirmBatchDropTables() {
const targets = await fetchSortedTableRowsForDrop();
if (targets.length === 0) return;
if (batchDropExecuting.value) return;
batchDropExecuting.value = true;
try {
const targets = await fetchSortedTableRowsForDrop();
if (targets.length === 0) return;
batchDropProgress.value = { completed: 0, total: targets.length };
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
const statements = await Promise.all(
targets.map(async (row) => ({
row,
sql: await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
const plan = await Promise.all(
targets.map(async (target) => ({
target,
sql: await buildDropTableSql(tableAdminSqlOptions(target, { cascade: useCascade })),
})),
);
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
for (const { row, sql } of statements) {
await api.executeQuery(props.connection.id, props.database, sql);
closeDroppedTableObjectTabsForRow(row);
}
return true;
});
if (!executed) return;
toast(t("objects.batchDropSuccess", { count: targets.length }));
removePinnedObjectBrowserRows(targets);
clearTableSelection();
await reload();
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
const batchSql = plan.map(({ sql }) => sql).join(";\n");
const result = await executeObjectBrowserSqlWithProductionGuard(batchSql, () =>
runBatchTableDrop({
databaseType: effectiveDatabaseType.value,
plan,
executeStatement: (sql) => api.executeQuery(props.connection.id, props.database, sql),
executeBatch: (sql, onProgress) => api.executeMultiWithProgress(props.connection.id, props.database, sql, onProgress),
onProgress: (progress) => {
batchDropProgress.value = { completed: Math.min(progress.completed, targets.length), total: targets.length };
},
}),
);
if (!result) return;
for (const row of result.succeeded) closeDroppedTableObjectTabsForRow(row);
if (result.succeeded.length > 0) {
removePinnedObjectBrowserRows(result.succeeded);
await reload();
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
}
if (result.failed) throw result.failed;
toast(t("objects.batchDropSuccess", { count: result.succeeded.length }));
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
} finally {
batchDropExecuting.value = false;
batchDropProgress.value = { completed: 0, total: 0 };
showBatchDropConfirm.value = false;
}
}
@@ -3072,9 +3094,27 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</template>
</DangerConfirmDialog>
<DangerConfirmDialog v-model:open="showBatchDropConfirm" :title="t('objects.confirmBatchDropTitle')" :message="t('objects.confirmBatchDropMessage', { count: selectedTableCount })" :sql="batchDropPreviewSql" :confirm-label="t('objects.dropSelected')" @confirm="confirmBatchDropTables">
<template v-if="canBatchDropCascade" #options>
<label class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
<DangerConfirmDialog
v-model:open="showBatchDropConfirm"
:title="t('objects.confirmBatchDropTitle')"
:message="t('objects.confirmBatchDropMessage', { count: selectedTableCount })"
:sql="batchDropPreviewSql"
:confirm-label="t('objects.dropSelected')"
:loading="batchDropExecuting"
:close-on-confirm="false"
@confirm="confirmBatchDropTables"
>
<template #options>
<div v-if="batchDropExecuting" class="mb-3 rounded-md border bg-muted/20 px-3 py-2.5">
<div class="mb-1.5 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
<span>{{ batchDropProgress.completed }} / {{ batchDropProgress.total }}</span>
<span>{{ batchDropProgressPercent }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="batchDropProgress.total" :aria-valuenow="batchDropProgress.completed">
<div class="h-full bg-primary transition-[width] duration-200" :style="{ width: `${batchDropProgressPercent}%` }" />
</div>
</div>
<label v-if="canBatchDropCascade" class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
<input v-model="batchDropCascade" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="refreshBatchDropPreviewSql()" />
<span class="grid gap-0.5">
<span class="font-medium text-foreground">{{ t("contextMenu.dropTableCascade") }}</span>
@@ -1879,8 +1879,17 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
:close-on-confirm="false"
@confirm="confirmSidebarDangerDialog"
>
<template v-if="sidebarDangerDialogRequest.option" #options>
<label class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
<template #options>
<div v-if="sidebarDangerDialogConfirming && sidebarDangerDialogRequest.progress" class="mb-3 rounded-md border bg-muted/20 px-3 py-2.5">
<div class="mb-1.5 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
<span>{{ sidebarDangerDialogRequest.progress.completed }} / {{ sidebarDangerDialogRequest.progress.total }}</span>
<span>{{ Math.round((sidebarDangerDialogRequest.progress.completed / sidebarDangerDialogRequest.progress.total) * 100) }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="sidebarDangerDialogRequest.progress.total" :aria-valuenow="sidebarDangerDialogRequest.progress.completed">
<div class="h-full bg-primary transition-[width] duration-200" :style="{ width: `${Math.round((sidebarDangerDialogRequest.progress.completed / sidebarDangerDialogRequest.progress.total) * 100)}%` }" />
</div>
</div>
<label v-if="sidebarDangerDialogRequest.option" class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
<input :checked="sidebarDangerDialogRequest.option.checked" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="updateSidebarDangerDialogOption" />
<span class="grid gap-0.5">
<span class="font-medium text-foreground">{{ sidebarDangerDialogRequest.option.label }}</span>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, watch, onBeforeUnmount, inject, reactive, shallowRef } from "vue";
import { computed, nextTick, watch, onBeforeUnmount, inject, reactive, ref, shallowRef } from "vue";
import { createRoutedSidebarDialogController } from "./sidebarDialogControllerRouting";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useSidebarDataOpenRuntime } from "@/composables/useSidebarDataOpenRuntime";
@@ -134,6 +134,7 @@ import { connectionSupportsServerDashboard as connectionSupportsPgServerDashboar
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty";
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
import { runBatchTableDrop } from "@/lib/table/batchTableDrop";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { copyToClipboard } from "@/lib/common/clipboard";
import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/savedSqlHistory";
@@ -412,6 +413,8 @@ const { isTableNotView, supportsTruncate, canDropTableCascade, canTruncateTableC
refreshMutatedTableDataTabsForNode,
});
const batchDropProgress = ref({ completed: 0, total: 0 });
const treeItemDialogOwner = Symbol("sidebar-tree-dialog-owner");
function claimTreeItemDialogOwnership() {
@@ -1726,6 +1729,7 @@ function requestBatchDrop() {
if (!targets.length) return;
batchDropTargets.value = targets.slice();
batchDropCascade.value = false;
batchDropProgress.value = { completed: 0, total: 0 };
void refreshBatchDropPreviewSql();
showBatchDropConfirm.value = true;
}
@@ -1979,6 +1983,46 @@ async function confirmBatchDrop() {
return;
}
const useCascade = batchDropCascade.value && targets.every((node) => node.type !== "table" || supportsDropTableCascade(databaseTypeForNode(node)));
if (targets.every((node) => node.type === "table" && node.connectionId && node.database)) {
const first = targets[0]!;
await connectionStore.ensureConnected(first.connectionId!);
batchDropProgress.value = { completed: 0, total: targets.length };
const plan = await Promise.all(
targets.map(async (target) => {
const sql = await dropSqlForTreeNode(target, { cascade: useCascade });
if (!sql) throw new Error("Drop table SQL is unavailable");
return { target, sql };
}),
);
const batchSql = plan.map(({ sql }) => sql).join(";\n");
const result = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(first.connectionId!),
database: first.database!,
sql: batchSql,
source: t("production.sourceSidebar"),
execute: () =>
runBatchTableDrop({
databaseType: databaseTypeForNode(first),
plan,
executeStatement: (sql) => api.executeQuery(first.connectionId!, first.database!, sql),
executeBatch: (sql, onProgress) => api.executeMultiWithProgress(first.connectionId!, first.database!, sql, onProgress),
onProgress: (progress) => {
batchDropProgress.value = { completed: Math.min(progress.completed, targets.length), total: targets.length };
},
}),
});
if (!result) return;
for (const target of result.succeeded) {
closeDroppedTableObjectTabsForNode(target);
connectionStore.removeTreeNode(target.id);
releaseActiveNodeReference([target.id]);
}
if (result.failed) throw result.failed;
toast(t("contextMenu.batchDropSuccess", { count: result.succeeded.length }), 3000);
showBatchDropConfirm.value = false;
return;
}
for (const target of targets) {
if (!target.connectionId || !target.database) continue;
await connectionStore.ensureConnected(target.connectionId);
@@ -3186,6 +3230,9 @@ routeDangerDialog(showBatchDropConfirm, () =>
return batchDropPreviewSql.value;
},
confirmLabel: batchDropMenuLabel(),
get progress() {
return batchDropProgress.value.total > 0 ? batchDropProgress.value : undefined;
},
option: canBatchDropCascade.value
? {
checked: batchDropCascade.value,