diff --git a/src/components/FilePicker.optimized.tsx b/src/components/FilePicker.optimized.tsx new file mode 100644 index 0000000..4169e79 --- /dev/null +++ b/src/components/FilePicker.optimized.tsx @@ -0,0 +1,416 @@ +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 +
+ )} +
+
+ ); +}); \ No newline at end of file diff --git a/src/components/SessionList.optimized.tsx b/src/components/SessionList.optimized.tsx new file mode 100644 index 0000000..89a1daf --- /dev/null +++ b/src/components/SessionList.optimized.tsx @@ -0,0 +1,209 @@ +import React, { useMemo, useCallback } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { FileText, ArrowLeft, Calendar, Clock } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Pagination } from "@/components/ui/pagination"; +import { ClaudeMemoriesDropdown } from "@/components/ClaudeMemoriesDropdown"; +import { cn } from "@/lib/utils"; +import { formatUnixTimestamp, formatISOTimestamp } from "@/lib/date-utils"; +import { usePagination } from "@/hooks/usePagination"; +import type { Session, ClaudeMdFile } from "@/lib/api"; + +interface SessionListProps { + sessions: Session[]; + projectPath: string; + onBack: () => void; + onSessionClick?: (session: Session) => void; + onEditClaudeFile?: (file: ClaudeMdFile) => void; + className?: string; +} + +// Memoized session card component to prevent unnecessary re-renders +const SessionCard = React.memo<{ + session: Session; + projectPath: string; + onClick?: () => void; + onEditClaudeFile?: (file: ClaudeMdFile) => void; +}>(({ session, projectPath, onClick, onEditClaudeFile }) => { + const formatTime = useCallback((timestamp: string | number | undefined) => { + if (!timestamp) return "Unknown time"; + + if (typeof timestamp === "string") { + return formatISOTimestamp(timestamp); + } else { + return formatUnixTimestamp(timestamp); + } + }, []); + + return ( + + + +
+
+ {/* Session title */} +
+ +
+

+ {`Session ${session.id.slice(0, 8)}`} +

+
+
+ + {/* Session metadata */} +
+
+ + {formatTime(session.created_at)} +
+ {session.message_timestamp && ( +
+ + {formatTime(session.message_timestamp)} +
+ )} +
+ + {/* Session ID */} +
+ ID: {session.id} +
+
+ + {/* Claude memories dropdown */} +
+ {})} + /> +
+
+
+
+
+ ); +}); + +SessionCard.displayName = 'SessionCard'; + +export const SessionList: React.FC = React.memo(({ + sessions, + projectPath, + onBack, + onSessionClick, + onEditClaudeFile, + className +}) => { + // Sort sessions by created_at in descending order + const sortedSessions = useMemo(() => { + return [...sessions].sort((a, b) => { + const timeA = a.created_at || 0; + const timeB = b.created_at || 0; + return timeB > timeA ? 1 : -1; + }); + }, [sessions]); + + // Use custom pagination hook + const { + currentPage, + totalPages, + paginatedData, + goToPage, + canGoNext: _canGoNext, + canGoPrevious: _canGoPrevious + } = usePagination(sortedSessions, { + initialPage: 1, + initialPageSize: 5 + }); + + const handleSessionClick = useCallback((session: Session) => { + onSessionClick?.(session); + }, [onSessionClick]); + + return ( +
+ {/* Header */} +
+
+ +
+

Sessions

+

+ {projectPath} +

+
+
+
+ {sessions.length} {sessions.length === 1 ? 'session' : 'sessions'} +
+
+ + {/* Sessions list */} + {sessions.length === 0 ? ( + + + +

+ No sessions found for this project +

+
+
+ ) : ( + <> + + + {paginatedData.map((session) => ( + handleSessionClick(session)} + onEditClaudeFile={onEditClaudeFile} + /> + ))} + + + + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ )} + + )} +
+ ); +}); \ No newline at end of file