chore: file-tree optimizations

This commit is contained in:
Nicolas Meienberger 2025-10-06 18:54:24 +02:00
parent 6866d10dc5
commit da28c11848
2 changed files with 54 additions and 42 deletions

View file

@ -9,7 +9,7 @@
*/ */
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react"; import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react";
import { memo, type ReactNode, useEffect, useMemo, useState } from "react"; import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
const NODE_PADDING_LEFT = 12; const NODE_PADDING_LEFT = 12;
@ -77,20 +77,23 @@ export const FileTree = memo((props: Props) => {
return list; return list;
}, [fileList, collapsedFolders]); }, [fileList, collapsedFolders]);
const toggleCollapseState = (fullPath: string) => { const toggleCollapseState = useCallback(
setCollapsedFolders((prevSet) => { (fullPath: string) => {
const newSet = new Set(prevSet); setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
if (newSet.has(fullPath)) { if (newSet.has(fullPath)) {
newSet.delete(fullPath); newSet.delete(fullPath);
onFolderExpand?.(fullPath); onFolderExpand?.(fullPath);
} else { } else {
newSet.add(fullPath); newSet.add(fullPath);
} }
return newSet; return newSet;
}); });
}; },
[onFolderExpand],
);
// Add new folders to collapsed set when file list changes // Add new folders to collapsed set when file list changes
useEffect(() => { useEffect(() => {
@ -105,6 +108,13 @@ export const FileTree = memo((props: Props) => {
}); });
}, [fileList, expandedFolders]); }, [fileList, expandedFolders]);
const handleFileSelect = useCallback(
(filePath: string) => {
onFileSelect?.(filePath);
},
[onFileSelect],
);
return ( return (
<div className={cn("text-sm", className)}> <div className={cn("text-sm", className)}>
{filteredFileList.map((fileOrFolder) => { {filteredFileList.map((fileOrFolder) => {
@ -115,9 +125,7 @@ export const FileTree = memo((props: Props) => {
key={fileOrFolder.id} key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath} selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder} file={fileOrFolder}
onClick={() => { onFileSelect={handleFileSelect}
onFileSelect?.(fileOrFolder.fullPath);
}}
/> />
); );
} }
@ -128,9 +136,7 @@ export const FileTree = memo((props: Props) => {
folder={fileOrFolder} folder={fileOrFolder}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)} collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
loading={loadingFolders.has(fileOrFolder.fullPath)} loading={loadingFolders.has(fileOrFolder.fullPath)}
onClick={() => { onToggle={toggleCollapseState}
toggleCollapseState(fileOrFolder.fullPath);
}}
/> />
); );
} }
@ -147,12 +153,17 @@ interface FolderProps {
folder: FolderNode; folder: FolderNode;
collapsed: boolean; collapsed: boolean;
loading?: boolean; loading?: boolean;
onClick: () => void; onToggle: (fullPath: string) => void;
} }
function Folder({ folder: { depth, name }, collapsed, loading, onClick }: FolderProps) { const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
const { depth, name, fullPath } = folder;
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen; const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
const handleClick = useCallback(() => {
onToggle(fullPath);
}, [onToggle, fullPath]);
return ( return (
<NodeButton <NodeButton
className={cn("group hover:bg-accent/50 text-foreground")} className={cn("group hover:bg-accent/50 text-foreground")}
@ -166,21 +177,27 @@ function Folder({ folder: { depth, name }, collapsed, loading, onClick }: Folder
<ChevronDown className="w-4 h-4 shrink-0" /> <ChevronDown className="w-4 h-4 shrink-0" />
) )
} }
onClick={onClick} onClick={handleClick}
> >
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" /> <FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
</NodeButton> </NodeButton>
); );
} });
interface FileProps { interface FileProps {
file: FileNode; file: FileNode;
selected: boolean; selected: boolean;
onClick: () => void; onFileSelect: (filePath: string) => void;
} }
function File({ file: { depth, name }, onClick, selected }: FileProps) { const File = memo(({ file, onFileSelect, selected }: FileProps) => {
const { depth, name, fullPath } = file;
const handleClick = useCallback(() => {
onFileSelect(fullPath);
}, [onFileSelect, fullPath]);
return ( return (
<NodeButton <NodeButton
className={cn("group", { className={cn("group", {
@ -189,12 +206,12 @@ function File({ file: { depth, name }, onClick, selected }: FileProps) {
})} })}
depth={depth} depth={depth}
icon={<FileIcon className="w-4 h-4 shrink-0 text-gray-500" />} icon={<FileIcon className="w-4 h-4 shrink-0 text-gray-500" />}
onClick={onClick} onClick={handleClick}
> >
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
</NodeButton> </NodeButton>
); );
} });
interface ButtonProps { interface ButtonProps {
depth: number; depth: number;
@ -204,19 +221,21 @@ interface ButtonProps {
onClick?: () => void; onClick?: () => void;
} }
function NodeButton({ depth, icon, onClick, className, children }: ButtonProps) { const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonProps) => {
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return ( return (
<button <button
type="button" type="button"
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)} className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft: `${8 + depth * NODE_PADDING_LEFT}px` }} style={{ paddingLeft }}
onClick={() => onClick?.()} onClick={onClick}
> >
{icon} {icon}
<div className="truncate w-full flex items-center gap-2">{children}</div> <div className="truncate w-full flex items-center gap-2">{children}</div>
</button> </button>
); );
} });
type Node = FileNode | FolderNode; type Node = FileNode | FolderNode;

View file

@ -1,9 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, 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 { 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 { parseError } from "~/lib/errors";
import type { Volume } from "~/lib/types"; import type { Volume } from "~/lib/types";
type Props = { type Props = {
@ -26,19 +28,11 @@ export const FilesTabContent = ({ volume }: Props) => {
// Fetch root level files // Fetch root level files
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["volume-files", volume.name, "/"], ...listFilesOptions({ 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,
}); });
// Update allFiles when root data changes
useMemo(() => { useMemo(() => {
if (data?.files) { if (data?.files) {
setAllFiles((prev) => { setAllFiles((prev) => {
@ -59,7 +53,6 @@ export const FilesTabContent = ({ volume }: Props) => {
return next; return next;
}); });
// If we haven't fetched this folder yet, fetch it
if (!fetchedFolders.has(folderPath)) { if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath)); setLoadingFolders((prev) => new Set(prev).add(folderPath));
@ -123,7 +116,7 @@ export const FilesTabContent = ({ volume }: Props) => {
)} )}
{error && ( {error && (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-destructive">Failed to load files: {String(error)}</p> <p className="text-destructive">Failed to load files: {error.message}</p>
</div> </div>
)} )}
{!isLoading && !error && ( {!isLoading && !error && (