'use client'; import React, { useState, useEffect, useCallback, useRef, Component, type ReactNode, type ErrorInfo } from 'react'; import MonacoEditor, { useMonaco } from '@monaco-editor/react'; import { VirtualFile, ProjectRuntime, getSpecificMimeType } from '@/lib/vfs/types'; import { vfs } from '@/lib/vfs'; import { X, Code2, Save, FileCode, Image as ImageIcon, Film, AlertCircle } from 'lucide-react'; import { PanelHeader } from '@/components/ui/panel'; import { Button } from '@/components/ui/button'; import { cn, logger } from '@/lib/utils'; import { useTheme } from 'next-themes'; import { useTypescriptIntelliSense } from '@/lib/hooks/use-typescript-intellisense'; import { track } from '@/lib/telemetry'; // Module-level, session-scoped: ensures `code_edited` fires at most once per // project per session, regardless of how many files are edited or how many // MultiTabEditor instances mount/unmount. const editedProjectsThisSession = new Set(); /** Error boundary to catch Monaco disposal crashes during panel resize/move. */ class EditorErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: Error, info: ErrorInfo) { logger.warn('[EditorErrorBoundary] Monaco recovered from error:', error.message); } componentDidUpdate(_: unknown, prevState: { hasError: boolean }) { if (this.state.hasError && !prevState.hasError) { // Re-render the editor on the next tick requestAnimationFrame(() => this.setState({ hasError: false })); } } render() { if (this.state.hasError) return null; return this.props.children; } } interface MultiTabEditorProps { projectId: string; runtime?: ProjectRuntime; onClose?: () => void; } interface OpenFile { file: VirtualFile; content: string; modified: boolean; } export function MultiTabEditor({ projectId, runtime, onClose }: MultiTabEditorProps) { const [openFiles, setOpenFiles] = useState>(new Map()); const [activeFilePath, setActiveFilePath] = useState(null); const { resolvedTheme } = useTheme(); const [mounted, setMounted] = useState(false); const savingPathsRef = React.useRef>(new Set()); // TypeScript IntelliSense for React projects const monacoInstance = useMonaco(); const monacoRef = useRef(monacoInstance); useEffect(() => { monacoRef.current = monacoInstance; }, [monacoInstance]); useTypescriptIntelliSense(projectId, runtime, monacoRef); useEffect(() => { setMounted(true); }, []); useEffect(() => { const handleFileOpen = (event: CustomEvent) => { openFile(event.detail); }; window.addEventListener('openFile', handleFileOpen as EventListener); return () => { window.removeEventListener('openFile', handleFileOpen as EventListener); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); useEffect(() => { const handleFilesChanged = async (event: CustomEvent) => { if (event.detail?.fromEditor) return; // Process updates asynchronously const updateFiles = async () => { // Capture current state setOpenFiles(prev => { const processingSnapshot = prev; // Run async updates (async () => { const updatedFiles = new Map(); for (const [path, openFile] of processingSnapshot.entries()) { // If this file is currently being saved, keep it unchanged if (savingPathsRef.current.has(path)) { updatedFiles.set(path, openFile); continue; } // If file is modified in editor, keep editor content if (openFile.modified) { try { await vfs.init(); const freshFile = await vfs.readFile(projectId, path); updatedFiles.set(path, { file: freshFile, content: openFile.content, modified: true }); } catch { updatedFiles.set(path, openFile); } continue; } // File not modified, update from VFS try { await vfs.init(); const freshFile = await vfs.readFile(projectId, path); updatedFiles.set(path, { file: freshFile, content: freshFile.content as string, modified: false }); } catch { updatedFiles.set(path, openFile); } } // Only apply updates if no files are being saved const hasFilesBeingSaved = Array.from(updatedFiles.keys()).some(path => savingPathsRef.current.has(path) ); if (!hasFilesBeingSaved) { setOpenFiles(updatedFiles); } })(); return prev; }); }; updateFiles(); }; window.addEventListener('filesChanged', handleFilesChanged as unknown as EventListener); return () => { window.removeEventListener('filesChanged', handleFilesChanged as unknown as EventListener); }; }, [projectId]); const openFile = async (file: VirtualFile) => { if (openFiles.has(file.path)) { setActiveFilePath(file.path); return; } const openFile: OpenFile = { file, content: file.content as string, modified: false }; setOpenFiles(prev => new Map(prev).set(file.path, openFile)); setActiveFilePath(file.path); }; const closeFile = (path: string, event?: React.MouseEvent) => { if (event) { event.stopPropagation(); } const file = openFiles.get(path); if (file?.modified) { if (!confirm(`Close ${file.file.name} without saving?`)) { return; } } setOpenFiles(prev => { const next = new Map(prev); next.delete(path); return next; }); if (activeFilePath === path) { const remaining = Array.from(openFiles.keys()).filter(p => p !== path); setActiveFilePath(remaining.length > 0 ? remaining[remaining.length - 1] : null); } }; const handleContentChange = useCallback((value: string | undefined, path: string) => { if (value === undefined) return; const fileType = getFileType(path); if (fileType.type !== 'text') return; setOpenFiles(prev => { const next = new Map(prev); const file = next.get(path); if (file) { const isModified = file.content !== value; next.set(path, { ...file, content: value, modified: isModified }); // Genuine user typing diverges from the state already committed by // the filesChanged handler (programmatic VFS updates set state and // editor content to the same value in the same pass, so isModified // is false for those). Only fire on real, user-originated edits. if (isModified && !editedProjectsThisSession.has(projectId)) { editedProjectsThisSession.add(projectId); track('code_edited'); } } return next; }); }, [projectId]); const saveFile = useCallback(async (path: string) => { const openFile = openFiles.get(path); if (!openFile || !openFile.modified) return; // Mark this path as being saved savingPathsRef.current.add(path); try { await vfs.init(); const updatedFile = await vfs.updateFile(projectId, path, openFile.content); setOpenFiles(prev => { const next = new Map(prev); next.set(path, { file: updatedFile, content: openFile.content, modified: false }); return next; }); } catch (error) { logger.error('Failed to save file:', error); } finally { // Remove from saving paths after a short delay to ensure all handlers have processed setTimeout(() => { savingPathsRef.current.delete(path); }, 100); } }, [openFiles, projectId]); const handleKeyDown = useCallback((e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); if (activeFilePath) { saveFile(activeFilePath); } } }, [activeFilePath, saveFile]); useEffect(() => { window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); }; }, [handleKeyDown]); const getMediaDataUrl = (file: OpenFile): string => { const mime = getSpecificMimeType(file.file.path); const content = file.file.content ?? file.content; if (content instanceof ArrayBuffer) { const bytes = new Uint8Array(content); let binary = ''; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return `data:${mime};base64,${btoa(binary)}`; } // Already a base64 string return `data:${mime};base64,${content}`; }; const getFileType = (path: string) => { const ext = path.split('.').pop()?.toLowerCase(); if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'ico'].includes(ext || '')) { return { type: 'image', language: 'plaintext' }; } if (['mp4', 'webm', 'ogg'].includes(ext || '')) { return { type: 'video', language: 'plaintext' }; } const textExtensions: Record = { 'js': 'javascript', 'mjs': 'javascript', 'ts': 'typescript', 'tsx': 'typescript', 'html': 'html', 'htm': 'html', 'css': 'css', 'json': 'json', 'md': 'markdown', 'txt': 'plaintext', 'svg': 'xml', 'xml': 'xml', 'yaml': 'yaml', 'yml': 'yaml', 'py': 'python', 'lua': 'lua' }; if (textExtensions[ext || '']) { return { type: 'text', language: textExtensions[ext || ''] }; } const binaryExtensions = ['zip', 'tar', 'gz', 'exe', 'bin', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']; if (binaryExtensions.includes(ext || '')) { return { type: 'unsupported', language: 'plaintext' }; } return { type: 'text', language: 'plaintext' }; }; const getLanguageFromPath = (path: string): string => { return getFileType(path).language; }; const activeFile = activeFilePath ? openFiles.get(activeFilePath) : null; return (
saveFile(activeFilePath!)} > Save )} /> {openFiles.size === 0 ? (

No files open

Select a file from the explorer to edit

) : ( <>
{Array.from(openFiles.entries()).map(([path, file]) => (
setActiveFilePath(path)} > {file.file.name} {file.modified && }
))}
{activeFile && (
{(() => { const fileType = getFileType(activeFile.file.path); if (fileType.type === 'image') { return (

Image Preview

{activeFile.file.name}

{activeFile.file.name} { const target = e.target as HTMLImageElement; target.style.display = 'none'; if (!target.parentElement?.querySelector('.error-msg')) { const div = document.createElement('div'); div.className = 'error-msg text-sm text-muted-foreground'; div.textContent = 'Unable to display image'; target.parentElement?.appendChild(div); } }} />

Image files cannot be edited in the text editor

); } if (fileType.type === 'video') { return (

Video Preview

{activeFile.file.name}

Video files cannot be edited in the text editor

); } if (fileType.type === 'unsupported') { return (

Unsupported File Type

{activeFile.file.name}

This file type is not supported for editing in the text editor. Binary files and certain document formats cannot be displayed here.

); } return ( handleContentChange(value, activeFile.file.path)} theme={mounted ? (resolvedTheme === 'dark' ? 'vs-dark' : 'light') : 'vs-dark'} options={{ minimap: { enabled: false }, fontSize: 14, lineNumbers: 'on', roundedSelection: false, scrollBeyondLastLine: false, automaticLayout: true, tabSize: 2, wordWrap: 'on', wrappingIndent: 'indent' }} /> ); })()}
)} )}
); } export function openFileInEditor(file: VirtualFile) { window.dispatchEvent(new CustomEvent('openFile', { detail: file })); }