import { useCallback, useEffect, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react"; import { Button } from "~/client/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "~/client/components/ui/alert-dialog"; import { PathSelector } from "~/client/components/path-selector"; import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser"; import { RestoreProgress } from "~/client/components/restore-progress"; import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events"; import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic"; import type { Repository } from "~/client/lib/types"; import { handleRepositoryError } from "~/client/lib/errors"; import { useNavigate } from "@tanstack/react-router"; import { cn } from "~/client/lib/utils"; type RestoreLocation = "original" | "custom"; interface RestoreFormProps { repository: Repository; snapshotId: string; returnPath: string; basePath?: string; } export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) { const navigate = useNavigate(); const { addEventListener } = useServerEvents(); const volumeBasePath = basePath ?? "/"; const [restoreLocation, setRestoreLocation] = useState("original"); const [customTargetPath, setCustomTargetPath] = useState(""); const [overwriteMode, setOverwriteMode] = useState("always"); const [showAdvanced, setShowAdvanced] = useState(false); const [excludeXattr, setExcludeXattr] = useState(""); const [isRestoreActive, setIsRestoreActive] = useState(false); const [restoreResult, setRestoreResult] = useState(null); const [showRestoreResultAlert, setShowRestoreResultAlert] = useState(false); const restoreCompletedRef = useRef(false); const [selectedPaths, setSelectedPaths] = useState>(new Set()); const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null); useEffect(() => { const abortController = new AbortController(); const signal = abortController.signal; addEventListener( "restore:started", (startedData) => { if (startedData.repositoryId === repository.shortId && startedData.snapshotId === snapshotId) { restoreCompletedRef.current = false; setIsRestoreActive(true); setRestoreResult(null); setShowRestoreResultAlert(false); } }, { signal }, ); addEventListener( "restore:progress", (progressData) => { if (progressData.repositoryId === repository.shortId && progressData.snapshotId === snapshotId) { if (restoreCompletedRef.current) { return; } setIsRestoreActive(true); } }, { signal }, ); addEventListener( "restore:completed", (completedData) => { if (completedData.repositoryId === repository.shortId && completedData.snapshotId === snapshotId) { restoreCompletedRef.current = true; setIsRestoreActive(false); setRestoreResult(completedData); setShowRestoreResultAlert(true); } }, { signal }, ); return () => { abortController.abort(); }; }, [addEventListener, repository.shortId, snapshotId]); const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ ...restoreSnapshotMutation(), onError: (error) => { restoreCompletedRef.current = true; setIsRestoreActive(false); handleRepositoryError("Restore failed", error, repository.shortId); }, }); const handleRestore = useCallback(() => { const excludeXattrArray = excludeXattr ?.split(",") .map((s) => s.trim()) .filter(Boolean); const isCustomLocation = restoreLocation === "custom"; const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined; const includePaths = Array.from(selectedPaths); restoreCompletedRef.current = false; setIsRestoreActive(true); setRestoreResult(null); setShowRestoreResultAlert(false); restoreSnapshot({ path: { shortId: repository.shortId }, body: { snapshotId, include: includePaths.length > 0 ? includePaths : undefined, selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined, excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, targetPath, overwrite: overwriteMode, }, }); }, [ repository.shortId, snapshotId, excludeXattr, restoreLocation, customTargetPath, selectedPaths, selectedPathKind, overwriteMode, restoreSnapshot, ]); const handleDownload = useCallback(() => { if (selectedPaths.size > 1) return; const url = new URL( `/api/v1/repositories/${repository.shortId}/snapshots/${snapshotId}/dump`, window.location.origin, ); const [selectedPath] = selectedPaths; if (selectedPath) { url.searchParams.set("path", selectedPath); if (selectedPathKind) { url.searchParams.set("kind", selectedPathKind); } } window.location.assign(url.toString()); }, [repository.shortId, snapshotId, selectedPathKind, selectedPaths]); const acknowledgeRestoreResult = useCallback(() => { setShowRestoreResultAlert(false); setRestoreResult(null); }, []); const handleResultAlertOpenChange = useCallback((open: boolean) => { if (open) { setShowRestoreResultAlert(true); } }, []); const canRestore = restoreLocation === "original" || customTargetPath.trim(); const canDownload = selectedPaths.size <= 1; const isRestoreRunning = isRestoring || isRestoreActive; function getDownloadButtonText(): string { if (selectedPaths.size > 0) { return `Download ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`; } return "Download All"; } function getRestoreButtonText(): string { if (isRestoreRunning) { return "Restoring..."; } if (selectedPaths.size > 0) { return `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`; } return "Restore All"; } return (

Restore Snapshot

{repository.name} / {snapshotId}

Download is available only for one selected item, or with no selection to download everything.

{isRestoreRunning && } Restore Location Choose where to restore the files
{restoreLocation === "custom" && (

Files will be restored directly to this path

)}
Overwrite Mode How to handle existing files

{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."}

setShowAdvanced(!showAdvanced)}>
Advanced options
{showAdvanced && (
setExcludeXattr(e.target.value)} />

Exclude specific extended attributes during restore (comma-separated)

)}
Select Files to Restore {selectedPaths.size > 0 ? `${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"} selected` : "Select specific files or folders, or leave empty to restore everything"}
{restoreResult?.status === "success" ? "Restore completed" : "Restore failed"} {restoreResult?.status === "success" ? `Snapshot ${snapshotId} was restored successfully.` : restoreResult?.error || `Snapshot ${snapshotId} could not be restored.`} OK
); }