From fcaf8f66f03526f379cd1b69f245777bacad0dc4 Mon Sep 17 00:00:00 2001 From: KevIsDev Date: Thu, 1 May 2025 15:56:08 +0100 Subject: [PATCH] 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. --- .../editor/codemirror/CodeMirrorEditor.tsx | 40 ++- app/components/workbench/EditorPanel.tsx | 59 +++-- app/components/workbench/Search.tsx | 239 ++++++++++++++++++ app/lib/webcontainer/index.ts | 21 +- app/styles/index.scss | 2 +- 5 files changed, 337 insertions(+), 24 deletions(-) create mode 100644 app/components/workbench/Search.tsx diff --git a/app/components/editor/codemirror/CodeMirrorEditor.tsx b/app/components/editor/codemirror/CodeMirrorEditor.tsx index e222eabb..f2ab3e15 100644 --- a/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -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(); } } diff --git a/app/components/workbench/EditorPanel.tsx b/app/components/workbench/EditorPanel.tsx index 4c5d0c51..11d2630c 100644 --- a/app/components/workbench/EditorPanel.tsx +++ b/app/components/workbench/EditorPanel.tsx @@ -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,24 +78,48 @@ export const EditorPanel = memo( - -
- -
- Files + + + + + + Files + + + Search + + - -
+ + + + + + + + + + diff --git a/app/components/workbench/Search.tsx b/app/components/workbench/Search.tsx new file mode 100644 index 00000000..fed64efc --- /dev/null +++ b/app/components/workbench/Search.tsx @@ -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, + onProgress: (results: DisplayMatch[]) => void, +): Promise { + 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 { + return results.reduce( + (acc, result) => { + if (!acc[result.path]) { + acc[result.path] = []; + } + + acc[result.path].push(result); + + return acc; + }, + {} as Record, + ); +} + +export function Search() { + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [expandedFiles, setExpandedFiles] = useState>({}); + + const groupedResults = useMemo(() => groupResultsByFile(searchResults), [searchResults]); + + useEffect(() => { + if (searchResults.length > 0) { + const allExpanded: Record = {}; + 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 = { + 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 ( +
+ {/* Search Bar */} +
+
+ 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" + /> +
+
+ + {/* Results */} +
+ {isSearching && ( +
+ Searching... +
+ )} + {!isSearching && searchResults.length === 0 && ( +
No results found.
+ )} + {!isSearching && + Object.keys(groupedResults).map((file) => ( +
+ + {expandedFiles[file] && ( +
+ {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 ( +
handleResultClick(match.path, match.lineNumber)} + > +
+                          {!isStart && ...}
+                          {previewText.slice(0, matchStart)}
+                          
+                            {previewText.slice(matchStart, matchEnd)}
+                          
+                          {previewText.slice(matchEnd)}
+                        
+
+ ); + })} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/app/lib/webcontainer/index.ts b/app/lib/webcontainer/index.ts index cd46d3d5..5591e396 100644 --- a/app/lib/webcontainer/index.ts +++ b/app/lib/webcontainer/index.ts @@ -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', }); diff --git a/app/styles/index.scss b/app/styles/index.scss index e2a86ff0..85f168f2 100644 --- a/app/styles/index.scss +++ b/app/styles/index.scss @@ -69,4 +69,4 @@ body { // Firefox support for inverted colors scrollbar-color: color-mix(in srgb, var(--bolt-elements-textPrimary), transparent 50%) transparent; -} \ No newline at end of file +}