Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ qa-output

.worktrees/
.turbo
.superset
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type { ComponentProps } from "react";
import { afterEach, describe, expect, test } from "bun:test";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils";

type SnapshotFilesRequest = {
shortId: string;
snapshotId: string;
path: string | null;
offset: string | null;
limit: string | null;
};

const snapshotFiles = {
files: [
{ name: "project", path: "/mnt/project", type: "dir" },
{ name: "a.txt", path: "/mnt/project/a.txt", type: "file" },
],
};

import { SnapshotTreeBrowser } from "../snapshot-tree-browser";

const mockListSnapshotFiles = () => {
const requests: SnapshotFilesRequest[] = [];

server.use(
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", ({ params, request }) => {
const url = new URL(request.url);
requests.push({
shortId: String(params.shortId),
snapshotId: String(params.snapshotId),
path: url.searchParams.get("path"),
offset: url.searchParams.get("offset"),
limit: url.searchParams.get("limit"),
});

return HttpResponse.json(snapshotFiles);
}),
);

return requests;
};

const renderSnapshotTreeBrowser = (props: Partial<ComponentProps<typeof SnapshotTreeBrowser>> = {}) => {
return render(
<SnapshotTreeBrowser
repositoryId="repo-1"
snapshotId="snap-1"
queryBasePath="/mnt/project"
displayBasePath="/mnt"
{...props}
/>,
);
};

afterEach(() => {
cleanup();
});

describe("SnapshotTreeBrowser", () => {
test("renders the query root folder when display base path is broader than query base path", async () => {
mockListSnapshotFiles();

renderSnapshotTreeBrowser();

expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
});

test("shows selected folder state when full paths are provided from the parent", async () => {
mockListSnapshotFiles();

renderSnapshotTreeBrowser({
withCheckboxes: true,
selectedPaths: new Set(["/mnt/project"]),
onSelectionChange: () => {},
});

const row = await screen.findByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");

expect(checkbox.getAttribute("aria-checked")).toBe("true");
});

test("returns the full snapshot path and kind when selecting a displayed folder", async () => {
mockListSnapshotFiles();

let selectedPaths: Set<string> | undefined;
let selectedKind: "file" | "dir" | null = null;

renderSnapshotTreeBrowser({
withCheckboxes: true,
onSelectionChange: (paths) => {
selectedPaths = paths;
},
onSingleSelectionKindChange: (kind) => {
selectedKind = kind;
},
});

const row = await screen.findByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");

await userEvent.click(checkbox);

expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]);
expect(selectedKind === "dir").toBe(true);
});

test("uses the query base path for the initial request when display base path is broader", async () => {
const requests = mockListSnapshotFiles();

renderSnapshotTreeBrowser();

await waitFor(() => {
expect(requests[0]).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: null,
limit: null,
});
});
});

test("prefetches using the query path when display and query roots differ", async () => {
const requests = mockListSnapshotFiles();

renderSnapshotTreeBrowser();

const row = await screen.findByRole("button", { name: "project" });
const initialRequestCount = requests.length;

await userEvent.hover(row);

await waitFor(() => {
expect(requests.length).toBe(initialRequestCount + 1);
});

expect(requests.at(-1)).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: "0",
limit: "500",
});
});

test("expands using the query path when display and query roots differ", async () => {
const requests = mockListSnapshotFiles();

renderSnapshotTreeBrowser();

const row = await screen.findByRole("button", { name: "project" });
const expandIcon = row.querySelector("svg");
if (!expandIcon) {
throw new Error("Expected expand icon for folder row");
}

const initialRequestCount = requests.length;
await userEvent.click(expandIcon);

await waitFor(() => {
expect(requests.length).toBeGreaterThan(initialRequestCount);
});

expect(requests.at(-1)).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: "0",
limit: "500",
});
});
});
85 changes: 42 additions & 43 deletions app/client/components/file-browsers/snapshot-tree-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,92 +7,91 @@ import { parseError } from "~/client/lib/errors";
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
import { logger } from "~/client/lib/logger";

function createPathPrefixFns(basePath: string) {
return {
strip(path: string) {
if (basePath === "/") return path;
if (path === basePath) return "/";
if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
return path;
},
add(displayPath: string) {
if (basePath === "/") return displayPath;
if (displayPath === "/") return basePath;
return `${basePath}${displayPath}`;
},
};
}

type SnapshotTreeBrowserProps = FileBrowserUiProps & {
repositoryId: string;
snapshotId: string;
basePath?: string;
queryBasePath?: string;
displayBasePath?: string;
pageSize?: number;
enabled?: boolean;
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
};

export const SnapshotTreeBrowser = ({
repositoryId,
snapshotId,
basePath = "/",
pageSize = 500,
enabled = true,
...uiProps
}: SnapshotTreeBrowserProps) => {
export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
const {
repositoryId,
snapshotId,
queryBasePath = "/",
displayBasePath,
pageSize = 500,
enabled = true,
...uiProps
} = props;

const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
const queryClient = useQueryClient();
const normalizedBasePath = normalizeAbsolutePath(basePath);
const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath);
const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? normalizedQueryBasePath);

const { data, isLoading, error } = useQuery({
...listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path: normalizedBasePath },
query: { path: normalizedQueryBasePath },
}),
enabled,
});

const stripBasePath = useCallback(
(path: string): string => {
if (normalizedBasePath === "/") return path;
if (path === normalizedBasePath) return "/";
if (path.startsWith(`${normalizedBasePath}/`)) {
return path.slice(normalizedBasePath.length);
}
return path;
},
[normalizedBasePath],
);

const addBasePath = useCallback(
(displayPath: string): string => {
if (normalizedBasePath === "/") return displayPath;
if (displayPath === "/") return normalizedBasePath;
return `${normalizedBasePath}${displayPath}`;
},
[normalizedBasePath],
);
const displayPathFns = useMemo(() => createPathPrefixFns(normalizedDisplayBasePath), [normalizedDisplayBasePath]);

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

const displayPaths = new Set<string>();
for (const fullPath of selectedPaths) {
displayPaths.add(stripBasePath(fullPath));
displayPaths.add(displayPathFns.strip(fullPath));
}

return displayPaths;
}, [selectedPaths, stripBasePath]);
}, [displayPathFns, selectedPaths]);

const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
fetchFolder: async (path, offset = 0) => {
fetchFolder: async (displayPath, offset = 0) => {
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path, offset: offset, limit: pageSize },
query: { path: displayPath, offset: offset, limit: pageSize },
}),
);
},
prefetchFolder: (path) => {
prefetchFolder: (displayPath) => {
void queryClient
.prefetchQuery(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path, offset: 0, limit: pageSize },
query: { path: displayPath, offset: 0, limit: pageSize },
}),
)
.catch((e) => logger.error(e));
},
pathTransform: {
strip: stripBasePath,
add: addBasePath,
},
pathTransform: displayPathFns,
});

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

const nextFullPaths = new Set<string>();
for (const displayPath of nextDisplayPaths) {
nextFullPaths.add(addBasePath(displayPath));
nextFullPaths.add(displayPathFns.add(displayPath));
}

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

onSelectionChange(nextFullPaths);
},
[onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds],
[displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange],
);

return (
Expand Down
10 changes: 6 additions & 4 deletions app/client/components/restore-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ interface RestoreFormProps {
repository: Repository;
snapshotId: string;
returnPath: string;
basePath?: string;
queryBasePath?: string;
displayBasePath?: string;
}

export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) {
export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, displayBasePath }: RestoreFormProps) {
const navigate = useNavigate();
const { addEventListener } = useServerEvents();

const volumeBasePath = basePath ?? "/";
const snapshotBasePath = queryBasePath ?? "/";

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