feat: file tree component

This commit is contained in:
Nicolas Meienberger 2025-10-04 17:37:05 +02:00
parent 6e7846c165
commit 87c61be9ef
3 changed files with 422 additions and 1 deletions

View file

@ -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<string>;
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<Set<string>>(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 (
<div className={cn("text-sm", className)}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case "file": {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
onClick={() => {
onFileSelect?.(fileOrFolder.fullPath);
}}
/>
);
}
case "folder": {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
onClick={() => {
toggleCollapseState(fileOrFolder.fullPath);
}}
/>
);
}
default: {
return undefined;
}
}
})}
</div>
);
},
);
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
onClick: () => void;
}
function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
return (
<NodeButton
className={cn("group hover:bg-accent/50 text-foreground")}
depth={depth}
icon={collapsed ? <ChevronRight className="w-4 h-4 shrink-0" /> : <ChevronDown className="w-4 h-4 shrink-0" />}
onClick={onClick}
>
<FolderIcon className="w-4 h-4 shrink-0 text-strong-accent" />
<span className="truncate">{name}</span>
</NodeButton>
);
}
interface FileProps {
file: FileNode;
selected: boolean;
onClick: () => void;
}
function File({ file: { depth, name }, onClick, selected }: FileProps) {
return (
<NodeButton
className={cn("group", {
"hover:bg-accent/50 text-foreground": !selected,
"bg-accent text-accent-foreground": selected,
})}
depth={depth}
icon={<FileIcon className="w-4 h-4 shrink-0 text-gray-500" />}
onClick={onClick}
>
<span className="truncate">{name}</span>
</NodeButton>
);
}
interface ButtonProps {
depth: number;
icon: ReactNode;
children: ReactNode;
className?: string;
onClick?: () => void;
}
function NodeButton({ depth, icon, onClick, className, children }: ButtonProps) {
return (
<button
type="button"
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft: `${8 + depth * NODE_PADDING_LEFT}px` }}
onClick={() => onClick?.()}
>
{icon}
<div className="truncate w-full flex items-center gap-2">{children}</div>
</button>
);
}
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<string>();
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<string, Node>();
const childrenMap = new Map<string, Node[]>();
// 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" });
}

View file

@ -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<Set<string>>(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 (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">Volume must be mounted to browse files.</p>
<p className="text-sm text-muted-foreground mt-2">Mount the volume to explore its contents.</p>
</CardContent>
</Card>
);
}
return (
<Card className="h-[600px] flex flex-col">
<CardHeader>
<CardTitle>File Explorer</CardTitle>
<CardDescription>Browse the files and folders in this volume.</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col">
{isLoading && (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full">
<p className="text-destructive">Failed to load files: {String(error)}</p>
</div>
)}
{!isLoading && !error && data?.files && (
<div className="overflow-auto flex-1 border rounded-md bg-card">
{data.files.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">This volume is empty.</p>
<p className="text-sm text-muted-foreground mt-2">
Files and folders will appear here once you add them.
</p>
</div>
) : (
<FileTree
files={data.files}
onFolderExpand={handleFolderExpand}
expandedFolders={expandedFolders}
className="p-2"
/>
)}
</div>
)}
</CardContent>
</Card>
);
};

View file

@ -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) {
<Tabs defaultValue="info" className="mt-4">
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
</TabsList>
<TabsContent value="info">
<VolumeInfoTabContent volume={volume} statfs={statfs} />
</TabsContent>
<TabsContent value="files">
<FilesTabContent volume={volume} />
</TabsContent>
<TabsContent value="docker">
<DockerTabContent volume={volume} />
</TabsContent>