feat: load sub folders
This commit is contained in:
parent
87c61be9ef
commit
ac3e2a6b01
4 changed files with 146 additions and 62 deletions
|
|
@ -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";
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue