import React from 'react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { RiCloseLine, RiCodeLine, RiFileImageLine, RiFileTextLine, RiFolder6Line, RiSearchLine } from '@remixicon/react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn, truncatePathMiddle } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { opencodeClient } from '@/lib/opencode/client'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; interface FileInfo { name: string; path: string; type: 'file' | 'directory'; size?: number; extension?: string; relativePath?: string; } interface ServerFilePickerProps { onFilesSelected: (files: FileInfo[]) => void; multiSelect?: boolean; children: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; presentation?: 'dropdown' | 'modal'; } export const ServerFilePicker: React.FC = ({ onFilesSelected, multiSelect = false, children, open: controlledOpen, onOpenChange, presentation = 'dropdown', }) => { const { isMobile } = useDeviceInfo(); // Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns const isCompact = isMobile; const { currentDirectory } = useDirectoryStore(); const searchFiles = useFileSearchStore((state) => state.searchFiles); const showHidden = useDirectoryShowHidden(); const showGitignored = useFilesViewShowGitignored(); const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false); const [cacheNonce, setCacheNonce] = React.useState(0); const [mobileOpen, setMobileOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); const debouncedSearchQuery = useDebouncedValue(searchQuery, 200); const [selectedFiles, setSelectedFiles] = React.useState>(new Set()); const [expandedDirs, setExpandedDirs] = React.useState>(new Set()); const [childrenByDir, setChildrenByDir] = React.useState>({}); const loadedDirsRef = React.useRef>(new Set()); const inFlightDirsRef = React.useRef>(new Set()); const [searchResults, setSearchResults] = React.useState([]); const [searching, setSearching] = React.useState(false); const [loading, setLoading] = React.useState(false); const [attaching, setAttaching] = React.useState(false); const [error, setError] = React.useState(null); const open = controlledOpen ?? uncontrolledOpen; const setOpen = onOpenChange ?? setUncontrolledOpen; const sortDirectoryItems = React.useCallback((items: FileInfo[]) => ( items.slice().sort((a, b) => { if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }) ), []); const mapFilesystemEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileInfo[] => ( sortDirectoryItems(entries .filter((item) => showHidden || !item.name.startsWith('.')) .map((item) => { const name = item.name; const extension = !item.isDirectory && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; return { name, path: item.path || `${dirPath}/${name}`, type: item.isDirectory ? 'directory' : 'file', size: 0, extension, }; })) ), [sortDirectoryItems, showHidden]); const loadDirectory = React.useCallback(async (dirPath: string) => { setLoading(true); setError(null); try { const entries = await opencodeClient.listLocalDirectory(dirPath, { respectGitignore: !showGitignored }); const items = mapFilesystemEntries(dirPath, entries.map((entry) => ({ name: entry.name, path: entry.path, isDirectory: entry.isDirectory, }))); loadedDirsRef.current = new Set([dirPath]); inFlightDirsRef.current = new Set(); setChildrenByDir({ [dirPath]: items }); setExpandedDirs(new Set()); } catch { setError('Failed to load directory contents'); loadedDirsRef.current = new Set([dirPath]); inFlightDirsRef.current = new Set(); setChildrenByDir({ [dirPath]: [] }); setExpandedDirs(new Set()); } finally { setLoading(false); } }, [mapFilesystemEntries, showGitignored]); const loadDirectoryChildren = React.useCallback(async (dirPath: string) => { const normalizedDir = dirPath.trim(); if (!normalizedDir) { return; } const cacheKey = `${normalizedDir}::${cacheNonce}`; if (loadedDirsRef.current.has(cacheKey)) { return; } if (inFlightDirsRef.current.has(cacheKey)) { return; } inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.add(cacheKey); try { const entries = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore: !showGitignored }); const items = mapFilesystemEntries(normalizedDir, entries.map((entry) => ({ name: entry.name, path: entry.path, isDirectory: entry.isDirectory, }))); loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current.add(cacheKey); setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: items, })); } catch { // Keep it unloadded so the user can retry expanding the directory. setChildrenByDir((prev) => { if (prev[normalizedDir]) { return prev; } return { ...prev, [normalizedDir]: [], }; }); } finally { inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(cacheKey); } }, [mapFilesystemEntries, showGitignored, cacheNonce]); React.useEffect(() => { if ((open || mobileOpen) && currentDirectory) { void loadDirectory(currentDirectory); } }, [open, mobileOpen, currentDirectory, loadDirectory]); React.useEffect(() => { setCacheNonce((prev) => prev + 1); }, [showHidden, showGitignored]); React.useEffect(() => { if (!(open || mobileOpen) || !currentDirectory) { setSearchResults([]); setSearching(false); return; } const trimmedQuery = debouncedSearchQuery .trim() .replace(/^\.\//, '') .replace(/^\/+/, ''); if (!trimmedQuery) { setSearchResults([]); setSearching(false); return; } const normalizedQuery = trimmedQuery.toLowerCase(); let cancelled = false; setSearching(true); searchFiles(currentDirectory, normalizedQuery, 150, { includeHidden: showHidden, respectGitignore: !showGitignored, }) .then((hits) => { if (cancelled) { return; } const mappedHits: FileInfo[] = hits.map((hit) => ({ name: hit.name, path: hit.path, type: 'file', extension: hit.extension, relativePath: hit.relativePath, size: 0, })); setSearchResults(mappedHits); }) .catch(() => { if (!cancelled) { setSearchResults([]); } }) .finally(() => { if (!cancelled) { setSearching(false); } }); return () => { cancelled = true; }; }, [open, mobileOpen, currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]); React.useEffect(() => { if (!open && !mobileOpen) { setSelectedFiles(new Set()); setSearchQuery(''); setSearchResults([]); setSearching(false); loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); setChildrenByDir({}); setExpandedDirs(new Set()); } }, [open, mobileOpen]); const getFileIcon = (file: FileInfo) => { if (file.type === 'directory') { return expandedDirs.has(file.path) ? ( ) : ( ); } const ext = file.extension?.toLowerCase(); switch (ext) { case 'ts': case 'tsx': case 'js': case 'jsx': case 'html': case 'css': case 'scss': case 'less': return ; case 'json': return ; case 'md': case 'mdx': return ; case 'png': case 'jpg': case 'jpeg': case 'gif': case 'svg': return ; default: return ; } }; const toggleDirectory = async (dirPath: string) => { const isExpanded = expandedDirs.has(dirPath); if (isExpanded) { setExpandedDirs(prev => { const next = new Set(prev); next.delete(dirPath); return next; }); } else { setExpandedDirs((prev) => { const next = new Set(prev); next.add(dirPath); return next; }); await loadDirectoryChildren(dirPath); } }; const toggleFileSelection = (filePath: string) => { if (multiSelect) { setSelectedFiles(prev => { const next = new Set(prev); if (next.has(filePath)) { next.delete(filePath); } else { next.add(filePath); } return next; }); } else { setSelectedFiles(new Set([filePath])); } }; const handleConfirm = async () => { const treeFileMap = new Map(); Object.values(childrenByDir).forEach((items) => { items.forEach((file) => { if (file.type === 'file') { treeFileMap.set(file.path, file); } }); }); const searchFileMap = new Map(searchResults.map((file) => [file.path, file])); const selected = Array.from(selectedFiles) .map((filePath) => treeFileMap.get(filePath) ?? searchFileMap.get(filePath)) .filter((file): file is FileInfo => Boolean(file)); setAttaching(true); try { await onFilesSelected(selected); setSelectedFiles(new Set()); setOpen(false); setMobileOpen(false); } finally { setAttaching(false); } }; const rootItems = React.useMemo(() => { if (!currentDirectory) { return []; } return childrenByDir[currentDirectory] ?? []; }, [childrenByDir, currentDirectory]); const isSearchActive = searchQuery.trim().length > 0; const getChildItems = (parentPath: string) => { return childrenByDir[parentPath] ?? []; }; const getRelativePath = (fullPath: string) => { if (currentDirectory && fullPath.startsWith(currentDirectory)) { const relativePath = fullPath.substring(currentDirectory.length); return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath; } return fullPath.split('/').pop() || fullPath; }; const renderFileItem = (file: FileInfo, level: number) => { const rawLabel = isSearchActive ? file.relativePath || getRelativePath(file.path) : file.name; const shouldCompact = isSearchActive && rawLabel.includes('/') && rawLabel.length > 45; const displayLabel = shouldCompact ? truncatePathMiddle(rawLabel, { maxLength: isCompact ? 42 : 48 }) : rawLabel; const row = (
{ e.preventDefault(); e.stopPropagation(); if (file.type === 'file') { toggleFileSelection(file.path); } }} >
{getFileIcon(file)} {displayLabel}
{file.type === 'file' && selectedFiles.has(file.path) && (
)}
); if (!shouldCompact) { return React.cloneElement(row, { key: file.path }); } return ( {row} {rawLabel} ); }; const renderFileTree = (file: FileInfo, level: number): React.ReactNode => { const isDirectory = file.type === 'directory'; const children = isDirectory ? getChildItems(file.path) : []; const isExpanded = expandedDirs.has(file.path); const isLoadingChildren = isDirectory && isExpanded && inFlightDirsRef.current.has(file.path) && children.length === 0; return (
{isDirectory && isExpanded && children.length > 0 && (
{children.map((child) => renderFileTree(child, level + 1))}
)} {isDirectory && isExpanded && isLoadingChildren && (
Loading…
)}
); }; const summaryLabel = selectedFiles.size > 0 ? `${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} selected` : 'No files selected'; const summarySection = (
{summaryLabel}
); const scrollAreaClass = isCompact ? 'flex-1 min-h-[240px]' : 'h-[300px]'; const pickerBody = ( <>
Select Project Files
setSearchQuery(e.target.value)} placeholder="Search files..." className="pl-7 h-6 typography-ui-label" onClick={(e) => e.stopPropagation()} /> {searchQuery && ( )}
{loading && (
Loading files...
)} {error && (
{error}
)} {!loading && !error && (
{isSearchActive ? ( searching ? (
Searching files…
) : ( searchResults.map((file) => renderFileItem(file, 0)) ) ) : ( rootItems.map((file) => renderFileTree(file, 0)) )} {!isSearchActive && rootItems.length === 0 && (
No files in this directory
)} {isSearchActive && !searching && searchResults.length === 0 && (
No files found
)}
)}
); const mobileTrigger = ( { event.preventDefault(); event.stopPropagation(); setMobileOpen(true); }} > {children} ); if (presentation === 'modal') { return ( <> {children ? ( { event.preventDefault(); event.stopPropagation(); setOpen(true); }} > {children} ) : null} setOpen(false)} title="Select Project Files" footer={summarySection} >
{pickerBody}
); } if (isCompact) { return ( <> {mobileTrigger} setMobileOpen(false)} title="Select Project Files" footer={summarySection} >
{pickerBody}
); } return ( {children} {pickerBody} {summarySection} ); };