import { useRef, useCallback, useMemo, useState, useEffect } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ChevronUp, ChevronDown } from 'lucide-react'; import type { DirEntry, TaskSummary } from 'apps/FileBrowser'; import { getFileType } from 'apps/FileViewer'; import { FileItem } from './FileItem'; type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null; type SortField = 'name' | 'size' | 'type' | 'date'; type SortDirection = 'asc' | 'desc'; 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; onDownload: (entry: DirEntry) => void; onSelect: (names: Set) => void; onCut: () => void; onCopy: () => void; renamingName: string | null; onRenamingChange: (name: string | null) => void; onReadAloud: (entry: DirEntry) => void; onOcr: (entry: DirEntry) => void; onTranscribe: (entry: DirEntry) => void; onExtractAudio: (entry: DirEntry) => void; onExtract: (entry: DirEntry) => void; getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; onCreateWorkspace: (entry: DirEntry) => void; scrollRef: React.RefObject; }; const LIST_ROW_HEIGHT = 42; const GRID_ROW_HEIGHT = 130; const GRID_GAP = 12; const HEADER_HEIGHT = 36; 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, onDownload, onSelect, onCut, onCopy, renamingName, onRenamingChange, onReadAloud, onOcr, onTranscribe, onExtractAudio, onExtract, getMatchingTasks, onRunTask, onCreateWorkspace, scrollRef, }: FileGridProps) => { const lastClickedIdx = useRef(-1); const [sortField, setSortField] = useState('name'); const [sortDirection, setSortDirection] = useState('asc'); const sorted = useMemo(() => { const compare = (a: DirEntry, b: DirEntry): number => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; let result: number; switch (sortField) { case 'name': result = a.name.localeCompare(b.name); break; case 'size': result = a.size - b.size; break; case 'type': { const typeA = a.type === 'directory' ? 'directory' : getFileType(a.name); const typeB = b.type === 'directory' ? 'directory' : getFileType(b.name); result = typeA.localeCompare(typeB); if (result === 0) result = a.name.localeCompare(b.name); break; } case 'date': result = a.modifiedAt - b.modifiedAt; break; } return sortDirection === 'asc' ? result : -result; }; return [...entries].sort(compare); }, [entries, sortField, sortDirection]); const toggleSort = (field: SortField) => { if (sortField === field) { setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')); } else { setSortField(field); setSortDirection('asc'); } }; 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, scrollMargin: HEADER_HEIGHT, }); if (entries.length === 0) { return
This folder is empty
; } const renderItem = (entry: DirEntry) => ( ); const sortIcon = (field: SortField) => sortField === field ? ( sortDirection === 'asc' ? ( ) : ( ) ) : null; const sortBtnClass = (field: SortField) => `flex items-center gap-1 text-xs font-medium cursor-pointer transition-colors ${ sortField === field ? 'text-duck-teal' : 'text-duck-dark/50 hover:text-duck-dark/80' }`; return ( <> {viewMode === 'list' ? (
) : (
Sort: {(['name', 'size', 'type', 'date'] as SortField[]).map((field) => ( ))}
)}
{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)}
); })}
); };