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

View file

@ -1,9 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { FolderOpen } from "lucide-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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { parseError } from "~/lib/errors";
import type { Volume } from "~/lib/types";
type Props = {
@ -26,19 +28,11 @@ export const FilesTabContent = ({ volume }: Props) => {
// Fetch root level files
const { data, isLoading, error } = useQuery({
queryKey: ["volume-files", volume.name, "/"],
queryFn: async () => {
const result = await listFiles({
path: { name: volume.name },
throwOnError: true,
});
return result.data;
},
...listFilesOptions({ path: { name: volume.name } }),
enabled: volume.status === "mounted",
refetchInterval: 10000,
});
// Update allFiles when root data changes
useMemo(() => {
if (data?.files) {
setAllFiles((prev) => {
@ -59,7 +53,6 @@ export const FilesTabContent = ({ volume }: Props) => {
return next;
});
// If we haven't fetched this folder yet, fetch it
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
@ -123,7 +116,7 @@ export const FilesTabContent = ({ volume }: Props) => {
)}
{error && (
<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>
)}
{!isLoading && !error && (