From 11407b537f584155b3610612bf50abb890cf925e Mon Sep 17 00:00:00 2001 From: amwps290 Date: Thu, 20 Aug 2026 16:04:22 +0800 Subject: [PATCH] feat(postgres): add table vacuum action --- .../src/components/objects/ObjectBrowser.vue | 135 +++++++++++++++--- .../src/components/sidebar/ConnectionTree.vue | 22 ++- .../sidebar/SidebarTreeRuntimeHost.vue | 65 ++++++++- .../sidebar/sidebarTreeDialogState.ts | 8 ++ .../useSidebarTableMutationRuntime.ts | 93 +++++++++++- apps/desktop/src/i18n/locales/en.ts | 14 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 14 ++ .../database/databaseFeatureSupport.spec.ts | 15 ++ .../lib/__tests__/database/dbAdminSql.spec.ts | 25 ++++ apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 17 ++- apps/desktop/src/lib/backend/tauri.ts | 17 ++- .../__tests__/productionSafety.spec.ts | 12 ++ .../lib/database/databaseCapabilitySets.ts | 2 + .../lib/database/databaseFeatureSupport.ts | 6 +- apps/desktop/src/lib/database/dbAdminSql.ts | 12 ++ .../src/lib/database/productionSafety.ts | 18 ++- .../src/lib/sidebar/sidebarDangerDialog.ts | 5 +- crates/dbx-core/src/db_admin_sql.rs | 92 ++++++++++++ crates/dbx-web/src/main.rs | 1 + crates/dbx-web/src/routes/query.rs | 10 ++ src-tauri/src/commands/query.rs | 5 + src-tauri/src/lib.rs | 1 + 23 files changed, 554 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 26ae67c7a2..a03fbc9b08 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -3,6 +3,7 @@ import { computed, createApp, nextTick, onActivated, onBeforeUnmount, ref, watch import { RecycleScroller } from "vue-virtual-scroller"; import { useSqlHighlighter } from "@/composables/useSqlHighlighter"; import { + Activity, ArrowDown, ArrowRightLeft, ArrowUp, @@ -61,7 +62,7 @@ import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue"; import * as api from "@/lib/backend/api"; import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database"; import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort"; -import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities"; +import { isSchemaAware, supportsTableVacuum, supportsTransfer } from "@/lib/database/databaseCapabilities"; import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport"; import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; @@ -73,6 +74,7 @@ import { buildCopyTableDataSql, buildEmptyTableSql, buildTruncateTableSql, + buildVacuumTableSql, supportsDropTableCascade, supportsTruncateTableCascade, type TableAdminSqlOptions, @@ -246,6 +248,12 @@ const showTruncateConfirm = ref(false); const truncateTarget = ref(null); const truncatePreviewSql = ref(""); const truncateTableCascade = ref(false); +const showVacuumConfirm = ref(false); +const vacuumTarget = ref(null); +const vacuumPreviewSql = ref(""); +const vacuumTableFull = ref(false); +const vacuumTableAnalyze = ref(false); +const vacuumExecuting = ref(false); const showEmptyConfirm = ref(false); const emptyTarget = ref(null); const emptyPreviewSql = ref(""); @@ -299,6 +307,8 @@ const canOpenStructureEditor = computed(() => supportsTableStructureEditing(tabl const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value)); const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value)); const supportsTruncateTable = computed(() => supportsTableTruncate(effectiveDatabaseType.value)); +const supportsVacuumTable = computed(() => !props.connection.read_only && supportsTableVacuum(effectiveDatabaseType.value)); +const vacuumRiskMessage = computed(() => (vacuumExecuting.value ? t("contextMenu.vacuumTableRunningHint") : vacuumTableFull.value ? t("contextMenu.vacuumTableFullRisk") : vacuumTableAnalyze.value ? t("contextMenu.vacuumTableAnalyzeRisk") : t("contextMenu.vacuumTableDefaultRisk"))); const sourceDialect = computed(() => codeMirrorSqlDialect(effectiveDatabaseType.value)); const sourceFormatDialect = computed(() => sqlFormatDialectForDbType(effectiveDatabaseType.value)); const objectFilters = computed(() => @@ -2356,6 +2366,55 @@ async function confirmTruncateTable() { truncateTarget.value = null; } +async function refreshVacuumPreviewSql() { + const row = vacuumTarget.value; + vacuumPreviewSql.value = ""; + if (!row) return; + vacuumPreviewSql.value = await buildVacuumTableSql({ + databaseType: effectiveDatabaseType.value, + schema: row.schema || selectedSchema.value, + tableName: row.name, + full: vacuumTableFull.value, + analyze: vacuumTableAnalyze.value, + }).catch(() => ""); +} + +function requestVacuumTable(row: ObjectBrowserRow) { + if (!supportsVacuumTable.value) return; + vacuumTarget.value = row; + vacuumTableFull.value = false; + vacuumTableAnalyze.value = false; + vacuumPreviewSql.value = ""; + void refreshVacuumPreviewSql(); + showVacuumConfirm.value = true; +} + +async function confirmVacuumTable() { + const row = vacuumTarget.value; + if (!row || vacuumExecuting.value) return; + vacuumExecuting.value = true; + try { + const sql = + vacuumPreviewSql.value || + (await buildVacuumTableSql({ + databaseType: effectiveDatabaseType.value, + schema: row.schema || selectedSchema.value, + tableName: row.name, + full: vacuumTableFull.value, + analyze: vacuumTableAnalyze.value, + })); + const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, row.schema || selectedSchema.value)); + if (!executed) return; + toast(t("contextMenu.vacuumTableSuccess", { name: row.name })); + showVacuumConfirm.value = false; + vacuumTarget.value = null; + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } finally { + vacuumExecuting.value = false; + } +} + async function refreshEmptyPreviewSql(row: ObjectBrowserRow) { emptyPreviewSql.value = ""; emptyPreviewSql.value = await buildEmptyTableSql(tableAdminSqlOptions(row)).catch(() => ""); @@ -2759,6 +2818,32 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { ]; } const useBatchActions = isSelectedBatchTableContext(item); + const moreActions: ContextMenuItem[] = []; + if (supportsVacuumTable.value) { + moreActions.push({ label: t("contextMenu.vacuumTable"), action: () => requestVacuumTable(item), icon: Activity, variant: "destructive" as const }); + } + if (supportsTruncateTable.value) { + moreActions.push({ + label: useBatchActions ? selectedBatchTableCountLabel("batchTruncate") : t("contextMenu.truncateTable"), + action: useBatchActions ? requestBatchTruncateTables : () => requestTruncateTable(item), + icon: Scissors, + variant: "destructive" as const, + }); + } + moreActions.push( + { + label: useBatchActions ? selectedBatchTableCountLabel("batchEmpty") : t("contextMenu.emptyTable"), + action: useBatchActions ? requestBatchEmptyTables : () => requestEmptyTable(item), + icon: Eraser, + variant: "destructive" as const, + }, + { + label: useBatchActions ? selectedBatchTableCountLabel("batchDrop") : t("contextMenu.dropTable"), + action: useBatchActions ? requestBatchDropTables : () => requestDrop(item), + icon: Trash2, + variant: "destructive" as const, + }, + ); return [ { label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 }, { @@ -2780,28 +2865,7 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { { label: t("contextMenu.duplicateStructure"), action: () => requestDuplicateStructure(item), icon: CopyPlus }, ...tableClipboardMenuItems(item), { label: "", separator: true }, - ...(supportsTruncateTable.value - ? [ - { - label: useBatchActions ? selectedBatchTableCountLabel("batchTruncate") : t("contextMenu.truncateTable"), - action: useBatchActions ? requestBatchTruncateTables : () => requestTruncateTable(item), - icon: Scissors, - variant: "destructive" as const, - }, - ] - : []), - { - label: useBatchActions ? selectedBatchTableCountLabel("batchEmpty") : t("contextMenu.emptyTable"), - action: useBatchActions ? requestBatchEmptyTables : () => requestEmptyTable(item), - icon: Eraser, - variant: "destructive" as const, - }, - { - label: useBatchActions ? selectedBatchTableCountLabel("batchDrop") : t("contextMenu.dropTable"), - action: useBatchActions ? requestBatchDropTables : () => requestDrop(item), - icon: Trash2, - variant: "destructive" as const, - }, + { label: t("common.more"), icon: ListTree, children: moreActions }, { label: "", separator: true }, { label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy }, ]; @@ -3541,6 +3605,31 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { + + + + +
+ +