import React from 'react'; import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react'; 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'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; type FileInfo = ProjectFileSearchHit; export interface FileMentionHandle { handleKeyDown: (key: string) => void; } type AutocompleteTab = 'commands' | 'agents' | 'files'; interface FileMentionAutocompleteProps { searchQuery: string; onFileSelect: (file: FileInfo) => void; onClose: () => void; showTabs?: boolean; activeTab?: AutocompleteTab; onTabSelect?: (tab: AutocompleteTab) => void; } export const FileMentionAutocomplete = React.forwardRef(({ searchQuery, onFileSelect, onClose, showTabs, activeTab = 'files', onTabSelect, }, ref) => { const { currentDirectory } = useDirectoryStore(); const { addServerFile } = useSessionStore(); const searchFiles = useFileSearchStore((state) => state.searchFiles); const debouncedQuery = useDebouncedValue(searchQuery, 180); const showHidden = useDirectoryShowHidden(); const showGitignored = useFilesViewShowGitignored(); const [files, setFiles] = React.useState([]); const [loading, setLoading] = React.useState(false); const [selectedIndex, setSelectedIndex] = React.useState(0); const [marqueeWidth, setMarqueeWidth] = React.useState(360); const [overflowMap, setOverflowMap] = React.useState>({}); const [marqueeDurations, setMarqueeDurations] = React.useState>({}); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const labelRefs = React.useRef<(HTMLSpanElement | null)[]>([]); const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]); const containerRef = React.useRef(null); const ignoreTabClickRef = React.useRef(false); const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => { const q = query.trim().toLowerCase(); if (!q) { return 0; } const c = candidate.toLowerCase(); let score = 0; let lastIndex = -1; let consecutive = 0; for (let i = 0; i < q.length; i += 1) { const ch = q[i]; if (!ch || ch === ' ') { continue; } const idx = c.indexOf(ch, lastIndex + 1); if (idx === -1) { return null; } const gap = idx - lastIndex - 1; if (gap === 0) { consecutive += 1; } else { consecutive = 0; } score += 10; score += Math.max(0, 18 - idx); score -= Math.max(0, gap); if (idx === 0) { score += 12; } else { const prev = c[idx - 1]; if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { score += 10; } } score += consecutive > 0 ? 12 : 0; lastIndex = idx; } score += Math.max(0, 24 - Math.round(c.length / 3)); return score; }, []); 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; } const normalizedQuery = (debouncedQuery ?? '').trim(); const normalizedQueryLower = normalizedQuery .replace(/^\.\//, '') .replace(/^\/+/, '') .toLowerCase(); let cancelled = false; setLoading(true); searchFiles(currentDirectory, normalizedQueryLower, 80, { includeHidden: showHidden, respectGitignore: !showGitignored, }) .then((hits) => { if (cancelled) { return; } const ranked = normalizedQueryLower ? hits .map((file) => { const label = file.relativePath || file.name || file.path; const score = fuzzyScore(normalizedQueryLower, label); return score === null ? null : { file, score, labelLength: label.length }; }) .filter(Boolean) as Array<{ file: FileInfo; score: number; labelLength: number }> : hits.map((file) => ({ file, score: 0, labelLength: (file.relativePath || file.name || file.path).length })); ranked.sort((a, b) => ( b.score - a.score || a.labelLength - b.labelLength || a.file.path.localeCompare(b.file.path) )); setFiles(ranked.slice(0, 15).map((entry) => entry.file)); }) .catch(() => { if (!cancelled) { setFiles([]); } }) .finally(() => { if (!cancelled) { setLoading(false); } }); return () => { cancelled = true; }; }, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles, showHidden, showGitignored]); React.useEffect(() => { setSelectedIndex(0); setOverflowMap({}); setMarqueeDurations({}); }, [files]); React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [selectedIndex]); React.useEffect(() => { let frameId: number | null = null; const updateOverflow = () => { if (frameId !== null) { cancelAnimationFrame(frameId); } frameId = requestAnimationFrame(() => { const next: Record = {}; const durations: Record = {}; labelRefs.current.forEach((node, index) => { if (!node) { return; } const measureNode = measureRefs.current[index]; const fullWidth = measureNode?.offsetWidth ?? node.scrollWidth; const overflowPx = Math.max(0, fullWidth - node.clientWidth); const isOverflowing = overflowPx > 8; next[index] = isOverflowing; if (isOverflowing) { const duration = Math.max(0.6, overflowPx / 110); durations[index] = duration; } }); setOverflowMap(next); setMarqueeDurations(durations); }); }; updateOverflow(); window.addEventListener('resize', updateOverflow); return () => { if (frameId !== null) { cancelAnimationFrame(frameId); } window.removeEventListener('resize', updateOverflow); }; }, [files]); React.useEffect(() => { const labelNode = labelRefs.current[selectedIndex]; if (!labelNode) { return; } const updateWidth = () => { const width = labelNode.clientWidth; if (width > 0) { setMarqueeWidth(width); } }; updateWidth(); if (typeof ResizeObserver === 'undefined') { return; } const observer = new ResizeObserver(updateWidth); observer.observe(labelNode); return () => { observer.disconnect(); }; }, [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 (
{showTabs ? (
{([ { id: 'commands' as const, label: 'Commands' }, { id: 'agents' as const, label: 'Agents' }, { id: 'files' as const, label: 'Files' }, ]).map((tab) => ( ))}
) : null} {loading ? (
) : (
{files.map((file, index) => { const relativePath = file.relativePath || file.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 45 }); const isSelected = selectedIndex === index; const isOverflowing = overflowMap[index] ?? false; const marqueeDuration = marqueeDurations[index] ?? 2.6; 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-interactive-selection" )} onClick={() => handleFileSelect(file)} onMouseEnter={() => setSelectedIndex(index)} > {getFileIcon(file)} { labelRefs.current[index] = el; }} className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container" style={isSelected ? { ['--file-mention-marquee-width' as string]: `${marqueeWidth}px`, ['--file-mention-marquee-duration' as string]: `${marqueeDuration}s` } : undefined} aria-label={relativePath} > { measureRefs.current[index] = el; }} className="absolute invisible whitespace-nowrap pointer-events-none" aria-hidden > {relativePath} {isOverflowing && isSelected ? ( {relativePath} ) : ( {displayPath} )}
); return ( {item} ); })} {} {files.length > 0 &&
} {files.length === 0 && (
No files found
)}
)}
↑↓ navigate • Enter select • Esc close
); });