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'; 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; } export const ServerFilePicker: React.FC = ({ onFilesSelected, multiSelect = false, children }) => { const { isMobile } = useDeviceInfo(); const { currentDirectory } = useDirectoryStore(); const searchFiles = useFileSearchStore((state) => state.searchFiles); const [open, setOpen] = React.useState(false); 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 [fileTree, setFileTree] = React.useState([]); 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 loadDirectory = React.useCallback(async (dirPath: string) => { setLoading(true); setError(null); try { const tempClient = opencodeClient.getApiClient(); const response = await tempClient.file.list({ query: { path: '.', directory: dirPath } }); if (!response.data) { setFileTree([]); return; } const items = response.data .filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.')) .map((item: { name: string; type: string; size?: number; absolute?: string }) => { const extension = item.type === 'file' ? item.name.split('.').pop()?.toLowerCase() : undefined; return { name: item.name, path: item.absolute || `${dirPath}/${item.name}`, type: item.type as 'file' | 'directory', size: item.size || 0, extension }; }) .sort((a: FileInfo, b: FileInfo) => { if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }); setFileTree(items); } catch { setError('Failed to load directory contents'); setFileTree([]); } finally { setLoading(false); } }, []); React.useEffect(() => { if ((open || mobileOpen) && currentDirectory) { void loadDirectory(currentDirectory); } }, [open, mobileOpen, currentDirectory, loadDirectory]); React.useEffect(() => { if (!(open || mobileOpen) || !currentDirectory) { setSearchResults([]); setSearching(false); return; } const trimmedQuery = debouncedSearchQuery.trim(); if (!trimmedQuery) { setSearchResults([]); setSearching(false); return; } let cancelled = false; setSearching(true); searchFiles(currentDirectory, trimmedQuery, 150) .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]); React.useEffect(() => { if (!open && !mobileOpen) { setSelectedFiles(new Set()); setSearchQuery(''); setSearchResults([]); setSearching(false); } }, [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; }); try { const tempClient = opencodeClient.getApiClient(); const response = await tempClient.file.list({ query: { path: '.', directory: dirPath } }); if (response.data) { const subItems = response.data .filter((item: { name: string; type: string; size?: number; absolute?: string }) => !item.name.startsWith('.')) .map((item: { name: string; type: string; size?: number; absolute?: string }) => { const extension = item.type === 'file' ? item.name.split('.').pop()?.toLowerCase() : undefined; return { name: item.name, path: item.absolute || `${dirPath}/${item.name}`, type: item.type as 'file' | 'directory', size: 0, extension }; }); setFileTree(prev => { const filtered = prev.filter(item => !item.path.startsWith(dirPath + '/')); return [...filtered, ...subItems].sort((a, b) => { const aDepth = a.path.split('/').length; const bDepth = b.path.split('/').length; if (aDepth !== bDepth) return aDepth - bDepth; if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; return a.name.localeCompare(b.name); }); }); } } catch { /* ignored */ } } }; 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( fileTree .filter((file) => file.type === 'file') .map((file) => [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 fileTree.filter((item) => { const itemDir = item.path.substring(0, item.path.lastIndexOf('/')); return itemDir === currentDirectory; }); }, [fileTree, currentDirectory]); const isSearchActive = searchQuery.trim().length > 0; const getChildItems = (parentPath: string) => { return fileTree.filter(item => { const itemDir = item.path.substring(0, item.path.lastIndexOf('/')); return itemDir === 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: isMobile ? 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); return (
{isDirectory && isExpanded && children.length > 0 && (
{children.map((child) => renderFileTree(child, level + 1))}
)}
); }; const summaryLabel = selectedFiles.size > 0 ? `${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} selected` : 'No files selected'; const summarySection = (
{summaryLabel}
); const scrollAreaClass = isMobile ? '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 (isMobile) { return ( <> {mobileTrigger} setMobileOpen(false)} title="Select Project Files" footer={summarySection} >
{pickerBody}
); } return ( {children} {pickerBody} {summarySection} ); };