From 87c61be9ef5fc06fa4fe87b7b89d7fdf353641ab Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 4 Oct 2025 17:37:05 +0200 Subject: [PATCH] feat: file tree component --- apps/client/app/components/file-tree.tsx | 330 ++++++++++++++++++ .../client/app/modules/details/tabs/files.tsx | 86 +++++ apps/client/app/routes/details.tsx | 7 +- 3 files changed, 422 insertions(+), 1 deletion(-) 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/components/file-tree.tsx b/apps/client/app/components/file-tree.tsx new file mode 100644 index 00000000..6881bfc4 --- /dev/null +++ b/apps/client/app/components/file-tree.tsx @@ -0,0 +1,330 @@ +/** + * 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 } from "lucide-react"; +import { memo, type ReactNode, 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; + className?: string; +} + +export const FileTree = memo( + ({ files = [], onFileSelect, selectedFile, onFolderExpand, expandedFolders = 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 = (fullPath: string) => { + setCollapsedFolders((prevSet) => { + const newSet = new Set(prevSet); + + if (newSet.has(fullPath)) { + newSet.delete(fullPath); + onFolderExpand?.(fullPath); + } else { + newSet.add(fullPath); + } + + return newSet; + }); + }; + + // Expand folders that are in the expandedFolders set + useEffect(() => { + setCollapsedFolders((prevSet) => { + const newSet = new Set(prevSet); + for (const folder of expandedFolders) { + newSet.delete(folder); + } + return newSet; + }); + }, [expandedFolders]); + + return ( +
+ {filteredFileList.map((fileOrFolder) => { + switch (fileOrFolder.kind) { + case "file": { + return ( + { + onFileSelect?.(fileOrFolder.fullPath); + }} + /> + ); + } + case "folder": { + return ( + { + toggleCollapseState(fileOrFolder.fullPath); + }} + /> + ); + } + default: { + return undefined; + } + } + })} +
+ ); + }, +); + +interface FolderProps { + folder: FolderNode; + collapsed: boolean; + onClick: () => void; +} + +function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) { + return ( + : } + onClick={onClick} + > + + {name} + + ); +} + +interface FileProps { + file: FileNode; + selected: boolean; + onClick: () => void; +} + +function File({ file: { depth, name }, onClick, selected }: FileProps) { + return ( + } + onClick={onClick} + > + {name} + + ); +} + +interface ButtonProps { + depth: number; + icon: ReactNode; + children: ReactNode; + className?: string; + onClick?: () => void; +} + +function NodeButton({ depth, icon, onClick, className, children }: ButtonProps) { + 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 folderPaths = new Set(); + const fileList: Node[] = []; + + for (const file of files) { + const segments = file.path.split("/").filter((segment) => segment); + let currentPath = ""; + let depth = 0; + + // Build folder hierarchy + for (let i = 0; i < segments.length - 1; i++) { + const name = segments[i]; + currentPath += `/${name}`; + + if (!folderPaths.has(currentPath)) { + folderPaths.add(currentPath); + fileList.push({ + kind: "folder", + id: fileList.length, + name, + fullPath: currentPath, + depth, + }); + } + depth++; + } + + // Add the file or final folder + const name = segments[segments.length - 1]; + currentPath += `/${name}`; + + if (file.type === "file") { + fileList.push({ + kind: "file", + id: fileList.length, + name, + fullPath: currentPath, + depth, + }); + } else if (!folderPaths.has(currentPath)) { + folderPaths.add(currentPath); + fileList.push({ + kind: "folder", + id: fileList.length, + name, + fullPath: currentPath, + depth, + }); + } + } + + return sortFileList(fileList); +} + +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 00000000..a418f4f5 --- /dev/null +++ b/apps/client/app/modules/details/tabs/files.tsx @@ -0,0 +1,86 @@ +import { useQuery } from "@tanstack/react-query"; +import { FolderOpen } from "lucide-react"; +import { useState } from "react"; +import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen"; +import { FileTree } from "~/components/file-tree"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import type { Volume } from "~/lib/types"; + +type Props = { + volume: Volume; +}; + +export const FilesTabContent = ({ volume }: Props) => { + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + + const { data, isLoading, error } = useQuery({ + ...listFilesOptions({ + path: { name: volume.name }, + }), + enabled: volume.status === "mounted", + refetchInterval: 10000, + }); + + const handleFolderExpand = (folderPath: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + next.add(folderPath); + return next; + }); + // You could optionally fetch the contents of the folder here + // For now, we're fetching everything at once + }; + + 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: {String(error)}

+
+ )} + {!isLoading && !error && data?.files && ( +
+ {data.files.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 70e19171..2646536c 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 + + +