import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { motion } from "framer-motion"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Button } from "@/components/ui/button"; import { api } from "@/lib/api"; import { X, Folder, File, ArrowLeft, FileCode, FileText, FileImage, Search, ChevronRight } from "lucide-react"; import type { FileEntry } from "@/lib/api"; import { cn } from "@/lib/utils"; // Global caches that persist across component instances const globalDirectoryCache = new Map(); const globalSearchCache = new Map(); interface FilePickerProps { basePath: string; onSelect: (entry: FileEntry) => void; onClose: () => void; initialQuery?: string; className?: string; allowDirectorySelection?: boolean; } // Memoized file icon selector const getFileIcon = (entry: FileEntry) => { if (entry.is_directory) return Folder; const ext = entry.name.split('.').pop()?.toLowerCase(); switch (ext) { case 'js': case 'jsx': case 'ts': case 'tsx': case 'py': case 'java': case 'cpp': case 'c': case 'go': case 'rs': return FileCode; case 'md': case 'txt': case 'json': case 'xml': case 'yaml': case 'yml': return FileText; case 'png': case 'jpg': case 'jpeg': case 'gif': case 'svg': case 'webp': return FileImage; default: return File; } }; const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; }; export const FilePicker: React.FC = React.memo(({ basePath, onSelect, onClose, initialQuery = "", className, allowDirectorySelection = false }) => { const [currentPath, setCurrentPath] = useState(basePath); const [entries, setEntries] = useState([]); const [searchQuery, setSearchQuery] = useState(initialQuery); const [selectedIndex, setSelectedIndex] = useState(0); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const searchInputRef = useRef(null); const scrollContainerRef = useRef(null); const searchDebounceRef = useRef(); // Filter and sort entries const displayEntries = useMemo(() => { const filtered = searchQuery.trim() ? entries.filter(entry => entry.name.toLowerCase().includes(searchQuery.toLowerCase()) ) : entries; return filtered.sort((a, b) => { if (a.is_directory !== b.is_directory) { return a.is_directory ? -1 : 1; } return a.name.localeCompare(b.name); }); }, [entries, searchQuery]); // Virtual scrolling setup const virtualizer = useVirtualizer({ count: displayEntries.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => 32, // Height of each item overscan: 10, // Number of items to render outside viewport }); const virtualItems = virtualizer.getVirtualItems(); // Load directory contents const loadDirectory = useCallback(async (path: string) => { const cacheKey = path; // Check cache first if (globalDirectoryCache.has(cacheKey)) { setEntries(globalDirectoryCache.get(cacheKey)!); return; } setIsLoading(true); setError(null); try { const result = await api.listDirectoryContents(path); globalDirectoryCache.set(cacheKey, result); setEntries(result); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load directory'); } finally { setIsLoading(false); } }, []); // Search functionality const performSearch = useCallback(async (query: string) => { if (!query.trim()) { loadDirectory(currentPath); return; } const cacheKey = `${currentPath}:${query}`; if (globalSearchCache.has(cacheKey)) { setEntries(globalSearchCache.get(cacheKey)!); return; } setIsLoading(true); setError(null); try { const result = await api.searchFiles(currentPath, query); globalSearchCache.set(cacheKey, result); setEntries(result); } catch (err) { setError(err instanceof Error ? err.message : 'Search failed'); } finally { setIsLoading(false); } }, [currentPath, loadDirectory]); // Handle entry click const handleEntryClick = useCallback((entry: FileEntry) => { if (!entry.is_directory || allowDirectorySelection) { onSelect(entry); } }, [onSelect, allowDirectorySelection]); // Handle entry double click const handleEntryDoubleClick = useCallback((entry: FileEntry) => { if (entry.is_directory) { setCurrentPath(entry.path); setSearchQuery(""); setSelectedIndex(0); } else { onSelect(entry); } }, [onSelect]); // Keyboard navigation const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (displayEntries.length === 0) return; switch (e.key) { case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => Math.max(0, prev - 1)); break; case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => Math.min(displayEntries.length - 1, prev + 1)); break; case 'Enter': e.preventDefault(); const selectedEntry = displayEntries[selectedIndex]; if (selectedEntry) { if (e.shiftKey || !selectedEntry.is_directory) { handleEntryClick(selectedEntry); } else { handleEntryDoubleClick(selectedEntry); } } break; case 'Escape': e.preventDefault(); onClose(); break; } }, [displayEntries, selectedIndex, handleEntryClick, handleEntryDoubleClick, onClose]); // Debounced search useEffect(() => { if (searchDebounceRef.current) { clearTimeout(searchDebounceRef.current); } searchDebounceRef.current = setTimeout(() => { performSearch(searchQuery); }, 300); return () => { if (searchDebounceRef.current) { clearTimeout(searchDebounceRef.current); } }; }, [searchQuery, performSearch]); // Load initial directory useEffect(() => { loadDirectory(currentPath); }, [currentPath, loadDirectory]); // Focus search input on mount useEffect(() => { searchInputRef.current?.focus(); }, []); // Scroll selected item into view useEffect(() => { const item = virtualizer.getVirtualItems().find( vItem => vItem.index === selectedIndex ); if (item) { virtualizer.scrollToIndex(selectedIndex, { align: 'center' }); } }, [selectedIndex, virtualizer]); return ( {/* Header */}
setSearchQuery(e.target.value)} placeholder="Search files..." className="flex-1 bg-transparent outline-none text-sm" />
{/* Current path */}
{currentPath}
{/* File list with virtual scrolling */}
{isLoading && (
Loading...
)} {error && (
{error}
)} {!isLoading && !error && displayEntries.length === 0 && (
{searchQuery.trim() ? 'No files found' : 'Empty directory'}
)} {displayEntries.length > 0 && (
{virtualItems.map((virtualRow) => { const entry = displayEntries[virtualRow.index]; const Icon = getFileIcon(entry); const isSelected = virtualRow.index === selectedIndex; return (
); })}
)}
{/* Footer */}
{displayEntries.length} {displayEntries.length === 1 ? 'item' : 'items'}
{allowDirectorySelection && (
Shift+Enter to select directory
)}
); });