mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 10:16:01 +00:00
258 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|