/** * Search in files panel (Cmd+Shift+F). * * Debounced literal string search with cancellation. * Results are clickable to open the file at the matched line. */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getBasename, getRelativePathWithinPrefix, lastSeparatorIndex, } from '@shared/utils/platformPath'; import { Loader2, Search, X } from 'lucide-react'; import { FileIcon } from './FileIcon'; import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor'; // ============================================================================= // Types // ============================================================================= interface SearchInFilesPanelProps { projectPath: string; onClose: () => void; onSelectMatch: (filePath: string, line: number) => void; } // ============================================================================= // Constants // ============================================================================= const DEBOUNCE_MS = 300; // ============================================================================= // Component // ============================================================================= export const SearchInFilesPanel = ({ projectPath, onClose, onSelectMatch, }: SearchInFilesPanelProps): React.ReactElement => { const [query, setQuery] = useState(''); const [caseSensitive, setCaseSensitive] = useState(false); const [results, setResults] = useState(null); const [searching, setSearching] = useState(false); const [error, setError] = useState(null); const [expandedFiles, setExpandedFiles] = useState>(new Set()); const inputRef = useRef(null); const debounceRef = useRef>(undefined); // Monotonic request ID — prevents stale results from overwriting fresh ones const requestIdRef = useRef(0); // Focus input on mount useEffect(() => { inputRef.current?.focus(); }, []); // Escape closes panel (capture phase to prevent overlay close) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); } }; window.addEventListener('keydown', handleKeyDown, true); return () => window.removeEventListener('keydown', handleKeyDown, true); }, [onClose]); const doSearch = useCallback(async (searchQuery: string, isCaseSensitive: boolean) => { if (!searchQuery.trim()) { setResults(null); setSearching(false); setError(null); return; } // Bump request ID — any in-flight request with a lower ID is stale const myRequestId = ++requestIdRef.current; setSearching(true); setError(null); try { const result = await api.editor.searchInFiles({ query: searchQuery, caseSensitive: isCaseSensitive, }); // Discard result if a newer request was fired while we were waiting if (requestIdRef.current !== myRequestId) return; setResults(result); // Auto-expand first few files const firstFiles = new Set(result.results.slice(0, 5).map((r) => r.filePath)); setExpandedFiles(firstFiles); } catch (err) { if (requestIdRef.current !== myRequestId) return; const message = err instanceof Error ? err.message : String(err); setError(message); } finally { if (requestIdRef.current === myRequestId) { setSearching(false); } } }, []); const handleQueryChange = useCallback( (value: string) => { setQuery(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { void doSearch(value, caseSensitive); }, DEBOUNCE_MS); }, [caseSensitive, doSearch] ); const handleCaseSensitiveToggle = useCallback(() => { const newValue = !caseSensitive; setCaseSensitive(newValue); if (query.trim()) { if (debounceRef.current) clearTimeout(debounceRef.current); void doSearch(query, newValue); } }, [caseSensitive, query, doSearch]); const toggleFileExpanded = useCallback((filePath: string) => { setExpandedFiles((prev) => { const next = new Set(prev); if (next.has(filePath)) { next.delete(filePath); } else { next.add(filePath); } return next; }); }, []); const getRelativePath = useCallback( (filePath: string) => { return getRelativePathWithinPrefix(projectPath, filePath) ?? filePath; }, [projectPath] ); return (
{/* Header */}
Search in Files Close search (Esc)
{/* Search input */}
handleQueryChange(e.target.value)} placeholder="Search..." className="flex-1 bg-transparent text-xs text-text outline-none placeholder:text-text-muted" /> {searching && }
Match Case
{/* Results */}
{error &&
{error}
} {results?.totalMatches === 0 && query.trim() && (
No results found
)} {results && results.totalMatches > 0 && ( <>
{results.totalMatches} match{results.totalMatches !== 1 ? 'es' : ''} in{' '} {results.results.length} file{results.results.length !== 1 ? 's' : ''} {results.truncated && ' (truncated)'}
{results.results.map((fileResult) => ( toggleFileExpanded(fileResult.filePath)} onSelectMatch={(line) => onSelectMatch(fileResult.filePath, line)} query={query} caseSensitive={caseSensitive} /> ))} )}
); }; // ============================================================================= // File group // ============================================================================= interface SearchFileGroupProps { fileResult: SearchFileResult; relativePath: string; expanded: boolean; onToggle: () => void; onSelectMatch: (line: number) => void; query: string; caseSensitive: boolean; } const SearchFileGroup = ({ fileResult, relativePath, expanded, onToggle, onSelectMatch, query, caseSensitive, }: SearchFileGroupProps): React.ReactElement => { const fileName = getBasename(relativePath) || relativePath; const sepIdx = lastSeparatorIndex(relativePath); const dirPath = sepIdx >= 0 ? relativePath.slice(0, sepIdx) : ''; return (
{expanded && (
{fileResult.matches.map((match, idx) => ( ))}
)}
); }; // ============================================================================= // Highlighted line // ============================================================================= interface HighlightedLineProps { text: string; query: string; caseSensitive: boolean; } const HighlightedLine = React.memo(function HighlightedLine({ text, query, caseSensitive, }: HighlightedLineProps): React.ReactElement { if (!query) { return {text}; } const searchText = caseSensitive ? text : text.toLowerCase(); const searchQuery = caseSensitive ? query : query.toLowerCase(); const parts: React.ReactNode[] = []; let lastIndex = 0; let idx = searchText.indexOf(searchQuery); while (idx !== -1) { if (idx > lastIndex) { parts.push( {text.slice(lastIndex, idx)} ); } parts.push( {text.slice(idx, idx + query.length)} ); lastIndex = idx + query.length; idx = searchText.indexOf(searchQuery, lastIndex); } if (lastIndex < text.length) { parts.push( {text.slice(lastIndex)} ); } return {parts}; });