feat: load sub folders

This commit is contained in:
Nicolas Meienberger 2025-10-05 17:58:17 +02:00
parent 87c61be9ef
commit ac3e2a6b01
4 changed files with 146 additions and 62 deletions

View file

@ -605,7 +605,12 @@ export type ListFilesData = {
path: {
name: string;
};
query?: never;
query?: {
/**
* Subdirectory path to list (relative to volume root)
*/
path?: string;
};
url: "/api/v1/volumes/{name}/files";
};

View file

@ -8,7 +8,7 @@
* Original source: https://github.com/stackblitz/bolt.new
*/
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon } from "lucide-react";
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, Loader2 } from "lucide-react";
import { memo, type ReactNode, useEffect, useMemo, useState } from "react";
import { cn } from "~/lib/utils";
@ -28,11 +28,20 @@ interface Props {
onFileSelect?: (filePath: string) => void;
onFolderExpand?: (folderPath: string) => void;
expandedFolders?: Set<string>;
loadingFolders?: Set<string>;
className?: string;
}
export const FileTree = memo(
({ files = [], onFileSelect, selectedFile, onFolderExpand, expandedFolders = new Set(), className }: Props) => {
({
files = [],
onFileSelect,
selectedFile,
onFolderExpand,
expandedFolders = new Set(),
loadingFolders = new Set(),
className,
}: Props) => {
const fileList = useMemo(() => {
return buildFileList(files);
}, [files]);
@ -82,6 +91,19 @@ export const FileTree = memo(
});
};
// 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]);
// Expand folders that are in the expandedFolders set
useEffect(() => {
setCollapsedFolders((prevSet) => {
@ -115,6 +137,7 @@ export const FileTree = memo(
key={fileOrFolder.id}
folder={fileOrFolder}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
loading={loadingFolders.has(fileOrFolder.fullPath)}
onClick={() => {
toggleCollapseState(fileOrFolder.fullPath);
}}
@ -134,15 +157,24 @@ export const FileTree = memo(
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
loading?: boolean;
onClick: () => void;
}
function Folder({ folder: { depth, name }, collapsed, onClick }: FolderProps) {
function Folder({ folder: { depth, name }, collapsed, loading, 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" />}
icon={
loading ? (
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
) : 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" />
@ -213,57 +245,26 @@ interface FolderNode extends BaseNode {
}
function buildFileList(files: FileEntry[]): Node[] {
const folderPaths = new Set<string>();
const fileList: Node[] = [];
const fileMap = new Map<string, 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 depth = segments.length - 1;
const name = segments[segments.length - 1];
currentPath += `/${name}`;
if (file.type === "file") {
fileList.push({
kind: "file",
id: fileList.length,
if (!fileMap.has(file.path)) {
fileMap.set(file.path, {
kind: file.type === "file" ? "file" : "folder",
id: fileMap.size,
name,
fullPath: currentPath,
depth,
});
} else if (!folderPaths.has(currentPath)) {
folderPaths.add(currentPath);
fileList.push({
kind: "folder",
id: fileList.length,
name,
fullPath: currentPath,
fullPath: file.path,
depth,
});
}
}
return sortFileList(fileList);
// Convert map to array and sort
return sortFileList(Array.from(fileMap.values()));
}
function sortFileList(nodeList: Node[]): Node[] {

View file

@ -1,7 +1,7 @@
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 { useCallback, useMemo, useState } from "react";
import { listFiles } from "~/api-client/sdk.gen";
import { FileTree } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import type { Volume } from "~/lib/types";
@ -10,26 +10,92 @@ 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<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
// Fetch root level files
const { data, isLoading, error } = useQuery({
...listFilesOptions({
path: { name: volume.name },
}),
queryKey: ["volume-files", volume.name, "/"],
queryFn: async () => {
const result = await listFiles({
path: { name: volume.name },
throwOnError: true,
});
return result.data;
},
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
};
// Update allFiles when root data changes
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 we haven't fetched this folder yet, fetch it
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 (
@ -60,9 +126,9 @@ export const FilesTabContent = ({ volume }: Props) => {
<p className="text-destructive">Failed to load files: {String(error)}</p>
</div>
)}
{!isLoading && !error && data?.files && (
{!isLoading && !error && (
<div className="overflow-auto flex-1 border rounded-md bg-card">
{data.files.length === 0 ? (
{fileArray.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>
@ -72,9 +138,10 @@ export const FilesTabContent = ({ volume }: Props) => {
</div>
) : (
<FileTree
files={data.files}
files={fileArray}
onFolderExpand={handleFolderExpand}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
className="p-2"
/>
)}

View file

@ -327,6 +327,17 @@ 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",