feat(postgres): add table vacuum action

This commit is contained in:
amwps290 authored and GitHub committed 2026-08-20 16:04:22 +08:00
1 parent 0e0edcd206
commit 11407b537f
23 files changed
+554 -36

No files matched your search

@@ -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<ObjectBrowserRow | null>(null);
const truncatePreviewSql = ref("");
const truncateTableCascade = ref(false);
const showVacuumConfirm = ref(false);
const vacuumTarget = ref<ObjectBrowserRow | null>(null);
const vacuumPreviewSql = ref("");
const vacuumTableFull = ref(false);
const vacuumTableAnalyze = ref(false);
const vacuumExecuting = ref(false);
const showEmptyConfirm = ref(false);
const emptyTarget = ref<ObjectBrowserRow | null>(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<SqlFormatDialect>(() => sqlFormatDialectForDbType(effectiveDatabaseType.value));
const objectFilters = computed<ObjectFilter[]>(() =>
@@ -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[] {
</DialogContent>
</Dialog>
<DangerConfirmDialog
v-model:open="showVacuumConfirm"
:title="t('contextMenu.vacuumTableTitle')"
:message="t('contextMenu.vacuumTableMessage', { name: vacuumTarget?.name ?? '' })"
:details-text="vacuumRiskMessage"
:sql="vacuumPreviewSql"
:confirm-label="vacuumExecuting ? t('contextMenu.vacuumTableRunning') : vacuumTableFull ? t('contextMenu.vacuumTableFullConfirm') : t('contextMenu.vacuumTable')"
:loading="vacuumExecuting"
:close-on-confirm="false"
@confirm="confirmVacuumTable"
>
<template #options>
<div class="mb-3 flex gap-3 rounded-md border bg-muted/20 px-3 py-2 text-sm">
<label class="flex items-center gap-2" :title="t('contextMenu.vacuumTableAnalyzeHint')">
<input v-model="vacuumTableAnalyze" :disabled="vacuumExecuting" type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" @change="refreshVacuumPreviewSql" />
<span class="font-medium text-foreground">ANALYZE</span>
</label>
<label class="flex items-center gap-2" :title="t('contextMenu.vacuumTableFullHint')">
<input v-model="vacuumTableFull" :disabled="vacuumExecuting" type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-destructive" @change="refreshVacuumPreviewSql" />
<span class="font-medium" :class="vacuumTableFull ? 'text-destructive' : 'text-foreground'">FULL</span>
</label>
</div>
</template>
</DangerConfirmDialog>
<DangerConfirmDialog
v-model:open="showTruncateConfirm"
:title="t('contextMenu.confirmTruncateTableTitle')"
@@ -61,7 +61,7 @@ import { codeMirrorSqlDialect } from "@/lib/database/jdbcDialect";
import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { createSidebarActionTarget, findSidebarActionTarget, matchesSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion";
import type { SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
import type { SidebarDangerDialogOption, SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
import { resetSidebarTreeDialogState, sidebarDangerRunningExecutionId } from "./sidebarTreeDialogState";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleNacosNamespacesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
import { sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort";
@@ -1609,8 +1609,9 @@ async function confirmSidebarDangerDialog() {
if (!request || sidebarDangerDialogConfirming.value) return;
if (request.closeOnConfirm !== false) sidebarDangerDialogOpen.value = false;
sidebarDangerDialogConfirming.value = true;
let completed: void | boolean = undefined;
try {
await request.confirm();
completed = await request.confirm();
} finally {
// A danger operation that hit a client-observed timeout is kept alive
// (still cancellable) rather than settled outright — sidebarDangerRunningExecutionId
@@ -1620,7 +1621,7 @@ async function confirmSidebarDangerDialog() {
// execution actually settles.
if (!sidebarDangerRunningExecutionId.value) {
sidebarDangerDialogConfirming.value = false;
sidebarDangerDialogOpen.value = false;
if (completed !== false) sidebarDangerDialogOpen.value = false;
}
}
}
@@ -1651,8 +1652,8 @@ async function cancelSidebarDangerDialogRunning() {
}
}
function updateSidebarDangerDialogOption(event: Event) {
const option = sidebarDangerDialogRequest.value?.option;
function updateSidebarDangerDialogOption(event: Event, optionOverride?: SidebarDangerDialogOption) {
const option = optionOverride ?? sidebarDangerDialogRequest.value?.option;
if (!option) return;
option.checked = (event.target as HTMLInputElement).checked;
void option.onChange?.(option.checked);
@@ -2490,6 +2491,17 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
<div class="h-full bg-primary transition-[width] duration-200" :style="{ width: `${Math.round((sidebarDangerDialogRequest.progress.completed / sidebarDangerDialogRequest.progress.total) * 100)}%` }" />
</div>
</div>
<div v-if="sidebarDangerDialogRequest.options?.length" class="mb-3 flex flex-wrap gap-2">
<template v-for="(option, optionIndex) in sidebarDangerDialogRequest.options ?? []" :key="`danger-option-${optionIndex}`">
<label class="flex items-start gap-2 rounded-md border px-3 py-2 text-sm" :class="[option.compact ? 'min-w-32 flex-1' : 'w-full', option.danger && option.checked ? 'border-destructive/50 bg-destructive/10' : 'bg-muted/20']" :title="option.compact ? option.hint : undefined">
<input :checked="option.checked" :disabled="sidebarDangerDialogConfirming" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0" :class="option.danger ? 'accent-destructive' : 'accent-primary'" @change="updateSidebarDangerDialogOption($event, option)" />
<span class="grid gap-0.5">
<span class="font-medium" :class="option.danger && option.checked ? 'text-destructive' : 'text-foreground'">{{ option.label }}</span>
<span v-if="!option.compact" class="text-xs leading-5 text-muted-foreground">{{ option.hint }}</span>
</span>
</label>
</template>
</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">
@@ -197,6 +197,7 @@ import {
isLoadingStructurePreview,
showEmptyTableConfirm,
showTruncateTableConfirm,
showVacuumTableConfirm,
showMysqlAutoIncrementConfirm,
showRenameObjectDialog,
renameObjectName,
@@ -208,6 +209,10 @@ import {
emptyTablePreviewSql,
truncateTablePreviewSql,
truncateTableCascade,
vacuumTableFull,
vacuumTableAnalyze,
vacuumTablePreviewSql,
vacuumTableExecuting,
mysqlAutoIncrementValue,
mysqlAutoIncrementPreviewSql,
dropObjectPreviewSql,
@@ -510,6 +515,7 @@ const {
const {
isTableNotView,
supportsTruncate,
supportsVacuum,
supportsMysqlAutoIncrement,
canDropTableCascade,
canTruncateTableCascade,
@@ -522,6 +528,9 @@ const {
confirmEmptyTable,
truncateTable,
confirmTruncateTable,
vacuumTable,
refreshVacuumPreviewForOptions,
confirmVacuumTable,
mysqlAutoIncrement,
refreshMysqlAutoIncrementPreviewSql,
confirmMysqlAutoIncrement,
@@ -3945,7 +3954,7 @@ function dangerRequest(request: Omit<SidebarDangerDialogRequest, "target">): Sid
const confirm = request.confirm;
routedRequest.confirm = async () => {
activateActionTarget(target);
await confirm();
return confirm();
};
if (request.option?.onChange) {
const onChange = request.option.onChange;
@@ -3954,6 +3963,14 @@ function dangerRequest(request: Omit<SidebarDangerDialogRequest, "target">): Sid
await onChange(checked);
};
}
for (const option of request.options ?? []) {
if (!option.onChange) continue;
const onChange = option.onChange;
option.onChange = async (checked) => {
activateActionTarget(target);
await onChange(checked);
};
}
if (request.textInput?.onInput) {
const onInput = request.textInput.onInput;
request.textInput.onInput = async (value) => {
@@ -4062,6 +4079,49 @@ routeDangerDialog(showTruncateTableConfirm, () =>
}),
);
routeDangerDialog(showVacuumTableConfirm, () =>
dangerRequest({
title: t("contextMenu.vacuumTableTitle"),
message: t("contextMenu.vacuumTableMessage", { name: activeNode.value.label }),
get detailsText() {
if (vacuumTableExecuting.value) return t("contextMenu.vacuumTableRunningHint");
return vacuumTableFull.value ? t("contextMenu.vacuumTableFullRisk") : vacuumTableAnalyze.value ? t("contextMenu.vacuumTableAnalyzeRisk") : t("contextMenu.vacuumTableDefaultRisk");
},
get sql() {
return vacuumTablePreviewSql.value;
},
get confirmLabel() {
return vacuumTableExecuting.value ? t("contextMenu.vacuumTableRunning") : vacuumTableFull.value ? t("contextMenu.vacuumTableFullConfirm") : t("contextMenu.vacuumTable");
},
loading: false,
closeOnConfirm: false,
options: [
{
checked: vacuumTableAnalyze.value,
label: t("contextMenu.vacuumTableAnalyze"),
hint: t("contextMenu.vacuumTableAnalyzeHint"),
compact: true,
async onChange(checked) {
vacuumTableAnalyze.value = checked;
await refreshVacuumPreviewForOptions();
},
},
{
checked: vacuumTableFull.value,
label: t("contextMenu.vacuumTableFull"),
hint: t("contextMenu.vacuumTableFullHint"),
compact: true,
danger: true,
async onChange(checked) {
vacuumTableFull.value = checked;
await refreshVacuumPreviewForOptions();
},
},
],
confirm: confirmVacuumTable,
}),
);
routeDangerDialog(showMysqlAutoIncrementConfirm, () =>
dangerRequest({
title: t("contextMenu.mysqlAutoIncrementTitle"),
@@ -5178,6 +5238,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.push({ label: t("contextMenu.duplicateStructure"), action: duplicateStructure, icon: CopyPlus });
// Keep menu copy aligned with keyboard copy so frozen multi-selection and single-row fallback stay compatible.
items.push(...treeTableClipboardMenuItems(node));
if (supportsVacuum.value) {
destructiveActions.push({ label: t("contextMenu.vacuumTable"), action: vacuumTable, icon: Activity, variant: "destructive" as const });
}
if (supportsMysqlAutoIncrement.value) {
items.push({ label: t("contextMenu.mysqlAutoIncrement"), action: mysqlAutoIncrement, icon: Gauge });
}
@@ -37,6 +37,7 @@ export const structureDocCopyTitle = ref("");
export const isLoadingStructurePreview = ref(false);
export const showEmptyTableConfirm = ref(false);
export const showTruncateTableConfirm = ref(false);
export const showVacuumTableConfirm = ref(false);
export const showMysqlAutoIncrementConfirm = ref(false);
export const showRenameObjectDialog = ref(false);
export const renameObjectName = ref("");
@@ -48,6 +49,11 @@ export const batchDropCascade = ref(false);
export const emptyTablePreviewSql = ref("");
export const truncateTablePreviewSql = ref("");
export const truncateTableCascade = ref(false);
export const vacuumTableFull = ref(false);
export const vacuumTableAnalyze = ref(false);
export const vacuumTablePreviewSql = ref("");
export const vacuumTablePreviewKey = ref("");
export const vacuumTableExecuting = ref(false);
export const mysqlAutoIncrementValue = ref("1");
export const mysqlAutoIncrementPreviewSql = ref("");
export const mysqlAutoIncrementPreviewKey = ref("");
@@ -187,6 +193,7 @@ const openFlags = [
showStructureDocCopyDialog,
showEmptyTableConfirm,
showTruncateTableConfirm,
showVacuumTableConfirm,
showMysqlAutoIncrementConfirm,
showDropObjectConfirm,
showRenameObjectDialog,
@@ -230,6 +237,7 @@ export function resetSidebarTreeDialogState() {
cloneMongoCollectionLoading.value = false;
resetMongoCreateIndexForm();
resetMongoIndexManager();
vacuumTableExecuting.value = false;
sidebarTreeDialogOwner.value = null;
sidebarDangerTarget.value = null;
sidebarFormTarget.value = null;