Files
claudia/src/components/FilePicker.tsx
2025-06-19 19:24:01 +05:30

492 lines
16 KiB
TypeScript

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<string, FileEntry[]>();
const globalSearchCache = new Map<string, FileEntry[]>();
// 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
* <FilePicker
* basePath="/Users/example/project"
* onSelect={(entry) => console.log('Selected:', entry)}
* onClose={() => setShowPicker(false)}
* />
*/
export const FilePicker: React.FC<FilePickerProps> = ({
basePath,
onSelect,
onClose,
initialQuery = "",
className,
}) => {
const searchQuery = initialQuery;
const [currentPath, setCurrentPath] = useState(basePath);
const [entries, setEntries] = useState<FileEntry[]>(() =>
searchQuery.trim() ? [] : globalDirectoryCache.get(basePath) || []
);
const [searchResults, setSearchResults] = useState<FileEntry[]>(() => {
if (searchQuery.trim()) {
const cacheKey = `${basePath}:${searchQuery}`;
return globalSearchCache.get(cacheKey) || [];
}
return [];
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pathHistory, setPathHistory] = useState<string[]>([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<NodeJS.Timeout | null>(null);
const fileListRef = useRef<HTMLDivElement>(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 (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className={cn(
"absolute bottom-full mb-2 left-0 z-50",
"w-[500px] h-[400px]",
"bg-background border border-border rounded-lg shadow-lg",
"flex flex-col overflow-hidden",
className
)}
>
{/* Header */}
<div className="border-b border-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={navigateBack}
disabled={!canGoBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-mono text-muted-foreground truncate max-w-[300px]">
{relativePath}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* File List */}
<div className="flex-1 overflow-y-auto relative">
{/* Show loading only if no cached data */}
{isLoading && displayEntries.length === 0 && (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
)}
{/* Show subtle indicator when displaying cached data while fetching fresh */}
{isShowingCached && isLoading && displayEntries.length > 0 && (
<div className="absolute top-1 right-2 text-xs text-muted-foreground/50 italic">
updating...
</div>
)}
{error && displayEntries.length === 0 && (
<div className="flex items-center justify-center h-full">
<span className="text-sm text-destructive">{error}</span>
</div>
)}
{!isLoading && !error && displayEntries.length === 0 && (
<div className="flex flex-col items-center justify-center h-full">
<Search className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">
{searchQuery.trim() ? 'No files found' : 'Empty directory'}
</span>
</div>
)}
{displayEntries.length > 0 && (
<div className="p-2 space-y-0.5" ref={fileListRef}>
{displayEntries.map((entry, index) => {
const Icon = getFileIcon(entry);
const isSearching = searchQuery.trim() !== '';
const isSelected = index === selectedIndex;
return (
<button
key={entry.path}
data-index={index}
onClick={() => handleEntryClick(entry)}
onDoubleClick={() => handleEntryDoubleClick(entry)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md",
"hover:bg-accent transition-colors",
"text-left text-sm",
isSelected && "bg-accent"
)}
title={entry.is_directory ? "Click to select • Double-click to enter" : "Click to select"}
>
<Icon className={cn(
"h-4 w-4 flex-shrink-0",
entry.is_directory ? "text-blue-500" : "text-muted-foreground"
)} />
<span className="flex-1 truncate">
{entry.name}
</span>
{!entry.is_directory && entry.size > 0 && (
<span className="text-xs text-muted-foreground">
{formatFileSize(entry.size)}
</span>
)}
{entry.is_directory && (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
{isSearching && (
<span className="text-xs text-muted-foreground font-mono truncate max-w-[150px]">
{entry.path.replace(basePath, '').replace(/^\//, '')}
</span>
)}
</button>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-border p-2">
<p className="text-xs text-muted-foreground text-center">
Navigate Enter Select Enter Directory Go Back Esc Close
</p>
</div>
</motion.div>
);
};