import { useCallback, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react"; import { FileTree } from "~/client/components/file-tree"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Button } from "~/client/components/ui/button"; import { Checkbox } from "~/client/components/ui/checkbox"; import { Label } from "~/client/components/ui/label"; import { Input } from "~/client/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "~/client/components/ui/alert-dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; import type { Snapshot, Volume } from "~/client/lib/types"; import { toast } from "sonner"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; type RestoreLocation = "original" | "custom"; interface Props { snapshot: Snapshot; repositoryName: string; volume?: Volume; onDeleteSnapshot?: (snapshotId: string) => void; isDeletingSnapshot?: boolean; } export const SnapshotFileBrowser = (props: Props) => { const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props; const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; const queryClient = useQueryClient(); const [selectedPaths, setSelectedPaths] = useState>(new Set()); const [showRestoreDialog, setShowRestoreDialog] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const [excludeXattr, setExcludeXattr] = useState(""); const [restoreLocation, setRestoreLocation] = useState("original"); const [customTargetPath, setCustomTargetPath] = useState(""); const [overwriteMode, setOverwriteMode] = useState("always"); const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/"; const { data: filesData, isLoading: filesLoading } = useQuery({ ...listSnapshotFilesOptions({ path: { name: repositoryName, snapshotId: snapshot.short_id }, query: { path: volumeBasePath }, }), }); const stripBasePath = useCallback( (path: string): string => { if (!volumeBasePath) return path; if (path === volumeBasePath) return "/"; if (path.startsWith(`${volumeBasePath}/`)) { const stripped = path.slice(volumeBasePath.length); return stripped; } return path; }, [volumeBasePath], ); const addBasePath = useCallback( (displayPath: string): string => { const vbp = volumeBasePath === "/" ? "" : volumeBasePath; if (!vbp) return displayPath; if (displayPath === "/") return vbp; return `${vbp}${displayPath}`; }, [volumeBasePath], ); const fileBrowser = useFileBrowser({ initialData: filesData, isLoading: filesLoading, fetchFolder: async (path) => { return await queryClient.ensureQueryData( listSnapshotFilesOptions({ path: { name: repositoryName, snapshotId: snapshot.short_id }, query: { path }, }), ); }, prefetchFolder: (path) => { queryClient.prefetchQuery( listSnapshotFilesOptions({ path: { name: repositoryName, snapshotId: snapshot.short_id }, query: { path }, }), ); }, pathTransform: { strip: stripBasePath, add: addBasePath, }, }); const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ ...restoreSnapshotMutation(), onSuccess: (data) => { toast.success("Restore completed", { description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`, }); setSelectedPaths(new Set()); }, onError: (error) => { toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" }); }, }); const handleRestoreClick = useCallback(() => { setShowRestoreDialog(true); }, []); const handleConfirmRestore = useCallback(() => { const pathsArray = Array.from(selectedPaths); const includePaths = pathsArray.map((path) => addBasePath(path)); const excludeXattrArray = excludeXattr ?.split(",") .map((s) => s.trim()) .filter(Boolean); const isCustomLocation = restoreLocation === "custom"; const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined; restoreSnapshot({ path: { name: repositoryName }, body: { snapshotId: snapshot.short_id, include: includePaths, delete: deleteExtraFiles, excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, targetPath, overwrite: overwriteMode, }, }); setShowRestoreDialog(false); }, [ selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr, restoreLocation, customTargetPath, overwriteMode, ]); return (
File Browser {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}
{selectedPaths.size > 0 && ( {isReadOnly && (

Volume is mounted as read-only.

Please remount with read-only disabled to restore files.

)}
)} {onDeleteSnapshot && ( )}
{fileBrowser.isLoading && (

Loading files...

)} {fileBrowser.isEmpty && (

No files in this snapshot

)} {!fileBrowser.isLoading && !fileBrowser.isEmpty && (
)}
Confirm Restore {selectedPaths.size > 0 ? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.` : "This will restore everything from the snapshot."}
{restoreLocation === "custom" && (
setCustomTargetPath(e.target.value)} />

Files will be restored directly to this path

)}

{overwriteMode === OVERWRITE_MODES.always && "Existing files will always be replaced with the snapshot version."} {overwriteMode === OVERWRITE_MODES.ifChanged && "Files are only replaced if their content differs from the snapshot."} {overwriteMode === OVERWRITE_MODES.ifNewer && "Files are only replaced if the snapshot version has a newer modification time."} {overwriteMode === OVERWRITE_MODES.never && "Existing files will never be replaced, only missing files are restored."}

{showAdvanced && (
setExcludeXattr(e.target.value)} />

Exclude specific extended attributes during restore (comma-separated)

setDeleteExtraFiles(checked === true)} />
)}
Cancel Confirm Restore
); };