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 { EditorView, showPanel, ViewPlugin } from '@codemirror/view'; import { unifiedMergeView, getChunks } from '@codemirror/merge'; import { showMinimap } from '@replit/codemirror-minimap'; import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react'; import { api } from '../utils/api'; function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) { 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') || '14'; }); const editorRef = useRef(null); // 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]); // Create editor toolbar panel - always visible const editorToolbarPanel = useMemo(() => { 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'} changes `; } toolbarHTML += '
'; // Right side - action buttons toolbarHTML += '
'; // Show/hide diff button (only if there's diff info) if (file.diffInfo) { toolbarHTML += ` `; } // Settings button toolbarHTML += ` `; // Expand button (only in sidebar mode) if (isSidebar && onToggleExpand) { toolbarHTML += ` `; } toolbarHTML += '
'; toolbarHTML += '
'; dom.innerHTML = toolbarHTML; // Attach event listeners for diff navigation 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(); }); } // Attach event listener for toggle diff button if (file.diffInfo) { const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn'); toggleDiffBtn?.addEventListener('click', () => { setShowDiff(!showDiff); }); } // Attach event listener for settings button const settingsBtn = dom.querySelector('.cm-settings-btn'); settingsBtn?.addEventListener('click', () => { if (window.openSettings) { window.openSettings('appearance'); } }); // Attach event listener for expand button 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]); // Get language extension based on file extension const getLanguageExtension = (filename) => { 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()]; 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 ? (
Loading {file.name}...
) : (
Loading {file.name}...
)} ); } return ( <>
{/* Header */}

{file.name}

{file.diffInfo && ( Showing changes )}

{file.path}

{!isSidebar && ( )}
{/* Editor */}
{/* Footer */}
Lines: {content.split('\n').length} Characters: {content.length}
Press Ctrl+S to save • Esc to close
); } export default CodeEditor;