import React, { useState, useEffect, useRef, useMemo } from 'react'; import CodeMirror from '@uiw/react-codemirror'; import { javascript } from '@codemirror/lang-javascript'; import { python } from '@codemirror/lang-python'; import { html } from '@codemirror/lang-html'; import { css } from '@codemirror/lang-css'; import { json } from '@codemirror/lang-json'; import { markdown } from '@codemirror/lang-markdown'; import { oneDark } from '@codemirror/theme-one-dark'; import { StreamLanguage } from '@codemirror/language'; import { EditorView, showPanel, ViewPlugin } from '@codemirror/view'; import { unifiedMergeView, getChunks } from '@codemirror/merge'; import { showMinimap } from '@replit/codemirror-minimap'; import { X, Save, Download, Maximize2, Minimize2, Settings as SettingsIcon } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { api } from '../utils/api'; import { useTranslation } from 'react-i18next'; import { Eye, Code2 } from 'lucide-react'; // Custom .env file syntax highlighting const envLanguage = StreamLanguage.define({ token(stream) { // Comments if (stream.match(/^#.*/)) return 'comment'; // Key (before =) if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition'; // Equals sign if (stream.match(/^=/)) return 'operator'; // Double-quoted string if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string'; // Single-quoted string if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string'; // Variable interpolation ${...} if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special'; // Variable reference $VAR if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special'; // Numbers if (stream.match(/^\d+/)) return 'number'; // Skip other characters stream.next(); return null; }, }); function MarkdownCodeBlock({ inline, className, children, ...props }) { const [copied, setCopied] = useState(false); const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); const looksMultiline = /[\r\n]/.test(raw); const shouldInline = inline || !looksMultiline; if (shouldInline) { return ( {children} ); } const match = /language-(\w+)/.exec(className || ''); const language = match ? match[1] : 'text'; return (
{language && language !== 'text' && (
{language}
)} {raw}
); } const markdownPreviewComponents = { code: MarkdownCodeBlock, blockquote: ({ children }) => (
{children}
), a: ({ href, children }) => ( {children} ), table: ({ children }) => (
{children}
), thead: ({ children }) => {children}, th: ({ children }) => ( {children} ), td: ({ children }) => ( {children} ), }; function MarkdownPreview({ content }) { const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); const rehypePlugins = useMemo(() => [rehypeRaw, rehypeKatex], []); return ( {content} ); } function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null, onPopOut = null }) { const { t } = useTranslation('codeEditor'); const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [isDarkMode, setIsDarkMode] = useState(() => { const savedTheme = localStorage.getItem('codeEditorTheme'); return savedTheme ? savedTheme === 'dark' : true; }); const [saveSuccess, setSaveSuccess] = useState(false); const [showDiff, setShowDiff] = useState(!!file.diffInfo); const [wordWrap, setWordWrap] = useState(() => { return localStorage.getItem('codeEditorWordWrap') === 'true'; }); const [minimapEnabled, setMinimapEnabled] = useState(() => { return localStorage.getItem('codeEditorShowMinimap') !== 'false'; }); const [showLineNumbers, setShowLineNumbers] = useState(() => { return localStorage.getItem('codeEditorLineNumbers') !== 'false'; }); const [fontSize, setFontSize] = useState(() => { return localStorage.getItem('codeEditorFontSize') || '12'; }); const [markdownPreview, setMarkdownPreview] = useState(false); const editorRef = useRef(null); // Check if file is markdown const isMarkdownFile = useMemo(() => { const ext = file.name.split('.').pop()?.toLowerCase(); return ext === 'md' || ext === 'markdown'; }, [file.name]); // Create minimap extension with chunk-based gutters const minimapExtension = useMemo(() => { if (!file.diffInfo || !showDiff || !minimapEnabled) return []; const gutters = {}; return [ showMinimap.compute(['doc'], (state) => { // Get actual chunks from merge view const chunksData = getChunks(state); const chunks = chunksData?.chunks || []; // Clear previous gutters Object.keys(gutters).forEach(key => delete gutters[key]); // Mark lines that are part of chunks chunks.forEach(chunk => { // Mark the lines in the B side (current document) const fromLine = state.doc.lineAt(chunk.fromB).number; const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number; for (let lineNum = fromLine; lineNum <= toLine; lineNum++) { gutters[lineNum] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)'; } }); return { create: () => ({ dom: document.createElement('div') }), displayText: 'blocks', showOverlay: 'always', gutters: [gutters] }; }) ]; }, [file.diffInfo, showDiff, minimapEnabled, isDarkMode]); // Create extension to scroll to first chunk on mount const scrollToFirstChunkExtension = useMemo(() => { if (!file.diffInfo || !showDiff) return []; return [ ViewPlugin.fromClass(class { constructor(view) { // Delay to ensure merge view is fully initialized setTimeout(() => { const chunksData = getChunks(view.state); const chunks = chunksData?.chunks || []; if (chunks.length > 0) { const firstChunk = chunks[0]; // Scroll to the first chunk view.dispatch({ effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' }) }); } }, 100); } update() {} destroy() {} }) ]; }, [file.diffInfo, showDiff]); // Whether toolbar has any buttons worth showing const hasToolbarButtons = !!(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand)); // Create editor toolbar panel - only when there are buttons to show const editorToolbarPanel = useMemo(() => { if (!hasToolbarButtons) return []; const createPanel = (view) => { const dom = document.createElement('div'); dom.className = 'cm-editor-toolbar-panel'; let currentIndex = 0; const updatePanel = () => { // Check if we have diff info and it's enabled const hasDiff = file.diffInfo && showDiff; const chunksData = hasDiff ? getChunks(view.state) : null; const chunks = chunksData?.chunks || []; const chunkCount = chunks.length; // Build the toolbar HTML let toolbarHTML = '
'; // Left side - diff navigation (if applicable) toolbarHTML += '
'; if (hasDiff) { toolbarHTML += ` ${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')} `; } toolbarHTML += '
'; // Right side - action buttons toolbarHTML += '
'; // Show/hide diff button (only if there's diff info) if (file.diffInfo) { toolbarHTML += ` `; } // Pop out button (only in sidebar mode with onPopOut) if (isSidebar && onPopOut) { toolbarHTML += ` `; } // Expand button (only in sidebar mode) if (isSidebar && onToggleExpand) { toolbarHTML += ` `; } toolbarHTML += '
'; toolbarHTML += '
'; dom.innerHTML = toolbarHTML; if (hasDiff) { const prevBtn = dom.querySelector('.cm-diff-nav-prev'); const nextBtn = dom.querySelector('.cm-diff-nav-next'); prevBtn?.addEventListener('click', () => { if (chunks.length === 0) return; currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1; const chunk = chunks[currentIndex]; if (chunk) { view.dispatch({ effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }) }); } updatePanel(); }); nextBtn?.addEventListener('click', () => { if (chunks.length === 0) return; currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0; const chunk = chunks[currentIndex]; if (chunk) { view.dispatch({ effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }) }); } updatePanel(); }); } if (file.diffInfo) { const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn'); toggleDiffBtn?.addEventListener('click', () => { setShowDiff(!showDiff); }); } if (isSidebar && onPopOut) { const popoutBtn = dom.querySelector('.cm-popout-btn'); popoutBtn?.addEventListener('click', () => { onPopOut(); }); } if (isSidebar && onToggleExpand) { const expandBtn = dom.querySelector('.cm-expand-btn'); expandBtn?.addEventListener('click', () => { onToggleExpand(); }); } }; updatePanel(); return { top: true, dom, update: updatePanel }; }; return [showPanel.of(createPanel)]; }, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand, onPopOut]); // Get language extension based on file extension const getLanguageExtension = (filename) => { const lowerName = filename.toLowerCase(); // Handle dotfiles like .env, .env.local, .env.production, etc. if (lowerName === '.env' || lowerName.startsWith('.env.')) { return [envLanguage]; } const ext = filename.split('.').pop()?.toLowerCase(); switch (ext) { case 'js': case 'jsx': case 'ts': case 'tsx': return [javascript({ jsx: true, typescript: ext.includes('ts') })]; case 'py': return [python()]; case 'html': case 'htm': return [html()]; case 'css': case 'scss': case 'less': return [css()]; case 'json': return [json()]; case 'md': case 'markdown': return [markdown()]; case 'env': return [envLanguage]; default: return []; } }; // Load file content useEffect(() => { const loadFileContent = async () => { try { setLoading(true); // If we have diffInfo with both old and new content, we can show the diff directly // This handles both GitPanel (full content) and ChatInterface (full content from API) if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) { // Use the new_string as the content to display // The unifiedMergeView will compare it against old_string setContent(file.diffInfo.new_string); setLoading(false); return; } // Otherwise, load from disk const response = await api.readFile(file.projectName, file.path); if (!response.ok) { throw new Error(`Failed to load file: ${response.status} ${response.statusText}`); } const data = await response.json(); setContent(data.content); } catch (error) { console.error('Error loading file:', error); setContent(`// Error loading file: ${error.message}\n// File: ${file.name}\n// Path: ${file.path}`); } finally { setLoading(false); } }; loadFileContent(); }, [file, projectPath]); const handleSave = async () => { setSaving(true); try { console.log('Saving file:', { projectName: file.projectName, path: file.path, contentLength: content?.length }); const response = await api.saveFile(file.projectName, file.path, content); console.log('Save response:', { status: response.status, ok: response.ok, contentType: response.headers.get('content-type') }); if (!response.ok) { const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { const errorData = await response.json(); throw new Error(errorData.error || `Save failed: ${response.status}`); } else { const textError = await response.text(); console.error('Non-JSON error response:', textError); throw new Error(`Save failed: ${response.status} ${response.statusText}`); } } const result = await response.json(); console.log('Save successful:', result); setSaveSuccess(true); setTimeout(() => setSaveSuccess(false), 2000); } catch (error) { console.error('Error saving file:', error); alert(`Error saving file: ${error.message}`); } finally { setSaving(false); } }; const handleDownload = () => { const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = file.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const toggleFullscreen = () => { setIsFullscreen(!isFullscreen); }; // Save theme preference to localStorage useEffect(() => { localStorage.setItem('codeEditorTheme', isDarkMode ? 'dark' : 'light'); }, [isDarkMode]); // Save word wrap preference to localStorage useEffect(() => { localStorage.setItem('codeEditorWordWrap', wordWrap.toString()); }, [wordWrap]); // Listen for settings changes from the Settings modal useEffect(() => { const handleStorageChange = () => { const newTheme = localStorage.getItem('codeEditorTheme'); if (newTheme) { setIsDarkMode(newTheme === 'dark'); } const newWordWrap = localStorage.getItem('codeEditorWordWrap'); if (newWordWrap !== null) { setWordWrap(newWordWrap === 'true'); } const newShowMinimap = localStorage.getItem('codeEditorShowMinimap'); if (newShowMinimap !== null) { setMinimapEnabled(newShowMinimap !== 'false'); } const newShowLineNumbers = localStorage.getItem('codeEditorLineNumbers'); if (newShowLineNumbers !== null) { setShowLineNumbers(newShowLineNumbers !== 'false'); } const newFontSize = localStorage.getItem('codeEditorFontSize'); if (newFontSize) { setFontSize(newFontSize); } }; // Listen for storage events (changes from other tabs/windows) window.addEventListener('storage', handleStorageChange); // Custom event for same-window updates window.addEventListener('codeEditorSettingsChanged', handleStorageChange); return () => { window.removeEventListener('storage', handleStorageChange); window.removeEventListener('codeEditorSettingsChanged', handleStorageChange); }; }, []); // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (e) => { if (e.ctrlKey || e.metaKey) { if (e.key === 's') { e.preventDefault(); handleSave(); } else if (e.key === 'Escape') { e.preventDefault(); onClose(); } } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [content]); if (loading) { return ( <> {isSidebar ? (
{t('loading', { fileName: file.name })}
) : (
{t('loading', { fileName: file.name })}
)} ); } return ( <>
{/* Header */}

{file.name}

{file.diffInfo && ( {t('header.showingChanges')} )}

{file.path}

{isMarkdownFile && ( )} {!isSidebar && ( )}
{/* Editor / Markdown Preview */}
{markdownPreview && isMarkdownFile ? (
) : ( )}
{/* Footer */}
{t('footer.lines')} {content.split('\n').length} {t('footer.characters')} {content.length}
{t('footer.shortcuts')}
); } export default CodeEditor;