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: {
|
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";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[] {
|
||||||
|
|
|
||||||
|
|
@ -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, "/"],
|
||||||
path: { name: volume.name },
|
queryFn: async () => {
|
||||||
}),
|
const result = await listFiles({
|
||||||
|
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
|
||||||
setExpandedFolders((prev) => {
|
useMemo(() => {
|
||||||
const next = new Set(prev);
|
if (data?.files) {
|
||||||
next.add(folderPath);
|
setAllFiles((prev) => {
|
||||||
return next;
|
const next = new Map(prev);
|
||||||
});
|
for (const file of data.files) {
|
||||||
// You could optionally fetch the contents of the folder here
|
next.set(file.path, file);
|
||||||
// For now, we're fetching everything at once
|
}
|
||||||
};
|
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") {
|
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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue