import React, { useState, useEffect, useRef } from "react"; import { motion } from "framer-motion"; 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(); // Note: These caches persist for the lifetime of the application. // In a production app, you might want to: // 1. Add TTL (time-to-live) to expire old entries // 2. Implement LRU (least recently used) eviction // 3. Clear caches when the working directory changes // 4. Add a maximum cache size limit interface FilePickerProps { /** * The base directory path to browse */ basePath: string; /** * Callback when a file/directory is selected */ onSelect: (entry: FileEntry) => void; /** * Callback to close the picker */ onClose: () => void; /** * Initial search query */ initialQuery?: string; /** * Optional className for styling */ className?: string; } // File icon mapping based on extension const getFileIcon = (entry: FileEntry) => { if (entry.is_directory) return Folder; const ext = entry.extension?.toLowerCase(); if (!ext) return File; // Code files if (['ts', 'tsx', 'js', 'jsx', 'py', 'rs', 'go', 'java', 'cpp', 'c', 'h'].includes(ext)) { return FileCode; } // Text/Markdown files if (['md', 'txt', 'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'css'].includes(ext)) { return FileText; } // Image files if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'].includes(ext)) { return FileImage; } return File; }; // Format file size to human readable const formatFileSize = (bytes: number): string => { if (bytes === 0) return ''; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; /** * FilePicker component - File browser with fuzzy search * * @example * console.log('Selected:', entry)} * onClose={() => setShowPicker(false)} * /> */ export const FilePicker: React.FC = ({ basePath, onSelect, onClose, initialQuery = "", className, }) => { const searchQuery = initialQuery; const [currentPath, setCurrentPath] = useState(basePath); const [entries, setEntries] = useState(() => searchQuery.trim() ? [] : globalDirectoryCache.get(basePath) || [] ); const [searchResults, setSearchResults] = useState(() => { if (searchQuery.trim()) { const cacheKey = `${basePath}:${searchQuery}`; return globalSearchCache.get(cacheKey) || []; } return []; }); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [pathHistory, setPathHistory] = useState([basePath]); const [selectedIndex, setSelectedIndex] = useState(0); const [isShowingCached, setIsShowingCached] = useState(() => { // Check if we're showing cached data on mount if (searchQuery.trim()) { const cacheKey = `${basePath}:${searchQuery}`; return globalSearchCache.has(cacheKey); } return globalDirectoryCache.has(basePath); }); const searchDebounceRef = useRef(null); const fileListRef = useRef(null); // Computed values const displayEntries = searchQuery.trim() ? searchResults : entries; const canGoBack = pathHistory.length > 1; // Get relative path for display const relativePath = currentPath.startsWith(basePath) ? currentPath.slice(basePath.length) || '/' : currentPath; // Load directory contents useEffect(() => { loadDirectory(currentPath); }, [currentPath]); // Debounced search useEffect(() => { if (searchDebounceRef.current) { clearTimeout(searchDebounceRef.current); } if (searchQuery.trim()) { const cacheKey = `${basePath}:${searchQuery}`; // Immediately show cached results if available if (globalSearchCache.has(cacheKey)) { console.log('[FilePicker] Immediately showing cached search results for:', searchQuery); setSearchResults(globalSearchCache.get(cacheKey) || []); setIsShowingCached(true); setError(null); } // Schedule fresh search after debounce searchDebounceRef.current = setTimeout(() => { performSearch(searchQuery); }, 300); } else { setSearchResults([]); setIsShowingCached(false); } return () => { if (searchDebounceRef.current) { clearTimeout(searchDebounceRef.current); } }; }, [searchQuery, basePath]); // Reset selected index when entries change useEffect(() => { setSelectedIndex(0); }, [entries, searchResults]); // Keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const displayEntries = searchQuery.trim() ? searchResults : entries; switch (e.key) { case 'Escape': e.preventDefault(); onClose(); break; case 'Enter': e.preventDefault(); // Enter always selects the current item (file or directory) if (displayEntries.length > 0 && selectedIndex < displayEntries.length) { onSelect(displayEntries[selectedIndex]); } break; 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 'ArrowRight': e.preventDefault(); // Right arrow enters directories if (displayEntries.length > 0 && selectedIndex < displayEntries.length) { const entry = displayEntries[selectedIndex]; if (entry.is_directory) { navigateToDirectory(entry.path); } } break; case 'ArrowLeft': e.preventDefault(); // Left arrow goes back to parent directory if (canGoBack) { navigateBack(); } break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [entries, searchResults, selectedIndex, searchQuery, canGoBack]); // Scroll selected item into view useEffect(() => { if (fileListRef.current) { const selectedElement = fileListRef.current.querySelector(`[data-index="${selectedIndex}"]`); if (selectedElement) { selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } }, [selectedIndex]); const loadDirectory = async (path: string) => { try { console.log('[FilePicker] Loading directory:', path); // Check cache first and show immediately if (globalDirectoryCache.has(path)) { console.log('[FilePicker] Showing cached contents for:', path); setEntries(globalDirectoryCache.get(path) || []); setIsShowingCached(true); setError(null); } else { // Only show loading if we don't have cached data setIsLoading(true); } // Always fetch fresh data in background const contents = await api.listDirectoryContents(path); console.log('[FilePicker] Loaded fresh contents:', contents.length, 'items'); // Cache the results globalDirectoryCache.set(path, contents); // Update with fresh data setEntries(contents); setIsShowingCached(false); setError(null); } catch (err) { console.error('[FilePicker] Failed to load directory:', path, err); console.error('[FilePicker] Error details:', err); // Only set error if we don't have cached data to show if (!globalDirectoryCache.has(path)) { setError(err instanceof Error ? err.message : 'Failed to load directory'); } } finally { setIsLoading(false); } }; const performSearch = async (query: string) => { try { console.log('[FilePicker] Searching for:', query, 'in:', basePath); // Create cache key that includes both query and basePath const cacheKey = `${basePath}:${query}`; // Check cache first and show immediately if (globalSearchCache.has(cacheKey)) { console.log('[FilePicker] Showing cached search results for:', query); setSearchResults(globalSearchCache.get(cacheKey) || []); setIsShowingCached(true); setError(null); } else { // Only show loading if we don't have cached data setIsLoading(true); } // Always fetch fresh results in background const results = await api.searchFiles(basePath, query); console.log('[FilePicker] Fresh search results:', results.length, 'items'); // Cache the results globalSearchCache.set(cacheKey, results); // Update with fresh results setSearchResults(results); setIsShowingCached(false); setError(null); } catch (err) { console.error('[FilePicker] Search failed:', query, err); // Only set error if we don't have cached data to show const cacheKey = `${basePath}:${query}`; if (!globalSearchCache.has(cacheKey)) { setError(err instanceof Error ? err.message : 'Search failed'); } } finally { setIsLoading(false); } }; const navigateToDirectory = (path: string) => { setCurrentPath(path); setPathHistory(prev => [...prev, path]); }; const navigateBack = () => { if (pathHistory.length > 1) { const newHistory = [...pathHistory]; newHistory.pop(); // Remove current const previousPath = newHistory[newHistory.length - 1]; // Don't go beyond the base path if (previousPath.startsWith(basePath) || previousPath === basePath) { setCurrentPath(previousPath); setPathHistory(newHistory); } } }; const handleEntryClick = (entry: FileEntry) => { // Single click always selects (file or directory) onSelect(entry); }; const handleEntryDoubleClick = (entry: FileEntry) => { // Double click navigates into directories if (entry.is_directory) { navigateToDirectory(entry.path); } }; return ( {/* Header */}
{relativePath}
{/* File List */}
{/* Show loading only if no cached data */} {isLoading && displayEntries.length === 0 && (
Loading...
)} {/* Show subtle indicator when displaying cached data while fetching fresh */} {isShowingCached && isLoading && displayEntries.length > 0 && (
updating...
)} {error && displayEntries.length === 0 && (
{error}
)} {!isLoading && !error && displayEntries.length === 0 && (
{searchQuery.trim() ? 'No files found' : 'Empty directory'}
)} {displayEntries.length > 0 && (
{displayEntries.map((entry, index) => { const Icon = getFileIcon(entry); const isSearching = searchQuery.trim() !== ''; const isSelected = index === selectedIndex; return ( ); })}
)}
{/* Footer */}

↑↓ Navigate • Enter Select • → Enter Directory • ← Go Back • Esc Close

); };