refactor: improve file explorer performance by pre-fetching on hover

This commit is contained in:
Nicolas Meienberger
2025-10-17 21:19:58 +02:00
parent c8fc5a1273
commit 269116c25e
3 changed files with 35 additions and 4 deletions

View File

@@ -27,6 +27,7 @@ interface Props {
selectedFile?: string; selectedFile?: string;
onFileSelect?: (filePath: string) => void; onFileSelect?: (filePath: string) => void;
onFolderExpand?: (folderPath: string) => void; onFolderExpand?: (folderPath: string) => void;
onFolderHover?: (folderPath: string) => void;
expandedFolders?: Set<string>; expandedFolders?: Set<string>;
loadingFolders?: Set<string>; loadingFolders?: Set<string>;
className?: string; className?: string;
@@ -38,6 +39,7 @@ export const FileTree = memo((props: Props) => {
onFileSelect, onFileSelect,
selectedFile, selectedFile,
onFolderExpand, onFolderExpand,
onFolderHover,
expandedFolders = new Set(), expandedFolders = new Set(),
loadingFolders = new Set(), loadingFolders = new Set(),
className, className,
@@ -137,6 +139,7 @@ export const FileTree = memo((props: Props) => {
collapsed={collapsedFolders.has(fileOrFolder.fullPath)} collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
loading={loadingFolders.has(fileOrFolder.fullPath)} loading={loadingFolders.has(fileOrFolder.fullPath)}
onToggle={toggleCollapseState} onToggle={toggleCollapseState}
onHover={onFolderHover}
/> />
); );
} }
@@ -154,9 +157,10 @@ interface FolderProps {
collapsed: boolean; collapsed: boolean;
loading?: boolean; loading?: boolean;
onToggle: (fullPath: string) => void; onToggle: (fullPath: string) => void;
onHover?: (fullPath: string) => void;
} }
const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => { const Folder = memo(({ folder, collapsed, loading, onToggle, onHover }: FolderProps) => {
const { depth, name, fullPath } = folder; const { depth, name, fullPath } = folder;
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen; const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
@@ -164,6 +168,12 @@ const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
onToggle(fullPath); onToggle(fullPath);
}, [onToggle, fullPath]); }, [onToggle, fullPath]);
const handleMouseEnter = useCallback(() => {
if (collapsed) {
onHover?.(fullPath);
}
}, [onHover, fullPath, collapsed]);
return ( return (
<NodeButton <NodeButton
className={cn("group hover:bg-accent/50 text-foreground")} className={cn("group hover:bg-accent/50 text-foreground")}
@@ -178,6 +188,7 @@ const Folder = memo(({ folder, collapsed, loading, onToggle }: FolderProps) => {
) )
} }
onClick={handleClick} onClick={handleClick}
onMouseEnter={handleMouseEnter}
> >
<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>
@@ -219,9 +230,10 @@ interface ButtonProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
onMouseEnter?: () => void;
} }
const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonProps) => { const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]); const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return ( return (
@@ -230,6 +242,7 @@ const NodeButton = memo(({ depth, icon, onClick, className, children }: ButtonPr
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 }} style={{ paddingLeft }}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter}
> >
{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>

View File

@@ -1,11 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } 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 { 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 = {
@@ -21,6 +20,7 @@ interface FileEntry {
} }
export const FilesTabContent = ({ volume }: Props) => { export const FilesTabContent = ({ volume }: Props) => {
const queryClient = useQueryClient();
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 [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set()); const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
@@ -88,6 +88,21 @@ export const FilesTabContent = ({ volume }: Props) => {
[volume.name, fetchedFolders], [volume.name, fetchedFolders],
); );
// Prefetch folder contents on hover
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
queryClient.prefetchQuery(
listFilesOptions({
path: { name: volume.name },
query: { path: folderPath },
}),
);
}
},
[volume.name, fetchedFolders, loadingFolders, queryClient],
);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]); const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
if (volume.status !== "mounted") { if (volume.status !== "mounted") {
@@ -133,6 +148,7 @@ export const FilesTabContent = ({ volume }: Props) => {
<FileTree <FileTree
files={fileArray} files={fileArray}
onFolderExpand={handleFolderExpand} onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders} expandedFolders={expandedFolders}
loadingFolders={loadingFolders} loadingFolders={loadingFolders}
className="p-2" className="p-2"

View File

@@ -131,5 +131,7 @@ export const volumeController = new Hono()
path: result.path, path: result.path,
} satisfies ListFilesResponseDto; } satisfies ListFilesResponseDto;
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
return c.json(response, 200); return c.json(response, 200);
}); });