feat: file tree component
This commit is contained in:
parent
6e7846c165
commit
87c61be9ef
3 changed files with 422 additions and 1 deletions
330
apps/client/app/components/file-tree.tsx
Normal file
330
apps/client/app/components/file-tree.tsx
Normal 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" });
|
||||
}
|
||||
86
apps/client/app/modules/details/tabs/files.tsx
Normal file
86
apps/client/app/modules/details/tabs/files.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue