import { useState, useMemo, useCallback, useEffect } from 'react'; import type { TextSearchOptions, TextSearchOnProgressCallback, WebContainer } from '@webcontainer/api'; import { workbenchStore } from '~/lib/stores/workbench'; import { webcontainer } from '~/lib/webcontainer'; import { WORK_DIR } from '~/utils/constants'; import { debounce } from '~/utils/debounce'; interface DisplayMatch { path: string; lineNumber: number; previewText: string; matchCharStart: number; matchCharEnd: number; } async function performTextSearch( instance: WebContainer, query: string, options: Omit, onProgress: (results: DisplayMatch[]) => void, ): Promise { if (!instance || typeof instance.internal?.textSearch !== 'function') { console.error('WebContainer instance not available or internal searchText method is missing/not a function.'); return; } const searchOptions: TextSearchOptions = { ...options, folders: [WORK_DIR], }; const progressCallback: TextSearchOnProgressCallback = (filePath: any, apiMatches: any[]) => { const displayMatches: DisplayMatch[] = []; apiMatches.forEach((apiMatch: { preview: { text: string; matches: string | any[] }; ranges: any[] }) => { const previewLines = apiMatch.preview.text.split('\n'); apiMatch.ranges.forEach((range: { startLineNumber: number; startColumn: any; endColumn: any }) => { let previewLineText = '(Preview line not found)'; let lineIndexInPreview = -1; if (apiMatch.preview.matches.length > 0) { const previewStartLine = apiMatch.preview.matches[0].startLineNumber; lineIndexInPreview = range.startLineNumber - previewStartLine; } if (lineIndexInPreview >= 0 && lineIndexInPreview < previewLines.length) { previewLineText = previewLines[lineIndexInPreview]; } else { previewLineText = previewLines[0] ?? '(Preview unavailable)'; } displayMatches.push({ path: filePath, lineNumber: range.startLineNumber, previewText: previewLineText, matchCharStart: range.startColumn, matchCharEnd: range.endColumn, }); }); }); if (displayMatches.length > 0) { onProgress(displayMatches); } }; try { await instance.internal.textSearch(query, searchOptions, progressCallback); } catch (error) { console.error('Error during internal text search:', error); } } function groupResultsByFile(results: DisplayMatch[]): Record { return results.reduce( (acc, result) => { if (!acc[result.path]) { acc[result.path] = []; } acc[result.path].push(result); return acc; }, {} as Record, ); } export function Search() { const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [expandedFiles, setExpandedFiles] = useState>({}); const [hasSearched, setHasSearched] = useState(false); const groupedResults = useMemo(() => groupResultsByFile(searchResults), [searchResults]); useEffect(() => { if (searchResults.length > 0) { const allExpanded: Record = {}; Object.keys(groupedResults).forEach((file) => { allExpanded[file] = true; }); setExpandedFiles(allExpanded); } }, [groupedResults, searchResults]); const handleSearch = useCallback(async (query: string) => { if (!query.trim()) { setSearchResults([]); setIsSearching(false); setExpandedFiles({}); setHasSearched(false); return; } setIsSearching(true); setSearchResults([]); setExpandedFiles({}); setHasSearched(true); const minLoaderTime = 300; // ms const start = Date.now(); try { const instance = await webcontainer; const options: Omit = { homeDir: WORK_DIR, // Adjust this path as needed includes: ['**/*.*'], excludes: ['**/node_modules/**', '**/package-lock.json', '**/.git/**', '**/dist/**', '**/*.lock'], gitignore: true, requireGit: false, globalIgnoreFiles: true, ignoreSymlinks: false, resultLimit: 500, isRegex: false, caseSensitive: false, isWordMatch: false, }; const progressHandler = (batchResults: DisplayMatch[]) => { setSearchResults((prevResults) => [...prevResults, ...batchResults]); }; await performTextSearch(instance, query, options, progressHandler); } catch (error) { console.error('Failed to initiate search:', error); } finally { const elapsed = Date.now() - start; if (elapsed < minLoaderTime) { setTimeout(() => setIsSearching(false), minLoaderTime - elapsed); } else { setIsSearching(false); } } }, []); const debouncedSearch = useCallback(debounce(handleSearch, 300), [handleSearch]); useEffect(() => { debouncedSearch(searchQuery); }, [searchQuery, debouncedSearch]); const handleResultClick = (filePath: string, line?: number) => { workbenchStore.setSelectedFile(filePath); /* * Adjust line number to be 0-based if it's defined * The search results use 1-based line numbers, but CodeMirrorEditor expects 0-based */ const adjustedLine = typeof line === 'number' ? Math.max(0, line - 1) : undefined; workbenchStore.setCurrentDocumentScrollPosition({ line: adjustedLine, column: 0 }); }; return (
{/* Search Bar */}
setSearchQuery(e.target.value)} placeholder="Search" className="w-full px-2 py-1 rounded-md bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary focus:outline-none transition-all" />
{/* Results */}
{isSearching && (
Searching...
)} {!isSearching && hasSearched && searchResults.length === 0 && searchQuery.trim() !== '' && (
No results found.
)} {!isSearching && Object.keys(groupedResults).map((file) => (
{expandedFiles[file] && (
{groupedResults[file].map((match, idx) => { const contextChars = 7; const isStart = match.matchCharStart <= contextChars; const previewStart = isStart ? 0 : match.matchCharStart - contextChars; const previewText = match.previewText.slice(previewStart); const matchStart = isStart ? match.matchCharStart : contextChars; const matchEnd = isStart ? match.matchCharEnd : contextChars + (match.matchCharEnd - match.matchCharStart); return (
handleResultClick(match.path, match.lineNumber)} >
                          {!isStart && ...}
                          {previewText.slice(0, matchStart)}
                          
                            {previewText.slice(matchStart, matchEnd)}
                          
                          {previewText.slice(matchEnd)}
                        
); })}
)}
))}
); }