feat: enhance error handling and add new search feature

- 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.
This commit is contained in:
KevIsDev 2025-05-01 15:56:08 +01:00
parent 3b2e869651
commit fcaf8f66f0
5 changed files with 337 additions and 24 deletions

View File

@ -47,8 +47,10 @@ type TextEditorDocument = EditorDocument & {
};
export interface ScrollPosition {
top: number;
left: number;
top?: number;
left?: number;
line?: number;
column?: number;
}
export interface EditorUpdate {
@ -159,6 +161,25 @@ export const CodeMirrorEditor = memo(
themeRef.current = theme;
});
useEffect(() => {
if (!viewRef.current || !doc || doc.isBinary) {
return;
}
if (typeof doc.scroll?.line === 'number') {
const line = doc.scroll.line;
const column = doc.scroll.column ?? 0;
const linePos = viewRef.current.state.doc.line(line + 1).from + column;
viewRef.current.dispatch({
selection: { anchor: linePos },
scrollIntoView: true,
});
viewRef.current.focus();
} else if (typeof doc.scroll?.top === 'number' || typeof doc.scroll?.left === 'number') {
viewRef.current.scrollDOM.scrollTo(doc.scroll.left ?? 0, doc.scroll.top ?? 0);
}
}, [doc?.scroll?.line, doc?.scroll?.column, doc?.scroll?.top, doc?.scroll?.left]);
useEffect(() => {
const onUpdate = debounce((update: EditorUpdate) => {
onChangeRef.current?.(update);
@ -417,11 +438,23 @@ function setEditorDocument(
const newLeft = doc.scroll?.left ?? 0;
const newTop = doc.scroll?.top ?? 0;
if (typeof doc.scroll?.line === 'number') {
const line = doc.scroll.line;
const column = doc.scroll.column ?? 0;
const linePos = view.state.doc.line(line + 1).from + column;
view.dispatch({
selection: { anchor: linePos },
scrollIntoView: true,
});
view.focus();
return;
}
const needsScrolling = currentLeft !== newLeft || currentTop !== newTop;
if (autoFocus && editable) {
if (needsScrolling) {
// we have to wait until the scroll position was changed before we can set the focus
view.scrollDOM.addEventListener(
'scroll',
() => {
@ -430,7 +463,6 @@ function setEditorDocument(
{ once: true },
);
} else {
// if the scroll position is still the same we can focus immediately
view.focus();
}
}

View File

@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react';
import { memo, useMemo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import * as Tabs from '@radix-ui/react-tabs'; // <-- Import Radix UI Tabs
import {
CodeMirrorEditor,
type EditorDocument,
@ -21,6 +22,8 @@ import { FileBreadcrumb } from './FileBreadcrumb';
import { FileTree } from './FileTree';
import { DEFAULT_TERMINAL_SIZE, TerminalTabs } from './terminal/TerminalTabs';
import { workbenchStore } from '~/lib/stores/workbench';
import { Search } from './Search'; // <-- Ensure Search is imported
import { classNames } from '~/utils/classNames'; // <-- Import classNames if not already present
interface EditorPanelProps {
files?: FileMap;
@ -75,12 +78,30 @@ export const EditorPanel = memo(
<PanelGroup direction="vertical">
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
<PanelGroup direction="horizontal">
<Panel defaultSize={20} minSize={10} collapsible>
<div className="flex flex-col border-r border-bolt-elements-borderColor h-full">
<PanelHeader>
<div className="i-ph:tree-structure-duotone shrink-0" />
<Panel defaultSize={20} minSize={15} collapsible className="border-r border-bolt-elements-borderColor">
<Tabs.Root defaultValue="files" className="flex flex-col h-full">
<PanelHeader className="w-full text-sm font-medium text-bolt-elements-textSecondary px-1">
<Tabs.List className="h-full flex-shrink-0 flex items-center">
<Tabs.Trigger
value="files"
className={classNames(
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
)}
>
Files
</Tabs.Trigger>
<Tabs.Trigger
value="search"
className={classNames(
'h-full bg-transparent hover:bg-bolt-elements-background-depth-3 py-0.5 px-2 rounded-lg text-sm font-medium text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary data-[state=active]:text-bolt-elements-textPrimary',
)}
>
Search
</Tabs.Trigger>
</Tabs.List>
</PanelHeader>
<Tabs.Content value="files" className="flex-grow overflow-auto focus-visible:outline-none">
<FileTree
className="h-full"
files={files}
@ -91,8 +112,14 @@ export const EditorPanel = memo(
selectedFile={selectedFile}
onFileSelect={onFileSelect}
/>
</div>
</Tabs.Content>
<Tabs.Content value="search" className="flex-grow overflow-auto focus-visible:outline-none">
<Search />
</Tabs.Content>
</Tabs.Root>
</Panel>
<PanelResizeHandle />
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
<PanelHeader className="overflow-x-auto">

View File

@ -0,0 +1,239 @@
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>
);
}

View File

@ -39,12 +39,27 @@ if (!import.meta.env.SSR) {
console.log('WebContainer preview message:', message);
// Handle both uncaught exceptions and unhandled promise rejections
if (message.type === 'PREVIEW_UNCAUGHT_EXCEPTION' || message.type === 'PREVIEW_UNHANDLED_REJECTION') {
if (
message.type === 'PREVIEW_UNCAUGHT_EXCEPTION' ||
message.type === 'PREVIEW_UNHANDLED_REJECTION' ||
message.type === 'PREVIEW_CONSOLE_ERROR'
) {
const isPromise = message.type === 'PREVIEW_UNHANDLED_REJECTION';
const isConsoleError = message.type === 'PREVIEW_CONSOLE_ERROR';
const title = isPromise
? 'Unhandled Promise Rejection'
: isConsoleError
? 'Console Error'
: 'Uncaught Exception';
workbenchStore.actionAlert.set({
type: 'preview',
title: isPromise ? 'Unhandled Promise Rejection' : 'Uncaught Exception',
description: message.message,
title,
description:
'message' in message
? message.message
: 'args' in message && Array.isArray(message.args) && message.args.length > 0
? message.args[0]
: 'Unknown error',
content: `Error occurred at ${message.pathname}${message.search}${message.hash}\nPort: ${message.port}\n\nStack trace:\n${cleanStackTrace(message.stack || '')}`,
source: 'preview',
});