import React from 'react'; import { cn, truncatePathMiddle } from '@/lib/utils'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory'; import type { ProjectFileSearchHit } from '@/lib/opencode/client'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Icon } from "@/components/icon/Icon"; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; type FileInfo = ProjectFileSearchHit; type AgentInfo = { name: string; description?: string; mode?: string | null; }; export interface FileMentionHandle { handleKeyDown: (key: string) => void; } interface FileMentionAutocompleteProps { searchQuery: string; onFileSelect: (file: FileInfo) => void; onAgentSelect?: (agentName: string) => void; onClose: () => void; style?: React.CSSProperties; } export const FileMentionAutocomplete = React.forwardRef(({ searchQuery, onFileSelect, onAgentSelect, onClose, style, }, ref) => { const { t } = useI18n(); const currentDirectory = useChatSearchDirectory() ?? ''; const activeProjectId = useProjectsStore((state) => state.activeProjectId); const activeProjectPath = useProjectsStore( React.useCallback( (state) => state.projects.find((project) => project.id === activeProjectId)?.path ?? null, [activeProjectId], ), ); const projectRoot = React.useMemo(() => { const candidate = activeProjectPath || currentDirectory; return candidate ? candidate.replace(/\\/g, '/').replace(/\/+$/, '') : null; }, [activeProjectPath, currentDirectory]); const projectTabs = useFilesViewTabsStore( React.useCallback( (state) => (projectRoot ? state.byRoot[projectRoot] : undefined), [projectRoot], ), ); const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const searchFiles = useFileSearchStore((state) => state.searchFiles); const debouncedQuery = useDebouncedValue(searchQuery, 180); const showHidden = useDirectoryShowHidden(); const showGitignored = useFilesViewShowGitignored(); const [files, setFiles] = React.useState([]); const [directories, setDirectories] = React.useState([]); const [agents, setAgents] = React.useState([]); const [loading, setLoading] = React.useState(false); const pendingSearchRef = React.useRef(0); const [selectedIndex, setSelectedIndex] = React.useState(0); const selectedIndexRef = React.useRef(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 isMobile = useUIStore((state) => state.isMobile); const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile); const normalizedSearchQuery = (searchQuery ?? '').trim(); const recentFiles = React.useMemo(() => { if (!projectRoot || !projectTabs) { return [] as FileInfo[]; } const ordered = [ projectTabs.selectedPath, ...projectTabs.openPaths.slice().reverse(), ].filter((value): value is string => typeof value === 'string' && value.length > 0); const seen = new Set(); const queryLower = normalizedSearchQuery.toLowerCase(); const mapped = ordered .filter((filePath) => { if (seen.has(filePath)) return false; seen.add(filePath); const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath; if (!queryLower) return true; return relative.toLowerCase().includes(queryLower); }) .slice(0, 6) .map((filePath) => { const normalizedPath = filePath.replace(/\\/g, '/'); const name = normalizedPath.split('/').filter(Boolean).pop() || normalizedPath; const relativePath = normalizedPath.startsWith(`${projectRoot}/`) ? normalizedPath.slice(projectRoot.length + 1) : normalizedPath; return { name, path: normalizedPath, relativePath, extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined, } satisfies FileInfo; }); return mapped; }, [normalizedSearchQuery, projectRoot, projectTabs]); const visibleAgents = React.useMemo( () => normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2), [agents, normalizedSearchQuery.length], ); const visibleDirectories = directories; const visibleRecentFiles = recentFiles; const visibleFiles = files; 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(); if (!normalizedQueryLower) { setFiles([]); return; } let cancelled = false; pendingSearchRef.current++; setLoading(true); searchFiles(currentDirectory, normalizedQueryLower, 80, { includeHidden: showHidden, respectGitignore: !showGitignored, type: 'file', }) .then((hits) => { if (cancelled) { return; } const recentSet = new Set(recentFiles.map((file) => file.path)); setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15)); }) .catch(() => { if (!cancelled) { setFiles([]); } }) .finally(() => { if (!cancelled) { pendingSearchRef.current--; if (pendingSearchRef.current <= 0) { pendingSearchRef.current = 0; setLoading(false); } } }); return () => { cancelled = true; pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1); if (pendingSearchRef.current <= 0) { setLoading(false); } }; }, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]); React.useEffect(() => { if (!currentDirectory) { setDirectories([]); return; } const normalizedQuery = (debouncedQuery ?? '').trim(); const normalizedQueryLower = normalizedQuery .replace(/^\.\//, '') .replace(/^\/+/, '') .toLowerCase(); if (!normalizedQueryLower) { setDirectories([]); return; } let cancelled = false; pendingSearchRef.current++; setLoading(true); searchFiles(currentDirectory, normalizedQueryLower, 20, { includeHidden: showHidden, respectGitignore: !showGitignored, type: 'directory', }) .then((hits) => { if (!cancelled) { setDirectories(hits.slice(0, 10)); } }) .catch(() => { if (!cancelled) { setDirectories([]); } }) .finally(() => { if (!cancelled) { pendingSearchRef.current--; if (pendingSearchRef.current <= 0) { pendingSearchRef.current = 0; setLoading(false); } } }); return () => { cancelled = true; pendingSearchRef.current = Math.max(0, pendingSearchRef.current - 1); if (pendingSearchRef.current <= 0) { setLoading(false); } }; }, [currentDirectory, debouncedQuery, searchFiles, showHidden, showGitignored]); React.useEffect(() => { const visibleAgents = getVisibleAgents(); const normalizedQuery = (searchQuery ?? '').trim().toLowerCase(); const filtered = visibleAgents .filter((agent) => agent.mode && agent.mode !== 'primary') .filter((agent) => { if (!normalizedQuery) return true; const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase(); return haystack.includes(normalizedQuery); }) .map((agent) => ({ name: agent.name, description: agent.description, mode: agent.mode, })) .sort((a, b) => a.name.localeCompare(b.name)); setAgents(filtered); }, [getVisibleAgents, searchQuery]); React.useEffect(() => { setSelectedIndex(0); setOverflowMap({}); setMarqueeDurations({}); }, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]); React.useEffect(() => { selectedIndexRef.current = selectedIndex; }, [selectedIndex]); React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ 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); }; }, [visibleFiles, visibleDirectories]); 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((file: FileInfo) => { onFileSelect(file); }, [onFileSelect]); const handleAgentPick = React.useCallback((agentName: string) => { onAgentSelect?.(agentName); }, [onAgentSelect]); React.useImperativeHandle(ref, () => ({ handleKeyDown: (key: string) => { if (key === 'Escape') { onClose(); return; } const total = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + visibleFiles.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 = ((selectedIndexRef.current % total) + total) % total; if (safeIndex < visibleAgents.length) { const agent = visibleAgents[safeIndex]; if (agent) { handleAgentPick(agent.name); } return; } const dirIndex = safeIndex - visibleAgents.length; if (dirIndex < visibleDirectories.length) { const dir = visibleDirectories[dirIndex]; if (dir) { handleFileSelect(dir); } return; } const fileIndex = dirIndex - visibleDirectories.length; const selectedFile = fileIndex < visibleRecentFiles.length ? visibleRecentFiles[fileIndex] : visibleFiles[fileIndex - visibleRecentFiles.length]; if (selectedFile) { handleFileSelect(selectedFile); } } } }), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]); 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 ? (
) : (
{visibleAgents.map((agent, index) => { const isSelected = selectedIndex === index; return (
{ itemRefs.current[index] = el; }} className={cn( 'flex items-start gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg', isSelected && 'bg-interactive-selection', )} onClick={() => handleAgentPick(agent.name)} onMouseMove={() => setSelectedIndex(index)} >
@{agent.name}
{agent.description && !isMobile ? (
{agent.description}
) : null}
); })} {visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
)} {visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
)} {visibleDirectories.map((dir, index) => { const rowIndex = visibleAgents.length + index; const relativePath = dir.relativePath || dir.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const isSelected = selectedIndex === rowIndex; return (
{ itemRefs.current[rowIndex] = 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(dir)} onMouseMove={() => setSelectedIndex(rowIndex)} > {displayPath}
); })} {visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
)} {visibleRecentFiles.map((file, index) => { const rowIndex = visibleAgents.length + visibleDirectories.length + index; const relativePath = file.relativePath || file.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const isSelected = selectedIndex === rowIndex; const isOverflowing = overflowMap[rowIndex] ?? false; const marqueeDuration = marqueeDurations[rowIndex] ?? 2.6; return (
{ itemRefs.current[rowIndex] = 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)} onMouseMove={() => setSelectedIndex(rowIndex)} > {getFileIcon(file)} { labelRefs.current[rowIndex] = 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[rowIndex] = el; }} className="absolute invisible whitespace-nowrap pointer-events-none" aria-hidden > {relativePath} {isOverflowing && isSelected ? ( {relativePath} ) : ( {displayPath} )}
); })} {visibleRecentFiles.length > 0 && visibleFiles.length > 0 && (
)} {visibleFiles.map((file, index) => { const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index; const relativePath = file.relativePath || file.name; const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 }); const isSelected = selectedIndex === rowIndex; const isOverflowing = overflowMap[rowIndex] ?? false; const marqueeDuration = marqueeDurations[rowIndex] ?? 2.6; const item = (
{ itemRefs.current[rowIndex] = 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)} onMouseMove={() => setSelectedIndex(rowIndex)} > {getFileIcon(file)} { labelRefs.current[rowIndex] = 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[rowIndex] = el; }} className="absolute invisible whitespace-nowrap pointer-events-none" aria-hidden > {relativePath} {isOverflowing && isSelected ? ( {relativePath} ) : ( {displayPath} )}
); return ( {item} ); })} {visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
{t('chat.fileMentionAutocomplete.empty')}
)}
)} {!isMobile && (
{t('chat.autocomplete.keyboardHint')}
)}
); });