feat: add delete backup functionality with confirmation dialog

This commit is contained in:
Jason
2026-02-26 22:06:10 +08:00
parent 3590df68b8
commit 01cc766a05
9 changed files with 216 additions and 14 deletions

View File

@@ -168,3 +168,9 @@ pub fn rename_db_backup(
) -> Result<String, String> {
Database::rename_backup(&oldFilename, &newName).map_err(|e| e.to_string())
}
/// Delete a database backup file
#[tauri::command]
pub fn delete_db_backup(filename: String) -> Result<(), String> {
Database::delete_backup(&filename).map_err(|e| e.to_string())
}

View File

@@ -531,4 +531,29 @@ impl Database {
log::info!("Renamed backup: {old_filename} -> {new_filename}");
Ok(new_filename)
}
/// Delete a backup file permanently.
pub fn delete_backup(filename: &str) -> Result<(), AppError> {
// Validate filename (path traversal + .db suffix)
if filename.contains("..")
|| filename.contains('/')
|| filename.contains('\\')
|| !filename.ends_with(".db")
{
return Err(AppError::InvalidInput(
"Invalid backup filename".to_string(),
));
}
let backup_path = get_app_config_dir().join("backups").join(filename);
if !backup_path.exists() {
return Err(AppError::InvalidInput(format!(
"Backup file not found: {filename}"
)));
}
fs::remove_file(&backup_path).map_err(|e| AppError::io(&backup_path, e))?;
log::info!("Deleted backup: {filename}");
Ok(())
}
}

View File

@@ -911,9 +911,11 @@ pub fn run() {
commands::save_file_dialog,
commands::open_file_dialog,
commands::open_zip_file_dialog,
commands::create_db_backup,
commands::list_db_backups,
commands::restore_db_backup,
commands::rename_db_backup,
commands::delete_db_backup,
commands::sync_current_providers_live,
// Deep link import
commands::parse_deeplink,

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Pencil, RotateCcw, Check, X } from "lucide-react";
import { Pencil, RotateCcw, Check, X, Download, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -67,9 +67,20 @@ export function BackupListSection({
onSettingsChange,
}: BackupListSectionProps) {
const { t } = useTranslation();
const { backups, isLoading, restore, isRestoring, rename, isRenaming } =
useBackupManager();
const {
backups,
isLoading,
create,
isCreating,
restore,
isRestoring,
rename,
isRenaming,
remove,
isDeleting,
} = useBackupManager();
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
const [deleteFilename, setDeleteFilename] = useState<string | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
@@ -110,6 +121,26 @@ export function BackupListSection({
setEditValue("");
};
const handleDelete = async () => {
if (!deleteFilename) return;
try {
await remove(deleteFilename);
setDeleteFilename(null);
toast.success(
t("settings.backupManager.deleteSuccess", {
defaultValue: "Backup deleted",
}),
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("settings.backupManager.deleteFailed", {
defaultValue: "Delete failed",
});
toast.error(detail);
}
};
const handleConfirmRename = async () => {
if (!editingFilename || !editValue.trim()) return;
try {
@@ -221,11 +252,45 @@ export function BackupListSection({
{/* Backup list */}
<div>
<h4 className="text-sm font-medium mb-2">
{t("settings.backupManager.title", {
defaultValue: "Database Backups",
})}
</h4>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">
{t("settings.backupManager.title", {
defaultValue: "Database Backups",
})}
</h4>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
disabled={isCreating || isRestoring}
onClick={async () => {
try {
await create();
toast.success(
t("settings.backupManager.createSuccess", {
defaultValue: "Backup created successfully",
}),
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("settings.backupManager.createFailed", {
defaultValue: "Backup failed",
});
toast.error(detail);
}
}}
>
<Download className="h-3 w-3 mr-1" />
{isCreating
? t("settings.backupManager.creating", {
defaultValue: "Backing up...",
})
: t("settings.backupManager.createBackup", {
defaultValue: "Backup Now",
})}
</Button>
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground py-2">Loading...</div>
@@ -298,18 +363,30 @@ export function BackupListSection({
size="icon"
className="h-7 w-7"
onClick={() => handleStartRename(backup.filename)}
disabled={isRestoring || isRenaming}
disabled={isRestoring || isRenaming || isDeleting}
title={t("settings.backupManager.rename", {
defaultValue: "Rename",
})}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setDeleteFilename(backup.filename)}
disabled={isRestoring || isDeleting}
title={t("settings.backupManager.delete", {
defaultValue: "Delete",
})}
>
<Trash2 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={isRestoring}
disabled={isRestoring || isDeleting}
onClick={() => setConfirmFilename(backup.filename)}
>
<RotateCcw className="h-3 w-3 mr-1" />
@@ -329,7 +406,7 @@ export function BackupListSection({
)}
</div>
{/* Confirmation Dialog */}
{/* Restore Confirmation Dialog */}
<Dialog
open={!!confirmFilename}
onOpenChange={(open) => !open && setConfirmFilename(null)}
@@ -368,6 +445,50 @@ export function BackupListSection({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={!!deleteFilename}
onOpenChange={(open) => !open && setDeleteFilename(null)}
>
<DialogContent className="max-w-md" zIndex="alert">
<DialogHeader>
<DialogTitle>
{t("settings.backupManager.deleteConfirmTitle", {
defaultValue: "Confirm Delete",
})}
</DialogTitle>
<DialogDescription>
{t("settings.backupManager.deleteConfirmMessage", {
defaultValue:
"This backup will be permanently deleted. This action cannot be undone.",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteFilename(null)}
disabled={isDeleting}
>
{t("common.cancel", { defaultValue: "Cancel" })}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting
? t("settings.backupManager.deleting", {
defaultValue: "Deleting...",
})
: t("settings.backupManager.delete", {
defaultValue: "Delete",
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -13,6 +13,11 @@ export function useBackupManager() {
queryFn: () => backupsApi.listDbBackups(),
});
const createMutation = useMutation({
mutationFn: () => backupsApi.createDbBackup(),
onSuccess: () => refetch(),
});
const restoreMutation = useMutation({
mutationFn: (filename: string) => backupsApi.restoreDbBackup(filename),
onSuccess: async () => {
@@ -34,12 +39,21 @@ export function useBackupManager() {
onSuccess: () => refetch(),
});
const deleteMutation = useMutation({
mutationFn: (filename: string) => backupsApi.deleteDbBackup(filename),
onSuccess: () => refetch(),
});
return {
backups,
isLoading,
create: createMutation.mutateAsync,
isCreating: createMutation.isPending,
restore: restoreMutation.mutateAsync,
isRestoring: restoreMutation.isPending,
rename: renameMutation.mutateAsync,
isRenaming: renameMutation.isPending,
remove: deleteMutation.mutateAsync,
isDeleting: deleteMutation.isPending,
};
}

View File

@@ -308,7 +308,17 @@
"rename": "Rename",
"renameSuccess": "Backup renamed",
"renameFailed": "Rename failed",
"namePlaceholder": "Enter new name"
"namePlaceholder": "Enter new name",
"createBackup": "Backup Now",
"creating": "Backing up...",
"createSuccess": "Backup created successfully",
"createFailed": "Backup failed",
"delete": "Delete",
"deleting": "Deleting...",
"deleteSuccess": "Backup deleted",
"deleteFailed": "Delete failed",
"deleteConfirmTitle": "Confirm Delete",
"deleteConfirmMessage": "This backup will be permanently deleted. This action cannot be undone."
},
"webdavSync": {
"title": "WebDAV Cloud Sync",

View File

@@ -308,7 +308,17 @@
"rename": "名前変更",
"renameSuccess": "バックアップの名前を変更しました",
"renameFailed": "名前変更に失敗しました",
"namePlaceholder": "新しい名前を入力"
"namePlaceholder": "新しい名前を入力",
"createBackup": "今すぐバックアップ",
"creating": "バックアップ中...",
"createSuccess": "バックアップが作成されました",
"createFailed": "バックアップに失敗しました",
"delete": "削除",
"deleting": "削除中...",
"deleteSuccess": "バックアップを削除しました",
"deleteFailed": "削除に失敗しました",
"deleteConfirmTitle": "バックアップの削除を確認",
"deleteConfirmMessage": "このバックアップは完全に削除されます。この操作は取り消せません。"
},
"webdavSync": {
"title": "WebDAV クラウド同期",

View File

@@ -308,7 +308,17 @@
"rename": "重命名",
"renameSuccess": "备份重命名成功",
"renameFailed": "重命名失败",
"namePlaceholder": "输入新名称"
"namePlaceholder": "输入新名称",
"createBackup": "立即备份",
"creating": "备份中...",
"createSuccess": "备份创建成功",
"createFailed": "备份创建失败",
"delete": "删除",
"deleting": "删除中...",
"deleteSuccess": "备份已删除",
"deleteFailed": "删除失败",
"deleteConfirmTitle": "确认删除备份",
"deleteConfirmMessage": "此备份将被永久删除,此操作无法撤消。"
},
"webdavSync": {
"title": "WebDAV 云同步",

View File

@@ -238,4 +238,8 @@ export const backupsApi = {
async renameDbBackup(oldFilename: string, newName: string): Promise<string> {
return await invoke("rename_db_backup", { oldFilename, newName });
},
async deleteDbBackup(filename: string): Promise<void> {
await invoke("delete_db_backup", { filename });
},
};