feat: add custom restore target directory

Adds the ability to restore snapshots to a custom directory instead of
only the original path. Closes #12.

Changes:
- Add target parameter to restore API endpoint
- Add directory picker UI in file browser restore dialog
- Add target input field in snapshot restore form
- Create reusable PathSelector component

Note: Run `bun run gen:api-client` after merging to regenerate types.
This commit is contained in:
Deepseek1 2025-11-28 12:09:14 +01:00 committed by hugo
parent 8e90c4ace1
commit 1f9826fe72
6 changed files with 91 additions and 3 deletions

View file

@ -0,0 +1,52 @@
import { useState } from "react";
import { DirectoryBrowser } from "./directory-browser";
import { Button } from "./ui/button";
type Props = {
value: string;
onChange: (path: string) => void;
label?: string;
};
/**
* A reusable path selector component that shows the selected path
* with a "Change" button, and expands to a DirectoryBrowser when clicked.
* Matches the pattern used in the volume creation form.
*/
export const PathSelector = ({ value, onChange, label = "Selected path:" }: Props) => {
const [showBrowser, setShowBrowser] = useState(false);
if (showBrowser) {
return (
<div className="space-y-2">
<DirectoryBrowser
onSelectPath={(path) => {
onChange(path);
setShowBrowser(false);
}}
selectedPath={value}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowBrowser(false)}
>
Cancel
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="flex-1 border rounded-md p-3 bg-muted/50">
<div className="text-xs font-medium text-muted-foreground mb-1">{label}</div>
<div className="text-sm font-mono break-all">{value}</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setShowBrowser(true)}>
Change
</Button>
</div>
);
};

View file

@ -18,6 +18,7 @@ import {
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { PathSelector } from "~/client/components/path-selector";
import type { Snapshot, Volume } from "~/client/lib/types";
import { toast } from "sonner";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
@ -42,6 +43,7 @@ export const SnapshotFileBrowser = (props: Props) => {
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [excludeXattr, setExcludeXattr] = useState("");
const [targetPath, setTargetPath] = useState("/");
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
@ -131,6 +133,7 @@ export const SnapshotFileBrowser = (props: Props) => {
path: { name: repositoryName },
body: {
snapshotId: snapshot.short_id,
target: targetPath || undefined,
include: includePaths,
delete: deleteExtraFiles,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
@ -138,7 +141,7 @@ export const SnapshotFileBrowser = (props: Props) => {
});
setShowRestoreDialog(false);
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]);
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr, targetPath]);
return (
<div className="space-y-4">
@ -232,6 +235,17 @@ export const SnapshotFileBrowser = (props: Props) => {
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm">Target Directory</Label>
<PathSelector
value={targetPath}
onChange={setTargetPath}
label="Restore to:"
/>
<p className="text-xs text-muted-foreground">
Files will be restored to this location. Default "/" restores to original paths.
</p>
</div>
<div>
<Button
type="button"

View file

@ -61,6 +61,7 @@ export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
path: { name },
body: {
snapshotId,
target: values.target || undefined,
include: include && include.length > 0 ? include : undefined,
exclude: exclude && exclude.length > 0 ? exclude : undefined,
excludeXattr: excludeXattr && excludeXattr.length > 0 ? excludeXattr : undefined,

View file

@ -17,6 +17,7 @@ import { Button } from "~/client/components/ui/button";
const restoreSnapshotFormSchema = type({
path: "string?",
target: "string?",
include: "string?",
exclude: "string?",
excludeXattr: "string?",
@ -37,6 +38,7 @@ export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
resolver: arktypeResolver(restoreSnapshotFormSchema),
defaultValues: {
path: "",
target: "",
include: "",
exclude: "",
excludeXattr: "",
@ -68,6 +70,23 @@ export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
)}
/>
<FormField
control={form.control}
name="target"
render={({ field }) => (
<FormItem>
<FormLabel>Target Directory (Optional)</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormDescription>
Restore to a custom location instead of the original path (defaults to /)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="include"

View file

@ -271,6 +271,7 @@ export const listSnapshotFilesDto = describeRoute({
*/
export const restoreSnapshotBody = type({
snapshotId: "string",
target: "string?",
include: "string[]?",
exclude: "string[]?",
excludeXattr: "string[]?",

View file

@ -201,7 +201,7 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
const restoreSnapshot = async (
name: string,
snapshotId: string,
options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean },
options?: { target?: string; include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean },
) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
@ -211,7 +211,8 @@ const restoreSnapshot = async (
throw new NotFoundError("Repository not found");
}
const result = await restic.restore(repository.config, snapshotId, "/", options);
const target = options?.target || "/";
const result = await restic.restore(repository.config, snapshotId, target, options);
return {
success: true,