bolt.diy/app/components/workbench/Search.tsx

258 lines
9.4 KiB
TypeScript

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<TextSearchOptions, 'folders'>,
onProgress: (results: DisplayMatch[]) => void,
): Promise<void> {
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<string, DisplayMatch[]> {
return results.reduce(
(acc, result) => {
if (!acc[result.path]) {
acc[result.path] = [];
}
acc[result.path].push(result);
return acc;
},
{} as Record<string, DisplayMatch[]>,
);
}
export function Search() {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DisplayMatch[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>({});
const [hasSearched, setHasSearched] = useState(false);
const groupedResults = useMemo(() => groupResultsByFile(searchResults), [searchResults]);
useEffect(() => {
if (searchResults.length > 0) {
const allExpanded: Record<string, boolean> = {};
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<TextSearchOptions, 'folders'> = {
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 (
<div className="flex flex-col h-full bg-bolt-elements-background-depth-2">
{/* Search Bar */}
<div className="flex items-center py-3 px-3">
<div className="relative flex-1">
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</div>
</div>
{/* Results */}
<div className="flex-1 overflow-auto py-2">
{isSearching && (
<div className="flex items-center justify-center h-32 text-bolt-elements-textTertiary">
<div className="i-ph:circle-notch animate-spin mr-2" /> Searching...
</div>
)}
{!isSearching && hasSearched && searchResults.length === 0 && searchQuery.trim() !== '' && (
<div className="flex items-center justify-center h-32 text-gray-500">No results found.</div>
)}
{!isSearching &&
Object.keys(groupedResults).map((file) => (
<div key={file} className="mb-2">
<button
className="flex gap-2 items-center w-full text-left py-1 px-2 text-bolt-elements-textSecondary bg-transparent hover:bg-bolt-elements-background-depth-3 group"
onClick={() => setExpandedFiles((prev) => ({ ...prev, [file]: !prev[file] }))}
>
<span
className=" i-ph:caret-down-thin w-3 h-3 text-bolt-elements-textSecondary transition-transform"
style={{ transform: expandedFiles[file] ? 'rotate(180deg)' : undefined }}
/>
<span className="font-normal text-sm">{file.split('/').pop()}</span>
<span className="h-5.5 w-5.5 flex items-center justify-center text-xs ml-auto bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent rounded-full">
{groupedResults[file].length}
</span>
</button>
{expandedFiles[file] && (
<div className="">
{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 (
<div
key={idx}
className="hover:bg-bolt-elements-background-depth-3 cursor-pointer transition-colors pl-6 py-1"
onClick={() => handleResultClick(match.path, match.lineNumber)}
>
<pre className="font-mono text-xs text-bolt-elements-textTertiary truncate">
{!isStart && <span>...</span>}
{previewText.slice(0, matchStart)}
<span className="bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent rounded px-1">
{previewText.slice(matchStart, matchEnd)}
</span>
{previewText.slice(matchEnd)}
</pre>
</div>
);
})}
</div>
)}
</div>
))}
</div>
</div>
);
}