diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index ad7f94d1..25dea170 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -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"; }; diff --git a/apps/client/app/components/file-tree.tsx b/apps/client/app/components/file-tree.tsx index 6881bfc4..934ff7e7 100644 --- a/apps/client/app/components/file-tree.tsx +++ b/apps/client/app/components/file-tree.tsx @@ -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; + loadingFolders?: Set; 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 ( : } + icon={ + loading ? ( + + ) : collapsed ? ( + + ) : ( + + ) + } onClick={onClick} > @@ -213,57 +245,26 @@ interface FolderNode extends BaseNode { } function buildFileList(files: FileEntry[]): Node[] { - const folderPaths = new Set(); - const fileList: Node[] = []; + const fileMap = new Map(); 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[] { diff --git a/apps/client/app/modules/details/tabs/files.tsx b/apps/client/app/modules/details/tabs/files.tsx index a418f4f5..32a33705 100644 --- a/apps/client/app/modules/details/tabs/files.tsx +++ b/apps/client/app/modules/details/tabs/files.tsx @@ -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>(new Set()); + const [fetchedFolders, setFetchedFolders] = useState>(new Set(["/"])); + const [loadingFolders, setLoadingFolders] = useState>(new Set()); + const [allFiles, setAllFiles] = useState>(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) => {

Failed to load files: {String(error)}

)} - {!isLoading && !error && data?.files && ( + {!isLoading && !error && (
- {data.files.length === 0 ? ( + {fileArray.length === 0 ? (

This volume is empty.

@@ -72,9 +138,10 @@ export const FilesTabContent = ({ volume }: Props) => {
) : ( )} diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index 85ae2f82..0b59d445 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -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",