import React from 'react'; import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn, truncatePathMiddle } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import type { ProjectFileSearchHit } from '@/lib/opencode/client'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; type FileInfo = ProjectFileSearchHit; export interface FileMentionHandle { handleKeyDown: (key: string) => void; } interface FileMentionAutocompleteProps { searchQuery: string; onFileSelect: (file: FileInfo) => void; onClose: () => void; } export const FileMentionAutocomplete = React.forwardRef(({ searchQuery, onFileSelect, onClose }, ref) => { const { currentDirectory } = useDirectoryStore(); const { addServerFile } = useSessionStore(); const searchFiles = useFileSearchStore((state) => state.searchFiles); const debouncedQuery = useDebouncedValue(searchQuery, 180); const [files, setFiles] = React.useState([]); const [loading, setLoading] = React.useState(false); const [selectedIndex, setSelectedIndex] = React.useState(0); const [hoveredTooltipIndex, setHoveredTooltipIndex] = React.useState(null); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const containerRef = React.useRef(null); React.useEffect(() => { const handlePointerDown = (event: MouseEvent | TouchEvent) => { const target = event.target as Node | null; if (!target || !containerRef.current) { return; } if (containerRef.current.contains(target)) { return; } onClose(); }; document.addEventListener('pointerdown', handlePointerDown, true); return () => { document.removeEventListener('pointerdown', handlePointerDown, true); }; }, [onClose]); React.useEffect(() => { if (!currentDirectory) { setFiles([]); return; } let cancelled = false; setLoading(true); searchFiles(currentDirectory, debouncedQuery ?? '', 40) .then((hits) => { if (cancelled) { return; } setFiles(hits.slice(0, 15)); }) .catch(() => { if (!cancelled) { setFiles([]); } }) .finally(() => { if (!cancelled) { setLoading(false); } }); return () => { cancelled = true; }; }, [currentDirectory, debouncedQuery, searchFiles]); React.useEffect(() => { setSelectedIndex(0); setHoveredTooltipIndex(null); }, [files]); React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [selectedIndex]); const handleFileSelect = React.useCallback(async (file: FileInfo) => { await addServerFile(file.path, file.name); onFileSelect(file); }, [addServerFile, onFileSelect]); React.useImperativeHandle(ref, () => ({ handleKeyDown: (key: string) => { if (key === 'Escape') { onClose(); return; } const total = files.length; if (total === 0) { return; } if (key === 'ArrowDown') { setSelectedIndex((prev) => (prev + 1) % total); return; } if (key === 'ArrowUp') { setSelectedIndex((prev) => (prev - 1 + total) % total); return; } if (key === 'Enter' || key === 'Tab') { const safeIndex = ((selectedIndex % total) + total) % total; const selectedFile = files[safeIndex]; if (selectedFile) { handleFileSelect(selectedFile); } } } }), [files, selectedIndex, onClose, handleFileSelect]); const getFileIcon = (file: FileInfo) => { const ext = file.extension?.toLowerCase(); switch (ext) { case 'ts': case 'tsx': case 'js': case 'jsx': return ; case 'json': return ; case 'md': case 'mdx': return ; case 'png': case 'jpg': case 'jpeg': case 'gif': case 'svg': return ; default: return ; } }; return (
{loading ? (
) : (
{files.map((file, index) => { const relativePath = file.relativePath || file.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 45 }); const isSelected = selectedIndex === index; const isHovered = hoveredTooltipIndex === index; const tooltipOpen = isSelected || isHovered; const item = (
{ itemRefs.current[index] = el; }} className={cn( "flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg", isSelected && "bg-accent" )} onClick={() => handleFileSelect(file)} onMouseEnter={() => setSelectedIndex(index)} > {getFileIcon(file)} {displayPath}
); return ( { setHoveredTooltipIndex((previous) => { if (!open && previous === index) { return null; } if (open) { return index; } return previous; }); }} > {item} {relativePath} ); })} {} {files.length > 0 &&
} {files.length === 0 && (
No files found
)}
)}
↑↓ navigate • Enter select • Esc close
); });