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: { path: {
name: string; name: string;
}; };
query?: never; query?: {
/**
* Subdirectory path to list (relative to volume root)
*/
path?: string;
};
url: "/api/v1/volumes/{name}/files"; url: "/api/v1/volumes/{name}/files";
}; };

View file

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

View file

@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { useState } from "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 { FileTree } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import type { Volume } from "~/lib/types"; import type { Volume } from "~/lib/types";
@ -10,26 +10,92 @@ type Props = {
volume: Volume; volume: Volume;
}; };
interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
export const FilesTabContent = ({ volume }: Props) => { export const FilesTabContent = ({ volume }: Props) => {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); 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({ const { data, isLoading, error } = useQuery({
...listFilesOptions({ queryKey: ["volume-files", volume.name, "/"],
queryFn: async () => {
const result = await listFiles({
path: { name: volume.name }, path: { name: volume.name },
}), throwOnError: true,
});
return result.data;
},
enabled: volume.status === "mounted", enabled: volume.status === "mounted",
refetchInterval: 10000, refetchInterval: 10000,
}); });
const handleFolderExpand = (folderPath: string) => { // 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) => { setExpandedFolders((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.add(folderPath); next.add(folderPath);
return next; return next;
}); });
// You could optionally fetch the contents of the folder here
// For now, we're fetching everything at once // 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") { if (volume.status !== "mounted") {
return ( return (
@ -60,9 +126,9 @@ export const FilesTabContent = ({ volume }: Props) => {
<p className="text-destructive">Failed to load files: {String(error)}</p> <p className="text-destructive">Failed to load files: {String(error)}</p>
</div> </div>
)} )}
{!isLoading && !error && data?.files && ( {!isLoading && !error && (
<div className="overflow-auto flex-1 border rounded-md bg-card"> <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"> <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" /> <FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">This volume is empty.</p> <p className="text-muted-foreground">This volume is empty.</p>
@ -72,9 +138,10 @@ export const FilesTabContent = ({ volume }: Props) => {
</div> </div>
) : ( ) : (
<FileTree <FileTree
files={data.files} files={fileArray}
onFolderExpand={handleFolderExpand} onFolderExpand={handleFolderExpand}
expandedFolders={expandedFolders} expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
className="p-2" className="p-2"
/> />
)} )}

View file

@ -327,6 +327,17 @@ export const listFilesDto = describeRoute({
description: "List files in a volume directory", description: "List files in a volume directory",
operationId: "listFiles", operationId: "listFiles",
tags: ["Volumes"], tags: ["Volumes"],
parameters: [
{
in: "query",
name: "path",
required: false,
schema: {
type: "string",
},
description: "Subdirectory path to list (relative to volume root)",
},
],
responses: { responses: {
200: { 200: {
description: "List of files in the volume", description: "List of files in the volume",