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 ? (
) : (
)}
>
);
}
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;