mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
- Add support for `PREVIEW_CONSOLE_ERROR` in WebContainer error handling - Introduce new Search component for text search functionality - Extend `ScrollPosition` interface to include `line` and `column` - Implement scroll-to-line functionality in CodeMirrorEditor - Add tab-based navigation for files and search in EditorPanel This commit introduces several enhancements to the editor, including improved error handling, better scrolling capabilities, and a new search feature. The changes are focused on improving the user experience and adding new functionality to the editor components.
240 lines
8.7 KiB
TypeScript
240 lines
8.7 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 { Loader2 } from 'lucide-react';
|
|
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 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({});
|
|
|
|
return;
|
|
}
|
|
|
|
setIsSearching(true);
|
|
setSearchResults([]);
|
|
setExpandedFiles({});
|
|
|
|
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 {
|
|
setIsSearching(false);
|
|
}
|
|
}, []);
|
|
|
|
const debouncedSearch = useCallback(debounce(handleSearch, 300), [handleSearch]);
|
|
|
|
useEffect(() => {
|
|
debouncedSearch(searchQuery);
|
|
}, [searchQuery, debouncedSearch]);
|
|
|
|
const handleResultClick = (filePath: string, line?: number) => {
|
|
workbenchStore.setSelectedFile(filePath);
|
|
workbenchStore.setCurrentDocumentScrollPosition({ line, 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">
|
|
<Loader2 className="animate-spin mr-2" /> Searching...
|
|
</div>
|
|
)}
|
|
{!isSearching && searchResults.length === 0 && (
|
|
<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>
|
|
);
|
|
}
|