Skip to content

Commit 187030d

Browse files
committed
fix: split display path and query base path
Closes #709
1 parent 6354705 commit 187030d

11 files changed

Lines changed: 189 additions & 135 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ qa-output
4343

4444
.worktrees/
4545
.turbo
46+
.superset
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { afterEach, describe, expect, mock, test } from "bun:test";
2+
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
3+
4+
const snapshotFiles = {
5+
files: [
6+
{ name: "project", path: "/mnt/project", type: "dir" },
7+
{ name: "a.txt", path: "/mnt/project/a.txt", type: "file" },
8+
],
9+
};
10+
11+
await mock.module("@tanstack/react-query", () => ({
12+
useQuery: () => ({ data: snapshotFiles, isLoading: false, error: null }),
13+
useQueryClient: () => ({
14+
ensureQueryData: async () => snapshotFiles,
15+
prefetchQuery: async () => undefined,
16+
}),
17+
}));
18+
19+
import { SnapshotTreeBrowser } from "../snapshot-tree-browser";
20+
21+
afterEach(() => {
22+
cleanup();
23+
});
24+
25+
describe("SnapshotTreeBrowser", () => {
26+
test("renders the query root folder when display base path is broader than query base path", () => {
27+
render(
28+
<SnapshotTreeBrowser
29+
repositoryId="repo-1"
30+
snapshotId="snap-1"
31+
queryBasePath="/mnt/project"
32+
displayBasePath="/mnt"
33+
/>,
34+
);
35+
36+
screen.getByRole("button", { name: "project" });
37+
});
38+
39+
test("shows selected folder state when full paths are provided from the parent", () => {
40+
render(
41+
<SnapshotTreeBrowser
42+
repositoryId="repo-1"
43+
snapshotId="snap-1"
44+
queryBasePath="/mnt/project"
45+
displayBasePath="/mnt"
46+
withCheckboxes
47+
selectedPaths={new Set(["/mnt/project"])}
48+
onSelectionChange={() => {}}
49+
/>,
50+
);
51+
52+
const row = screen.getByRole("button", { name: "project" });
53+
const checkbox = within(row).getByRole("checkbox");
54+
55+
expect(checkbox.getAttribute("aria-checked")).toBe("true");
56+
});
57+
58+
test("returns the full snapshot path and kind when selecting a displayed folder", () => {
59+
let selectedPaths: Set<string> | undefined;
60+
let selectedKind: "file" | "dir" | null = null;
61+
62+
render(
63+
<SnapshotTreeBrowser
64+
repositoryId="repo-1"
65+
snapshotId="snap-1"
66+
queryBasePath="/mnt/project"
67+
displayBasePath="/mnt"
68+
withCheckboxes
69+
onSelectionChange={(paths) => {
70+
selectedPaths = paths;
71+
}}
72+
onSingleSelectionKindChange={(kind) => {
73+
selectedKind = kind;
74+
}}
75+
/>,
76+
);
77+
78+
const row = screen.getByRole("button", { name: "project" });
79+
const checkbox = within(row).getByRole("checkbox");
80+
81+
fireEvent.click(checkbox);
82+
83+
expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]);
84+
expect(selectedKind === "dir").toBe(true);
85+
});
86+
});

app/client/components/file-browsers/snapshot-tree-browser.tsx

Lines changed: 42 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,92 +7,91 @@ import { parseError } from "~/client/lib/errors";
77
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
88
import { logger } from "~/client/lib/logger";
99

10+
function createPathPrefixFns(basePath: string) {
11+
return {
12+
strip(path: string) {
13+
if (basePath === "/") return path;
14+
if (path === basePath) return "/";
15+
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
16+
return path;
17+
},
18+
add(displayPath: string) {
19+
if (basePath === "/") return displayPath;
20+
if (displayPath === "/") return basePath;
21+
return `${basePath}${displayPath}`;
22+
},
23+
};
24+
}
25+
1026
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
1127
repositoryId: string;
1228
snapshotId: string;
13-
basePath?: string;
29+
queryBasePath?: string;
30+
displayBasePath?: string;
1431
pageSize?: number;
1532
enabled?: boolean;
1633
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
1734
};
1835

19-
export const SnapshotTreeBrowser = ({
20-
repositoryId,
21-
snapshotId,
22-
basePath = "/",
23-
pageSize = 500,
24-
enabled = true,
25-
...uiProps
26-
}: SnapshotTreeBrowserProps) => {
36+
export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
37+
const {
38+
repositoryId,
39+
snapshotId,
40+
queryBasePath = "/",
41+
displayBasePath,
42+
pageSize = 500,
43+
enabled = true,
44+
...uiProps
45+
} = props;
46+
2747
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
2848
const queryClient = useQueryClient();
29-
const normalizedBasePath = normalizeAbsolutePath(basePath);
49+
const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath);
50+
const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? normalizedQueryBasePath);
3051

3152
const { data, isLoading, error } = useQuery({
3253
...listSnapshotFilesOptions({
3354
path: { shortId: repositoryId, snapshotId },
34-
query: { path: normalizedBasePath },
55+
query: { path: normalizedQueryBasePath },
3556
}),
3657
enabled,
3758
});
3859

39-
const stripBasePath = useCallback(
40-
(path: string): string => {
41-
if (normalizedBasePath === "/") return path;
42-
if (path === normalizedBasePath) return "/";
43-
if (path.startsWith(`${normalizedBasePath}/`)) {
44-
return path.slice(normalizedBasePath.length);
45-
}
46-
return path;
47-
},
48-
[normalizedBasePath],
49-
);
50-
51-
const addBasePath = useCallback(
52-
(displayPath: string): string => {
53-
if (normalizedBasePath === "/") return displayPath;
54-
if (displayPath === "/") return normalizedBasePath;
55-
return `${normalizedBasePath}${displayPath}`;
56-
},
57-
[normalizedBasePath],
58-
);
60+
const displayPathFns = useMemo(() => createPathPrefixFns(normalizedDisplayBasePath), [normalizedDisplayBasePath]);
5961

6062
const displaySelectedPaths = useMemo(() => {
6163
if (!selectedPaths) return undefined;
6264

6365
const displayPaths = new Set<string>();
6466
for (const fullPath of selectedPaths) {
65-
displayPaths.add(stripBasePath(fullPath));
67+
displayPaths.add(displayPathFns.strip(fullPath));
6668
}
6769

6870
return displayPaths;
69-
}, [selectedPaths, stripBasePath]);
71+
}, [displayPathFns, selectedPaths]);
7072

7173
const fileBrowser = useFileBrowser({
7274
initialData: data,
7375
isLoading,
74-
fetchFolder: async (path, offset = 0) => {
76+
fetchFolder: async (displayPath, offset = 0) => {
7577
return await queryClient.ensureQueryData(
7678
listSnapshotFilesOptions({
7779
path: { shortId: repositoryId, snapshotId },
78-
query: { path, offset: offset, limit: pageSize },
80+
query: { path: displayPathFns.add(displayPath), offset: offset, limit: pageSize },
7981
}),
8082
);
8183
},
82-
prefetchFolder: (path) => {
84+
prefetchFolder: (displayPath) => {
8385
void queryClient
8486
.prefetchQuery(
8587
listSnapshotFilesOptions({
8688
path: { shortId: repositoryId, snapshotId },
87-
query: { path, offset: 0, limit: pageSize },
89+
query: { path: displayPathFns.add(displayPath), offset: 0, limit: pageSize },
8890
}),
8991
)
9092
.catch((e) => logger.error(e));
9193
},
92-
pathTransform: {
93-
strip: stripBasePath,
94-
add: addBasePath,
95-
},
94+
pathTransform: displayPathFns,
9695
});
9796

9897
const displayPathKinds = useMemo(() => {
@@ -109,7 +108,7 @@ export const SnapshotTreeBrowser = ({
109108

110109
const nextFullPaths = new Set<string>();
111110
for (const displayPath of nextDisplayPaths) {
112-
nextFullPaths.add(addBasePath(displayPath));
111+
nextFullPaths.add(displayPathFns.add(displayPath));
113112
}
114113

115114
if (onSingleSelectionKindChange) {
@@ -127,7 +126,7 @@ export const SnapshotTreeBrowser = ({
127126

128127
onSelectionChange(nextFullPaths);
129128
},
130-
[onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds],
129+
[displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange],
131130
);
132131

133132
return (

app/client/components/restore-form.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,15 @@ interface RestoreFormProps {
3333
repository: Repository;
3434
snapshotId: string;
3535
returnPath: string;
36-
basePath?: string;
36+
queryBasePath?: string;
37+
displayBasePath?: string;
3738
}
3839

39-
export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) {
40+
export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, displayBasePath }: RestoreFormProps) {
4041
const navigate = useNavigate();
4142
const { addEventListener } = useServerEvents();
4243

43-
const volumeBasePath = basePath ?? "/";
44+
const snapshotBasePath = queryBasePath ?? "/";
4445

4546
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
4647
const [customTargetPath, setCustomTargetPath] = useState("");
@@ -346,7 +347,8 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
346347
<SnapshotTreeBrowser
347348
repositoryId={repository.shortId}
348349
snapshotId={snapshotId}
349-
basePath={volumeBasePath}
350+
queryBasePath={snapshotBasePath}
351+
displayBasePath={displayBasePath}
350352
pageSize={500}
351353
className="flex flex-1 min-h-0 flex-col"
352354
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"

app/client/modules/backups/components/snapshot-file-browser.tsx

Lines changed: 11 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
import { RotateCcw, Trash2 } from "lucide-react";
2-
import { useQuery } from "@tanstack/react-query";
32
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
43
import { Button, buttonVariants } from "~/client/components/ui/button";
54
import type { Snapshot } from "~/client/lib/types";
65
import { useTimeFormat } from "~/client/lib/datetime";
76
import { cn } from "~/client/lib/utils";
87
import { Link } from "@tanstack/react-router";
98
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
10-
import { getBackupScheduleOptions } from "~/client/api-client/@tanstack/react-query.gen";
11-
import { getVolumeMountPath } from "~/client/lib/volume-path";
9+
import { findCommonAncestor } from "@zerobyte/core/utils";
1210

1311
interface Props {
1412
snapshot: Snapshot;
1513
repositoryId: string;
1614
backupId?: string;
17-
basePath?: string;
15+
displayBasePath?: string;
1816
onDeleteSnapshot?: (snapshotId: string) => void;
1917
isDeletingSnapshot?: boolean;
2018
}
@@ -28,43 +26,11 @@ const treeProps = {
2826
stateClassName: "flex-1 min-h-0",
2927
} as const;
3028

31-
interface ScheduleAwareTreeBrowserProps {
32-
scheduleShortId: string;
33-
repositoryId: string;
34-
snapshotId: string;
35-
}
36-
37-
const ScheduleAwareTreeBrowser = ({ scheduleShortId, repositoryId, snapshotId }: ScheduleAwareTreeBrowserProps) => {
38-
const { data: schedule, isPending } = useQuery({
39-
...getBackupScheduleOptions({ path: { shortId: scheduleShortId } }),
40-
retry: false,
41-
});
42-
43-
if (isPending) {
44-
return <TreeBrowserFallback />;
45-
}
46-
47-
return (
48-
<SnapshotTreeBrowser
49-
repositoryId={repositoryId}
50-
snapshotId={snapshotId}
51-
basePath={schedule ? getVolumeMountPath(schedule.volume) : "/"}
52-
{...treeProps}
53-
/>
54-
);
55-
};
56-
57-
const TreeBrowserFallback = () => (
58-
<div className={cn(treeProps.treeContainerClassName, "flex items-center justify-center")}>
59-
<p className="text-muted-foreground">Loading volume info...</p>
60-
</div>
61-
);
62-
6329
export const SnapshotFileBrowser = (props: Props) => {
64-
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
30+
const { snapshot, repositoryId, backupId, displayBasePath, onDeleteSnapshot, isDeletingSnapshot } = props;
6531
const { formatDateTime } = useTimeFormat();
6632

67-
const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined;
33+
const queryBasePath = findCommonAncestor(snapshot.paths);
6834

6935
return (
7036
<div className="space-y-4">
@@ -110,27 +76,13 @@ export const SnapshotFileBrowser = (props: Props) => {
11076
</div>
11177
</CardHeader>
11278
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
113-
{basePath ? (
114-
<SnapshotTreeBrowser
115-
repositoryId={repositoryId}
116-
snapshotId={snapshot.short_id}
117-
basePath={basePath}
118-
{...treeProps}
119-
/>
120-
) : scheduleShortId ? (
121-
<ScheduleAwareTreeBrowser
122-
scheduleShortId={scheduleShortId}
123-
repositoryId={repositoryId}
124-
snapshotId={snapshot.short_id}
125-
/>
126-
) : (
127-
<SnapshotTreeBrowser
128-
repositoryId={repositoryId}
129-
snapshotId={snapshot.short_id}
130-
basePath="/"
131-
{...treeProps}
132-
/>
133-
)}
79+
<SnapshotTreeBrowser
80+
repositoryId={repositoryId}
81+
snapshotId={snapshot.short_id}
82+
queryBasePath={queryBasePath}
83+
displayBasePath={displayBasePath}
84+
{...treeProps}
85+
/>
13486
</CardContent>
13587
</Card>
13688
</div>

app/client/modules/repositories/routes/restore-snapshot.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,20 @@ type Props = {
55
repository: Repository;
66
snapshotId: string;
77
returnPath: string;
8-
basePath?: string;
8+
queryBasePath?: string;
9+
displayBasePath?: string;
910
};
1011

1112
export function RestoreSnapshotPage(props: Props) {
12-
const { returnPath, snapshotId, repository, basePath } = props;
13+
const { returnPath, snapshotId, repository, queryBasePath, displayBasePath } = props;
1314

14-
return <RestoreForm repository={repository} snapshotId={snapshotId} returnPath={returnPath} basePath={basePath} />;
15+
return (
16+
<RestoreForm
17+
repository={repository}
18+
snapshotId={snapshotId}
19+
returnPath={returnPath}
20+
queryBasePath={queryBasePath}
21+
displayBasePath={displayBasePath}
22+
/>
23+
);
1524
}

0 commit comments

Comments
 (0)