diff --git a/apps/client/app/components/snapshots-table.tsx b/apps/client/app/components/snapshots-table.tsx index 53be81a..54c040d 100644 --- a/apps/client/app/components/snapshots-table.tsx +++ b/apps/client/app/components/snapshots-table.tsx @@ -1,4 +1,5 @@ import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react"; +import { useNavigate } from "react-router"; import type { ListSnapshotsResponse } from "~/api-client/types.gen"; import { ByteSize } from "~/components/bytes-size"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; @@ -9,9 +10,16 @@ type Snapshot = ListSnapshotsResponse["snapshots"][0]; type Props = { snapshots: Snapshot[]; + repositoryName: string; }; -export const SnapshotsTable = ({ snapshots }: Props) => { +export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { + const navigate = useNavigate(); + + const handleRowClick = (snapshotId: string) => { + navigate(`/repositories/${repositoryName}/${snapshotId}`); + }; + return (
@@ -26,7 +34,11 @@ export const SnapshotsTable = ({ snapshots }: Props) => { {snapshots.map((snapshot) => ( - + handleRowClick(snapshot.short_id)} + >
diff --git a/apps/client/app/lib/breadcrumbs.ts b/apps/client/app/lib/breadcrumbs.ts index 699f91e..8c94f25 100644 --- a/apps/client/app/lib/breadcrumbs.ts +++ b/apps/client/app/lib/breadcrumbs.ts @@ -23,10 +23,20 @@ export function generateBreadcrumbs(pathname: string, params: Record { ) : ( <> - +
{`Showing ${snapshots.snapshots.length} of ${snapshots.snapshots.length}`} diff --git a/apps/client/app/modules/details/tabs/files.tsx b/apps/client/app/modules/details/tabs/files.tsx index 256f1ee..93264ff 100644 --- a/apps/client/app/modules/details/tabs/files.tsx +++ b/apps/client/app/modules/details/tabs/files.tsx @@ -26,7 +26,6 @@ export const FilesTabContent = ({ volume }: Props) => { const [loadingFolders, setLoadingFolders] = useState>(new Set()); const [allFiles, setAllFiles] = useState>(new Map()); - // Fetch root level files const { data, isLoading, error } = useQuery({ ...listFilesOptions({ path: { name: volume.name } }), enabled: volume.status === "mounted", diff --git a/apps/client/app/modules/repositories/components/snapshot-files.tsx b/apps/client/app/modules/repositories/components/snapshot-files.tsx new file mode 100644 index 0000000..e6e5b13 --- /dev/null +++ b/apps/client/app/modules/repositories/components/snapshot-files.tsx @@ -0,0 +1,148 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { FolderOpen } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { listSnapshotFiles } from "~/api-client"; +import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen"; +import { FileTree } from "~/components/file-tree"; + +interface FileEntry { + name: string; + path: string; + type: string; + size?: number; + mtime?: string; +} + +type Props = { + name: string; + snapshotId: string; +}; + +export const SnapshotFilesList = ({ name, snapshotId }: Props) => { + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [fetchedFolders, setFetchedFolders] = useState>(new Set(["/"])); + const [loadingFolders, setLoadingFolders] = useState>(new Set()); + const [allFiles, setAllFiles] = useState>(new Map()); + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + ...listSnapshotFilesOptions({ + path: { name, snapshotId }, + query: { path: "/" }, + }), + }); + + useMemo(() => { + if (data?.files) { + setAllFiles((prev) => { + const next = new Map(prev); + for (const file of data.files) { + next.set(file.path, file); + } + return next; + }); + } + }, [data]); + + const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]); + + const handleFolderExpand = useCallback( + async (folderPath: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + next.add(folderPath); + return next; + }); + + if (!fetchedFolders.has(folderPath)) { + setLoadingFolders((prev) => new Set(prev).add(folderPath)); + + try { + const result = await listSnapshotFiles({ + path: { name: name ?? "", snapshotId: snapshotId ?? "" }, + query: { path: folderPath }, + throwOnError: true, + }); + + if (result.data) { + setAllFiles((prev) => { + const next = new Map(prev); + for (const file of result.data.files) { + next.set(file.path, file); + } + return next; + }); + + setFetchedFolders((prev) => new Set(prev).add(folderPath)); + } + } catch (error) { + console.error("Failed to fetch folder contents:", error); + } finally { + setLoadingFolders((prev) => { + const next = new Set(prev); + next.delete(folderPath); + return next; + }); + } + } + }, + [name, snapshotId, fetchedFolders], + ); + + const handleFolderHover = useCallback( + async (folderPath: string) => { + if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) { + queryClient.prefetchQuery( + listSnapshotFilesOptions({ + path: { name, snapshotId }, + query: { path: folderPath }, + }), + ); + } + }, + [name, snapshotId, fetchedFolders, loadingFolders, queryClient], + ); + + if (isLoading && fileArray.length === 0) { + return ( +
+

Loading files...

+
+ ); + } + + if (error) { + return ( +
+

Failed to load files: {(error as Error).message}

+
+ ); + } + + if (fileArray.length === 0) { + return ( +
+ +

This snapshot appears to be empty.

+
+ ); + } + + return ( +
+ ({ + name: f.name, + path: f.path, + type: f.type === "dir" ? "directory" : "file", + size: f.size, + modifiedAt: f.mtime ? new Date(f.mtime).getTime() : undefined, + }))} + onFolderExpand={handleFolderExpand} + onFolderHover={handleFolderHover} + expandedFolders={expandedFolders} + loadingFolders={loadingFolders} + /> +
+ ); +}; diff --git a/apps/client/app/modules/repositories/routes/snapshot-details.tsx b/apps/client/app/modules/repositories/routes/snapshot-details.tsx new file mode 100644 index 0000000..44bffbd --- /dev/null +++ b/apps/client/app/modules/repositories/routes/snapshot-details.tsx @@ -0,0 +1,84 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { SnapshotFilesList } from "../components/snapshot-files"; + +export default function SnapshotDetailsPage() { + const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>(); + + const { data } = useQuery({ + ...listSnapshotFilesOptions({ + path: { name: name ?? "", snapshotId: snapshotId ?? "" }, + query: { path: "/" }, + }), + enabled: !!name && !!snapshotId, + }); + + if (!name || !snapshotId) { + return ( +
+

Invalid snapshot reference

+
+ ); + } + + return ( +
+
+
+

{name}

+

Snapshot: {snapshotId}

+
+
+ + + + File Explorer + Browse the files and folders in this snapshot. + + + + + + + {data?.snapshot && ( + + + Snapshot Information + + +
+
+ Snapshot ID: +

{data.snapshot.id}

+
+
+ Short ID: +

{data.snapshot.short_id}

+
+
+ Hostname: +

{data.snapshot.hostname}

+
+
+ Time: +

{new Date(data.snapshot.time).toLocaleString()}

+
+
+ Paths: +
+ {data.snapshot.paths.map((path) => ( +

+ {path} +

+ ))} +
+
+
+
+
+ )} +
+ ); +} diff --git a/apps/client/app/modules/repositories/tabs/snapshots.tsx b/apps/client/app/modules/repositories/tabs/snapshots.tsx index b948dcb..2d397dd 100644 --- a/apps/client/app/modules/repositories/tabs/snapshots.tsx +++ b/apps/client/app/modules/repositories/tabs/snapshots.tsx @@ -153,7 +153,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
) : ( - + )}
diff --git a/apps/client/app/routes.ts b/apps/client/app/routes.ts index 144e30a..7b60ac6 100644 --- a/apps/client/app/routes.ts +++ b/apps/client/app/routes.ts @@ -9,5 +9,6 @@ export default [ route("volumes/:name", "./routes/details.tsx"), route("repositories", "./modules/repositories/routes/repositories.tsx"), route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"), + route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"), ]), ] satisfies RouteConfig; diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index c030649..f08ec5c 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -72,13 +72,18 @@ export const repositoriesController = new Hono() return c.json(response, 200); }) - .get("/:name/snapshots/:snapshotId/files", listSnapshotFilesDto, validator("query", listSnapshotFilesQuery), async (c) => { - const { name, snapshotId } = c.req.param(); - const { path } = c.req.valid("query"); + .get( + "/:name/snapshots/:snapshotId/files", + listSnapshotFilesDto, + validator("query", listSnapshotFilesQuery), + async (c) => { + const { name, snapshotId } = c.req.param(); + const { path } = c.req.valid("query"); - const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path); + const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path); - c.header("Cache-Control", "max-age=300, stale-while-revalidate=600"); + c.header("Cache-Control", "max-age=300, stale-while-revalidate=600"); - return c.json(result, 200); - }); + return c.json(result, 200); + }, + );