fix: split display path and query base path

Closes #709
This commit is contained in:
Nicolas Meienberger 2026-03-26 18:55:34 +01:00
parent 6354705626
commit 2a1eaf29d0
12 changed files with 191 additions and 135 deletions

1
.gitignore vendored
View file

@ -43,3 +43,4 @@ qa-output
.worktrees/
.turbo
.superset

View file

@ -0,0 +1,86 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
const snapshotFiles = {
files: [
{ name: "project", path: "/mnt/project", type: "dir" },
{ name: "a.txt", path: "/mnt/project/a.txt", type: "file" },
],
};
await mock.module("@tanstack/react-query", () => ({
useQuery: () => ({ data: snapshotFiles, isLoading: false, error: null }),
useQueryClient: () => ({
ensureQueryData: async () => snapshotFiles,
prefetchQuery: async () => undefined,
}),
}));
import { SnapshotTreeBrowser } from "../snapshot-tree-browser";
afterEach(() => {
cleanup();
});
describe("SnapshotTreeBrowser", () => {
test("renders the query root folder when display base path is broader than query base path", () => {
render(
<SnapshotTreeBrowser
repositoryId="repo-1"
snapshotId="snap-1"
queryBasePath="/mnt/project"
displayBasePath="/mnt"
/>,
);
screen.getByRole("button", { name: "project" });
});
test("shows selected folder state when full paths are provided from the parent", () => {
render(
<SnapshotTreeBrowser
repositoryId="repo-1"
snapshotId="snap-1"
queryBasePath="/mnt/project"
displayBasePath="/mnt"
withCheckboxes
selectedPaths={new Set(["/mnt/project"])}
onSelectionChange={() => {}}
/>,
);
const row = screen.getByRole("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", () => {
let selectedPaths: Set<string> | undefined;
let selectedKind: "file" | "dir" | null = null;
render(
<SnapshotTreeBrowser
repositoryId="repo-1"
snapshotId="snap-1"
queryBasePath="/mnt/project"
displayBasePath="/mnt"
withCheckboxes
onSelectionChange={(paths) => {
selectedPaths = paths;
}}
onSingleSelectionKindChange={(kind) => {
selectedKind = kind;
}}
/>,
);
const row = screen.getByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");
fireEvent.click(checkbox);
expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]);
expect(selectedKind === "dir").toBe(true);
});
});

View file

@ -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(() => {
@ -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) {
@ -127,7 +126,7 @@ export const SnapshotTreeBrowser = ({
onSelectionChange(nextFullPaths);
},
[onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds],
[displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange],
);
return (

View file

@ -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("");
@ -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"

View file

@ -1,5 +1,4 @@
import { RotateCcw, Trash2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button, buttonVariants } from "~/client/components/ui/button";
import type { Snapshot } from "~/client/lib/types";
@ -7,14 +6,13 @@ import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
import { Link } from "@tanstack/react-router";
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
import { getBackupScheduleOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { getVolumeMountPath } from "~/client/lib/volume-path";
import { findCommonAncestor } from "@zerobyte/core/utils";
interface Props {
snapshot: Snapshot;
repositoryId: string;
backupId?: string;
basePath?: string;
displayBasePath?: string;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
}
@ -28,43 +26,11 @@ const treeProps = {
stateClassName: "flex-1 min-h-0",
} as const;
interface ScheduleAwareTreeBrowserProps {
scheduleShortId: string;
repositoryId: string;
snapshotId: string;
}
const ScheduleAwareTreeBrowser = ({ scheduleShortId, repositoryId, snapshotId }: ScheduleAwareTreeBrowserProps) => {
const { data: schedule, isPending } = useQuery({
...getBackupScheduleOptions({ path: { shortId: scheduleShortId } }),
retry: false,
});
if (isPending) {
return <TreeBrowserFallback />;
}
return (
<SnapshotTreeBrowser
repositoryId={repositoryId}
snapshotId={snapshotId}
basePath={schedule ? getVolumeMountPath(schedule.volume) : "/"}
{...treeProps}
/>
);
};
const TreeBrowserFallback = () => (
<div className={cn(treeProps.treeContainerClassName, "flex items-center justify-center")}>
<p className="text-muted-foreground">Loading volume info...</p>
</div>
);
export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
const { snapshot, repositoryId, backupId, displayBasePath, onDeleteSnapshot, isDeletingSnapshot } = props;
const { formatDateTime } = useTimeFormat();
const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined;
const queryBasePath = findCommonAncestor(snapshot.paths);
return (
<div className="space-y-4">
@ -110,27 +76,13 @@ export const SnapshotFileBrowser = (props: Props) => {
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{basePath ? (
<SnapshotTreeBrowser
repositoryId={repositoryId}
snapshotId={snapshot.short_id}
basePath={basePath}
{...treeProps}
/>
) : scheduleShortId ? (
<ScheduleAwareTreeBrowser
scheduleShortId={scheduleShortId}
repositoryId={repositoryId}
snapshotId={snapshot.short_id}
/>
) : (
<SnapshotTreeBrowser
repositoryId={repositoryId}
snapshotId={snapshot.short_id}
basePath="/"
{...treeProps}
/>
)}
<SnapshotTreeBrowser
repositoryId={repositoryId}
snapshotId={snapshot.short_id}
queryBasePath={queryBasePath}
displayBasePath={displayBasePath}
{...treeProps}
/>
</CardContent>
</Card>
</div>

View file

@ -34,6 +34,7 @@ import { ScheduleNotificationsConfig } from "../components/schedule-notification
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
import { cn } from "~/client/lib/utils";
import { getVolumeMountPath } from "~/client/lib/volume-path";
import type {
BackupSchedule,
NotificationDestination,
@ -303,6 +304,7 @@ export function ScheduleDetailsPage(props: Props) {
snapshot={selectedSnapshot}
repositoryId={schedule.repository.shortId}
backupId={schedule.shortId}
displayBasePath={getVolumeMountPath(schedule.volume)}
onDeleteSnapshot={handleDeleteSnapshot}
isDeletingSnapshot={deleteSnapshot.isPending}
/>

View file

@ -5,11 +5,20 @@ type Props = {
repository: Repository;
snapshotId: string;
returnPath: string;
basePath?: string;
queryBasePath?: string;
displayBasePath?: string;
};
export function RestoreSnapshotPage(props: Props) {
const { returnPath, snapshotId, repository, basePath } = props;
const { returnPath, snapshotId, repository, queryBasePath, displayBasePath } = props;
return <RestoreForm repository={repository} snapshotId={snapshotId} returnPath={returnPath} basePath={basePath} />;
return (
<RestoreForm
repository={repository}
snapshotId={snapshotId}
returnPath={returnPath}
queryBasePath={queryBasePath}
displayBasePath={displayBasePath}
/>
);
}

View file

@ -101,7 +101,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot
<SnapshotFileBrowser
repositoryId={repositoryId}
snapshot={data}
basePath={backupSchedule ? getVolumeMountPath(backupSchedule.volume) : undefined}
displayBasePath={backupSchedule ? getVolumeMountPath(backupSchedule.volume) : undefined}
/>
) : (
<SnapshotFileBrowserSkeleton />

View file

@ -3,6 +3,7 @@ import { getBackupSchedule } from "~/client/api-client";
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
import { getVolumeMountPath } from "~/client/lib/volume-path";
import { findCommonAncestor } from "@zerobyte/core/utils";
export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({
component: RouteComponent,
@ -29,7 +30,8 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
snapshot,
repository,
schedule: schedule.data,
basePath: getVolumeMountPath(schedule.data.volume),
queryBasePath: findCommonAncestor(snapshot.paths),
displayBasePath: getVolumeMountPath(schedule.data.volume),
};
},
head: ({ params }) => ({
@ -53,14 +55,15 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
function RouteComponent() {
const { backupId, snapshotId } = Route.useParams();
const { repository, basePath } = Route.useLoaderData();
const { repository, queryBasePath, displayBasePath } = Route.useLoaderData();
return (
<RestoreSnapshotPage
returnPath={`/backups/${backupId}`}
snapshotId={snapshotId}
repository={repository}
basePath={basePath}
queryBasePath={queryBasePath}
displayBasePath={displayBasePath}
/>
);
}

View file

@ -3,6 +3,7 @@ import { getBackupSchedule } from "~/client/api-client";
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
import { getVolumeMountPath } from "~/client/lib/volume-path";
import { findCommonAncestor } from "@zerobyte/core/utils";
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({
component: RouteComponent,
@ -15,16 +16,16 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }),
]);
let basePath: string | undefined;
let displayBasePath: string | undefined;
const scheduleShortId = snapshot.tags?.[0];
if (scheduleShortId) {
const scheduleRes = await getBackupSchedule({ path: { shortId: scheduleShortId } });
if (scheduleRes.data) {
basePath = getVolumeMountPath(scheduleRes.data.volume);
displayBasePath = getVolumeMountPath(scheduleRes.data.volume);
}
}
return { snapshot, repository, basePath };
return { snapshot, repository, queryBasePath: findCommonAncestor(snapshot.paths), displayBasePath };
},
staticData: {
breadcrumb: (match) => [
@ -47,14 +48,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
function RouteComponent() {
const { repositoryId, snapshotId } = Route.useParams();
const { repository, basePath } = Route.useLoaderData();
const { repository, queryBasePath, displayBasePath } = Route.useLoaderData();
return (
<RestoreSnapshotPage
returnPath={`/repositories/${repositoryId}/${snapshotId}`}
repository={repository}
snapshotId={snapshotId}
basePath={basePath}
queryBasePath={queryBasePath}
displayBasePath={displayBasePath}
/>
);
}

View file

@ -35,7 +35,7 @@ function getRunId(testInfo: TestInfo) {
return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
}
function getWorkerTestDataPath(runId: string) {
function getWorkerTestDataPath() {
fs.mkdirSync(testDataPath, { recursive: true });
return testDataPath;
}
@ -49,7 +49,7 @@ function getScenarioNames(runId: string): ScenarioNames {
}
function prepareTestFile(runId: string, fileName = "test.json"): string {
const runPath = path.join(getWorkerTestDataPath(runId), runId);
const runPath = path.join(getWorkerTestDataPath(), runId);
fs.mkdirSync(runPath, { recursive: true });
const filePath = path.join(runPath, fileName);
@ -58,8 +58,8 @@ function prepareTestFile(runId: string, fileName = "test.json"): string {
return filePath;
}
async function createBackupScenario(page: Page, names: ScenarioNames, runId: string, options: ScenarioOptions = {}) {
getWorkerTestDataPath(runId);
async function createBackupScenario(page: Page, names: ScenarioNames, options: ScenarioOptions = {}) {
getWorkerTestDataPath();
const volumeNameInput = page.getByRole("textbox", { name: "Name" });
await expect(async () => {
@ -153,7 +153,7 @@ test("can backup & restore a file", async ({ page }, testInfo) => {
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, runId);
await createBackupScenario(page, names);
await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible();
@ -177,7 +177,7 @@ test("can backup & restore a file", async ({ page }, testInfo) => {
test("can restore a single selected file to a custom location", async ({ page }, testInfo) => {
const runId = getRunId(testInfo);
const names = getScenarioNames(runId);
const workerTestDataPath = getWorkerTestDataPath(runId);
const workerTestDataPath = getWorkerTestDataPath();
const fileName = `single-file-${runId}.json`;
const filePath = prepareTestFile(runId, fileName);
const restoreTargetPath = path.join(workerTestDataPath, fileName);
@ -188,7 +188,7 @@ test("can restore a single selected file to a custom location", async ({ page },
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, runId);
await createBackupScenario(page, names);
await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible();
@ -234,7 +234,7 @@ test("can re-tag a snapshot to another backup schedule", async ({ page }, testIn
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, runId);
await createBackupScenario(page, names);
await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible();
@ -277,7 +277,7 @@ test("can delete a snapshot from the repository snapshots tab", async ({ page },
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, runId);
await createBackupScenario(page, names);
await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible();
@ -302,7 +302,7 @@ test("can delete a snapshot from the repository snapshots tab", async ({ page },
test("can download a selected snapshot directory as a tar archive", async ({ page }, testInfo) => {
const runId = getRunId(testInfo);
const names = getScenarioNames(runId);
const workerTestDataPath = getWorkerTestDataPath(runId);
const workerTestDataPath = getWorkerTestDataPath();
const fileName = `download-${runId}.json`;
const filePath = prepareTestFile(runId, fileName);
const downloadedPath = path.join(workerTestDataPath, `downloaded-${runId}.tar`);
@ -312,7 +312,7 @@ test("can download a selected snapshot directory as a tar archive", async ({ pag
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, runId);
await createBackupScenario(page, names);
await page.getByRole("button", { name: "Backup now" }).click();
await expect(page.getByText("Backup started successfully")).toBeVisible();
@ -351,7 +351,7 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, runId);
await createBackupScenario(page, names);
await gotoAndWaitForAppReady(page, "/backups");
await page.getByText(names.backupName, { exact: true }).first().click();
@ -378,7 +378,7 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page
test("backup respects include globs, exclusion patterns, and exclude-if-present", async ({ page }, testInfo) => {
const runId = getRunId(testInfo);
const names = getScenarioNames(runId);
const workerTestDataPath = getWorkerTestDataPath(runId);
const workerTestDataPath = getWorkerTestDataPath();
const keptDir = `kept-${runId}`;
const secondKeptDir = `second-kept-${runId}`;
@ -433,7 +433,7 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present"
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, runId, {
await createBackupScenario(page, names, {
includePatterns: [
`/${keptDir}`,
`/${secondKeptDir}`,
@ -492,7 +492,7 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present"
test("backup can include a selected folder whose name contains brackets", async ({ page }, testInfo) => {
const runId = getRunId(testInfo);
const names = getScenarioNames(runId);
const workerTestDataPath = getWorkerTestDataPath(runId);
const workerTestDataPath = getWorkerTestDataPath();
const bracketDir = `movies [${runId}]`;
const bracketPath = path.join(workerTestDataPath, bracketDir);
const fileName = `inside-${runId}.txt`;
@ -503,7 +503,7 @@ test("backup can include a selected folder whose name contains brackets", async
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
await createBackupScenario(page, names, runId, {
await createBackupScenario(page, names, {
selectedPaths: [`/${bracketDir}`],
});

View file

@ -1,5 +1,5 @@
{
"include": ["**/*"],
"include": ["app/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],