chore: file-tree optimizations
This commit is contained in:
parent
6866d10dc5
commit
da28c11848
2 changed files with 54 additions and 42 deletions
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue