diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 49285c8aa..0a1b26d27 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -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([]); 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 { + 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[] { + + { + it("continues after a table fails and collects each result", async () => { + const executed: string[] = []; + const result = await runBatchTableEmpty(["orders", "locked", "customers"], async (table) => { + executed.push(table); + if (table === "locked") throw new Error("permission denied"); + }); + + expect(executed).toEqual(["orders", "locked", "customers"]); + expect(result.succeeded).toEqual(["orders", "customers"]); + expect(result.failed.map(({ target }) => target)).toEqual(["locked"]); + }); + + it("uses submitted feedback for asynchronous mutations", () => { + const succeeded = { succeeded: ["events"], failed: [] }; + const partial = { succeeded: ["events"], failed: [{ target: "logs", error: new Error("failed") }] }; + const failed = { succeeded: [], failed: [{ target: "logs", error: new Error("failed") }] }; + + expect(batchTableEmptyFeedback(succeeded, true)).toBe("submitted"); + expect(batchTableEmptyFeedback(partial, true)).toBe("submitted-partial"); + expect(batchTableEmptyFeedback(failed, true)).toBe("submitted-partial"); + expect(batchTableEmptyFeedback(succeeded, false)).toBe("success"); + expect(batchTableEmptyFeedback(partial, false)).toBe("partial"); + expect(batchTableEmptyFeedback(failed, false)).toBe("partial"); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/batchTableEmpty.ts b/apps/desktop/src/lib/sidebar/batchTableEmpty.ts new file mode 100644 index 000000000..6549e0e9c --- /dev/null +++ b/apps/desktop/src/lib/sidebar/batchTableEmpty.ts @@ -0,0 +1,24 @@ +export interface BatchTableEmptyResult { + succeeded: T[]; + failed: Array<{ target: T; error: unknown }>; +} + +export type BatchTableEmptyFeedback = "success" | "partial" | "submitted" | "submitted-partial"; + +export async function runBatchTableEmpty(targets: readonly T[], execute: (target: T) => Promise): Promise> { + const result: BatchTableEmptyResult = { succeeded: [], failed: [] }; + for (const target of targets) { + try { + await execute(target); + result.succeeded.push(target); + } catch (error) { + result.failed.push({ target, error }); + } + } + return result; +} + +export function batchTableEmptyFeedback(result: BatchTableEmptyResult, asynchronousMutation: boolean): BatchTableEmptyFeedback { + if (asynchronousMutation) return result.failed.length > 0 ? "submitted-partial" : "submitted"; + return result.failed.length > 0 ? "partial" : "success"; +}