mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
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:
parent
3b2e869651
commit
fcaf8f66f0
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
239
app/components/workbench/Search.tsx
Normal file
239
app/components/workbench/Search.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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',
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user