From 1e3419c25056229abf5e0487fe3b8aea5a9a2be2 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Mon, 6 Oct 2025 19:46:49 +0200 Subject: [PATCH] feat: file explorer (#1) * feat: list volume files backend * feat: file tree component * feat: load sub folders * fix: filetree wrong opening order * temp: open / close icons * chore: remove all hc files when cleaning * chore: file-tree optimizations --- .../api-client/@tanstack/react-query.gen.ts | 22 ++ apps/client/app/api-client/sdk.gen.ts | 13 + apps/client/app/api-client/types.gen.ts | 39 ++ apps/client/app/components/file-tree.tsx | 341 ++++++++++++++++++ .../client/app/modules/details/tabs/files.tsx | 146 ++++++++ apps/client/app/routes/details.tsx | 7 +- .../modules/backends/utils/backend-utils.ts | 16 +- .../src/modules/volumes/volume.controller.ts | 14 + apps/server/src/modules/volumes/volume.dto.ts | 47 +++ .../src/modules/volumes/volume.service.ts | 64 ++++ 10 files changed, 707 insertions(+), 2 deletions(-) create mode 100644 apps/client/app/components/file-tree.tsx create mode 100644 apps/client/app/modules/details/tabs/files.tsx diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index bc0df28..35e8bf0 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -17,6 +17,7 @@ import { mountVolume, unmountVolume, healthCheckVolume, + listFiles, } from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import type { @@ -45,6 +46,7 @@ import type { UnmountVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, + ListFilesData, } from "../types.gen"; import { client as _heyApiClient } from "../client.gen"; @@ -539,3 +541,23 @@ export const healthCheckVolumeMutation = ( }; return mutationOptions; }; + +export const listFilesQueryKey = (options: Options) => createQueryKey("listFiles", options); + +/** + * List files in a volume directory + */ +export const listFilesOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await listFiles({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: listFilesQueryKey(options), + }); +}; diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 64432dd..7a08e9b 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -41,6 +41,9 @@ import type { HealthCheckVolumeData, HealthCheckVolumeResponses, HealthCheckVolumeErrors, + ListFilesData, + ListFilesResponses, + ListFilesErrors, } from "./types.gen"; import { client as _heyApiClient } from "./client.gen"; @@ -248,3 +251,13 @@ export const healthCheckVolume = ( ...options, }); }; + +/** + * List files in a volume directory + */ +export const listFiles = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: "/api/v1/volumes/{name}/files", + ...options, + }); +}; diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 9085ca5..25dea17 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -600,6 +600,45 @@ export type HealthCheckVolumeResponses = { export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses]; +export type ListFilesData = { + body?: never; + path: { + name: string; + }; + query?: { + /** + * Subdirectory path to list (relative to volume root) + */ + path?: string; + }; + url: "/api/v1/volumes/{name}/files"; +}; + +export type ListFilesErrors = { + /** + * Volume not found + */ + 404: unknown; +}; + +export type ListFilesResponses = { + /** + * List of files in the volume + */ + 200: { + files: Array<{ + name: string; + path: string; + type: "directory" | "file"; + modifiedAt?: number; + size?: number; + }>; + path: string; + }; +}; + +export type ListFilesResponse = ListFilesResponses[keyof ListFilesResponses]; + export type ClientOptions = { baseUrl: "http://localhost:4096" | (string & {}); }; diff --git a/apps/client/app/components/file-tree.tsx b/apps/client/app/components/file-tree.tsx new file mode 100644 index 0000000..8e5df05 --- /dev/null +++ b/apps/client/app/components/file-tree.tsx @@ -0,0 +1,341 @@ +/** + * FileTree Component + * + * Adapted from bolt.new by StackBlitz + * Copyright (c) 2024 StackBlitz, Inc. + * Licensed under the MIT License + * + * Original source: https://github.com/stackblitz/bolt.new + */ + +import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react"; +import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { cn } from "~/lib/utils"; + +const NODE_PADDING_LEFT = 12; + +interface FileEntry { + name: string; + path: string; + type: "file" | "directory"; + size?: number; + modifiedAt?: number; +} + +interface Props { + files?: FileEntry[]; + selectedFile?: string; + onFileSelect?: (filePath: string) => void; + onFolderExpand?: (folderPath: string) => void; + expandedFolders?: Set; + loadingFolders?: Set; + className?: string; +} + +export const FileTree = memo((props: Props) => { + const { + files = [], + onFileSelect, + selectedFile, + onFolderExpand, + expandedFolders = new Set(), + loadingFolders = new Set(), + className, + } = props; + + const fileList = useMemo(() => { + return buildFileList(files); + }, [files]); + + const [collapsedFolders, setCollapsedFolders] = useState>(new Set()); + + const filteredFileList = useMemo(() => { + const list = []; + let lastDepth = Number.MAX_SAFE_INTEGER; + + for (const fileOrFolder of fileList) { + const depth = fileOrFolder.depth; + + // if the depth is equal we reached the end of the collapsed group + if (lastDepth === depth) { + lastDepth = Number.MAX_SAFE_INTEGER; + } + + // ignore collapsed folders + if (collapsedFolders.has(fileOrFolder.fullPath)) { + lastDepth = Math.min(lastDepth, depth); + } + + // ignore files and folders below the last collapsed folder + if (lastDepth < depth) { + continue; + } + + list.push(fileOrFolder); + } + + return list; + }, [fileList, collapsedFolders]); + + const toggleCollapseState = useCallback( + (fullPath: string) => { + setCollapsedFolders((prevSet) => { + const newSet = new Set(prevSet); + + if (newSet.has(fullPath)) { + newSet.delete(fullPath); + onFolderExpand?.(fullPath); + } else { + newSet.add(fullPath); + } + + return newSet; + }); + }, + [onFolderExpand], + ); + + // Add new folders to collapsed set when file list changes + useEffect(() => { + setCollapsedFolders((prevSet) => { + const newSet = new Set(prevSet); + for (const item of fileList) { + if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) { + newSet.add(item.fullPath); + } + } + return newSet; + }); + }, [fileList, expandedFolders]); + + const handleFileSelect = useCallback( + (filePath: string) => { + onFileSelect?.(filePath); + }, + [onFileSelect], + ); + + return ( +
+ {filteredFileList.map((fileOrFolder) => { + switch (fileOrFolder.kind) { + case "file": { + return ( + + ); + } + case "folder": { + return ( + + ); + } + default: { + return undefined; + } + } + })} +
+ ); +}); + +interface FolderProps { + folder: FolderNode; + collapsed: boolean; + loading?: boolean; + onToggle: (fullPath: string) => void; +} + +const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => { + const { depth, name, fullPath } = folder; + const FolderIconComponent = collapsed ? FolderIcon : FolderOpen; + + const handleClick = useCallback(() => { + onToggle(fullPath); + }, [onToggle, fullPath]); + + return ( + + ) : collapsed ? ( + + ) : ( + + ) + } + onClick={handleClick} + > + + {name} + + ); +}); + +interface FileProps { + file: FileNode; + selected: boolean; + onFileSelect: (filePath: string) => void; +} + +const File = memo(({ file, onFileSelect, selected }: FileProps) => { + const { depth, name, fullPath } = file; + + const handleClick = useCallback(() => { + onFileSelect(fullPath); + }, [onFileSelect, fullPath]); + + return ( + } + onClick={handleClick} + > + {name} + + ); +}); + +interface ButtonProps { + depth: number; + icon: ReactNode; + children: ReactNode; + className?: string; + onClick?: () => void; +} + +const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonProps) => { + const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]); + + return ( + + ); +}); + +type Node = FileNode | FolderNode; + +interface BaseNode { + id: number; + depth: number; + name: string; + fullPath: string; +} + +interface FileNode extends BaseNode { + kind: "file"; +} + +interface FolderNode extends BaseNode { + kind: "folder"; +} + +function buildFileList(files: FileEntry[]): Node[] { + const fileMap = new Map(); + + for (const file of files) { + const segments = file.path.split("/").filter((segment) => segment); + const depth = segments.length - 1; + const name = segments[segments.length - 1]; + + if (!fileMap.has(file.path)) { + fileMap.set(file.path, { + kind: file.type === "file" ? "file" : "folder", + id: fileMap.size, + name, + fullPath: file.path, + depth, + }); + } + } + + // Convert map to array and sort + return sortFileList(Array.from(fileMap.values())); +} + +function sortFileList(nodeList: Node[]): Node[] { + const nodeMap = new Map(); + const childrenMap = new Map(); + + // Pre-sort nodes by name and type + nodeList.sort((a, b) => compareNodes(a, b)); + + for (const node of nodeList) { + nodeMap.set(node.fullPath, node); + + const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/"; + + if (parentPath !== "/") { + if (!childrenMap.has(parentPath)) { + childrenMap.set(parentPath, []); + } + childrenMap.get(parentPath)?.push(node); + } + } + + const sortedList: Node[] = []; + + const depthFirstTraversal = (path: string): void => { + const node = nodeMap.get(path); + + if (node) { + sortedList.push(node); + } + + const children = childrenMap.get(path); + + if (children) { + for (const child of children) { + if (child.kind === "folder") { + depthFirstTraversal(child.fullPath); + } else { + sortedList.push(child); + } + } + } + }; + + // Start with root level items + const rootItems = nodeList.filter((node) => { + const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/"; + return parentPath === "/"; + }); + + for (const item of rootItems) { + depthFirstTraversal(item.fullPath); + } + + return sortedList; +} + +function compareNodes(a: Node, b: Node): number { + if (a.kind !== b.kind) { + return a.kind === "folder" ? -1 : 1; + } + + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); +} diff --git a/apps/client/app/modules/details/tabs/files.tsx b/apps/client/app/modules/details/tabs/files.tsx new file mode 100644 index 0000000..559fab4 --- /dev/null +++ b/apps/client/app/modules/details/tabs/files.tsx @@ -0,0 +1,146 @@ +import { useQuery } from "@tanstack/react-query"; +import { FolderOpen } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen"; +import { listFiles } from "~/api-client/sdk.gen"; +import { FileTree } from "~/components/file-tree"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { parseError } from "~/lib/errors"; +import type { Volume } from "~/lib/types"; + +type Props = { + volume: Volume; +}; + +interface FileEntry { + name: string; + path: string; + type: "file" | "directory"; + size?: number; + modifiedAt?: number; +} + +export const FilesTabContent = ({ volume }: 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()); + + // Fetch root level files + const { data, isLoading, error } = useQuery({ + ...listFilesOptions({ path: { name: volume.name } }), + enabled: volume.status === "mounted", + refetchInterval: 10000, + }); + + 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 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 listFiles({ + path: { name: volume.name }, + query: { path: folderPath }, + throwOnError: true, + }); + + if (result.data?.files) { + 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; + }); + } + } + }, + [volume.name, fetchedFolders], + ); + + const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]); + + if (volume.status !== "mounted") { + return ( + + + +

Volume must be mounted to browse files.

+

Mount the volume to explore its contents.

+
+
+ ); + } + + return ( + + + File Explorer + Browse the files and folders in this volume. + + + {isLoading && ( +
+

Loading files...

+
+ )} + {error && ( +
+

Failed to load files: {error.message}

+
+ )} + {!isLoading && !error && ( +
+ {fileArray.length === 0 ? ( +
+ +

This volume is empty.

+

+ Files and folders will appear here once you add them. +

+
+ ) : ( + + )} +
+ )} +
+
+ ); +}; diff --git a/apps/client/app/routes/details.tsx b/apps/client/app/routes/details.tsx index 70e1917..2646536 100644 --- a/apps/client/app/routes/details.tsx +++ b/apps/client/app/routes/details.tsx @@ -1,7 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router"; import { toast } from "sonner"; -import { getVolume } from "~/api-client"; import { deleteVolumeMutation, getVolumeOptions, @@ -16,7 +15,9 @@ import { parseError } from "~/lib/errors"; import { cn } from "~/lib/utils"; import { VolumeBackupsTabContent } from "~/modules/details/tabs/backups"; import { DockerTabContent } from "~/modules/details/tabs/docker"; +import { FilesTabContent } from "~/modules/details/tabs/files"; import { VolumeInfoTabContent } from "~/modules/details/tabs/info"; +import { getVolume } from "../api-client"; import type { Route } from "./+types/details"; export function meta({ params }: Route.MetaArgs) { @@ -133,12 +134,16 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) { Configuration + Files Docker Backups + + + diff --git a/apps/server/src/modules/backends/utils/backend-utils.ts b/apps/server/src/modules/backends/utils/backend-utils.ts index b96d643..4b89af3 100644 --- a/apps/server/src/modules/backends/utils/backend-utils.ts +++ b/apps/server/src/modules/backends/utils/backend-utils.ts @@ -4,6 +4,7 @@ import { execFile as execFileCb } from "node:child_process"; import { promisify } from "node:util"; import { OPERATION_TIMEOUT } from "../../../core/constants"; import { logger } from "../../../utils/logger"; +import { toMessage } from "../../../utils/errors"; const execFile = promisify(execFileCb); @@ -33,5 +34,18 @@ export const createTestFile = async (path: string): Promise => { const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); await fs.writeFile(testFilePath, "healthcheck"); - await fs.unlink(testFilePath); + + const files = await fs.readdir(path); + await Promise.all( + files.map(async (file) => { + if (file.startsWith(".healthcheck-")) { + const filePath = npath.join(path, file); + try { + await fs.unlink(filePath); + } catch (err) { + logger.warn(`Failed to stat or unlink file ${filePath}: ${toMessage(err)}`); + } + } + }), + ); }; diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index ea123ef..09ebba8 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -9,7 +9,9 @@ import { getVolumeDto, healthCheckDto, type ListContainersResponseDto, + type ListFilesResponseDto, type ListVolumesResponseDto, + listFilesDto, listVolumesDto, mountVolumeDto, testConnectionBody, @@ -118,4 +120,16 @@ export const volumeController = new Hono() const { error, status } = await volumeService.checkHealth(name); return c.json({ error, status }, 200); + }) + .get("/:name/files", listFilesDto, async (c) => { + const { name } = c.req.param(); + const subPath = c.req.query("path"); + const result = await volumeService.listFiles(name, subPath); + + const response = { + files: result.files, + path: result.path, + } satisfies ListFilesResponseDto; + + return c.json(response, 200); }); diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index cdcd9ba..0b59d44 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -305,3 +305,50 @@ export const getContainersDto = describeRoute({ }, }, }); + +/** + * List files in a volume + */ +const fileEntrySchema = type({ + name: "string", + path: "string", + type: type.enumerated("file", "directory"), + size: "number?", + modifiedAt: "number?", +}); + +export const listFilesResponse = type({ + files: fileEntrySchema.array(), + path: "string", +}); +export type ListFilesResponseDto = typeof listFilesResponse.infer; + +export const listFilesDto = describeRoute({ + description: "List files in a volume directory", + operationId: "listFiles", + tags: ["Volumes"], + parameters: [ + { + in: "query", + name: "path", + required: false, + schema: { + type: "string", + }, + description: "Subdirectory path to list (relative to volume root)", + }, + ], + responses: { + 200: { + description: "List of files in the volume", + content: { + "application/json": { + schema: resolver(listFilesResponse), + }, + }, + }, + 404: { + description: "Volume not found", + }, + }, +}); diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index 043243e..42decc0 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -253,6 +253,69 @@ const getContainersUsingVolume = async (name: string) => { return { containers: usingContainers }; }; +const listFiles = async (name: string, subPath?: string) => { + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); + + if (!volume) { + throw new NotFoundError("Volume not found"); + } + + if (volume.status !== "mounted") { + throw new InternalServerError("Volume is not mounted"); + } + + const requestedPath = subPath ? path.join(volume.path, subPath) : volume.path; + + const normalizedPath = path.normalize(requestedPath); + if (!normalizedPath.startsWith(volume.path)) { + throw new InternalServerError("Invalid path"); + } + + try { + const entries = await fs.readdir(normalizedPath, { withFileTypes: true }); + + const files = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(normalizedPath, entry.name); + const relativePath = path.relative(volume.path, fullPath); + + try { + const stats = await fs.stat(fullPath); + return { + name: entry.name, + path: `/${relativePath}`, + type: entry.isDirectory() ? ("directory" as const) : ("file" as const), + size: entry.isFile() ? stats.size : undefined, + modifiedAt: stats.mtimeMs, + }; + } catch { + return { + name: entry.name, + path: `/${relativePath}`, + type: entry.isDirectory() ? ("directory" as const) : ("file" as const), + size: undefined, + modifiedAt: undefined, + }; + } + }), + ); + + return { + files: files.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }), + path: subPath || "/", + }; + } catch (error) { + throw new InternalServerError(`Failed to list files: ${toMessage(error)}`); + } +}; + export const volumeService = { listVolumes, createVolume, @@ -264,4 +327,5 @@ export const volumeService = { unmountVolume, checkHealth, getContainersUsingVolume, + listFiles, };