import { useRef, useCallback, useMemo, useState, useEffect } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { DirEntry, TaskSummary } from 'apps/FileBrowser'; import { FileItem } from './FileItem'; type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null; type FileGridProps = { entries: DirEntry[]; viewMode: 'grid' | 'list'; currentPath: string; selected: Set; clipboard: ClipboardState; onOpen: (entry: DirEntry) => void; onDelete: (entry: DirEntry) => void; onRename: (entry: DirEntry, newName: string) => void; onChat: (entry: DirEntry) => void; onSelect: (names: Set) => void; onCut: () => void; onCopy: () => void; renamingName: string | null; onRenamingChange: (name: string | null) => void; getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; scrollRef: React.RefObject; }; const LIST_ROW_HEIGHT = 42; const GRID_ROW_HEIGHT = 130; const GRID_GAP = 12; const useColumnCount = (scrollRef: React.RefObject) => { const [cols, setCols] = useState(4); useEffect(() => { const el = scrollRef.current; if (!el) return; const measure = () => { const width = el.clientWidth - 32; // subtract p-4 (16px each side) // Breakpoints matching grid-cols-2 sm:3 md:4 lg:5 xl:6 if (width >= 1280) setCols(6); else if (width >= 1024) setCols(5); else if (width >= 768) setCols(4); else if (width >= 640) setCols(3); else setCols(2); }; measure(); const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); }, [scrollRef]); return cols; }; export const FileGrid = ({ entries, viewMode, currentPath, selected, clipboard, onOpen, onDelete, onRename, onChat, onSelect, onCut, onCopy, renamingName, onRenamingChange, getMatchingTasks, onRunTask, scrollRef, }: FileGridProps) => { const lastClickedIdx = useRef(-1); const sorted = useMemo( () => [...entries].sort((a, b) => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; return a.name.localeCompare(b.name); }), [entries], ); const handleSelect = useCallback( (entry: DirEntry, ev: React.MouseEvent) => { const idx = sorted.findIndex((e) => e.name === entry.name); if (ev.shiftKey && lastClickedIdx.current >= 0) { const start = Math.min(lastClickedIdx.current, idx); const end = Math.max(lastClickedIdx.current, idx); const next = new Set(selected); for (let i = start; i <= end; i++) { next.add(sorted[i]!.name); } onSelect(next); } else if (ev.ctrlKey || ev.metaKey) { const next = new Set(selected); if (next.has(entry.name)) { next.delete(entry.name); } else { next.add(entry.name); } onSelect(next); lastClickedIdx.current = idx; } else { onSelect(new Set([entry.name])); lastClickedIdx.current = idx; } }, [sorted, selected, onSelect], ); const cols = useColumnCount(scrollRef); const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set(); const anySelected = selected.size > 0; const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`); const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols); const virtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => scrollRef.current, estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP), overscan: 10, }); if (entries.length === 0) { return
This folder is empty
; } const renderItem = (entry: DirEntry) => ( ); return (
{virtualizer.getVirtualItems().map((virtualRow) => { if (viewMode === 'list') { const entry = sorted[virtualRow.index]!; return (
{renderItem(entry)}
); } const startIdx = virtualRow.index * cols; const rowEntries = sorted.slice(startIdx, startIdx + cols); return (
{rowEntries.map(renderItem)}
); })}
); };