feat(editor): Change Code Editor to show diffs in source control panel and during messaging.

Add merge view and minimap extensions to CodeMirror for enhanced code
editing capabilities. Increase Express JSON and URL-encoded payload
limits from default (100kb) to 50mb to support larger file operations
and git diffs.
This commit is contained in:
simos
2025-10-31 00:37:20 +00:00
parent eda89ef147
commit d2f02558a1
9 changed files with 644 additions and 204 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
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';
@@ -7,8 +7,9 @@ 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, Decoration } from '@codemirror/view';
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
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, Eye, EyeOff } from 'lucide-react';
import { api } from '../utils/api';
@@ -21,90 +22,150 @@ function CodeEditor({ file, onClose, projectPath }) {
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(false);
const editorRef = useRef(null);
// Create diff highlighting
const diffEffect = StateEffect.define();
const diffField = StateField.define({
create() {
return Decoration.none;
},
update(decorations, tr) {
decorations = decorations.map(tr.changes);
for (let effect of tr.effects) {
if (effect.is(diffEffect)) {
decorations = effect.value;
// Create minimap extension with chunk-based gutters
const minimapExtension = useMemo(() => {
if (!file.diffInfo || !showDiff) 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, 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);
}
}
return decorations;
},
provide: f => EditorView.decorations.from(f)
});
const createDiffDecorations = (content, diffInfo) => {
if (!diffInfo || !showDiff) return Decoration.none;
const builder = new RangeSetBuilder();
const lines = content.split('\n');
const oldLines = diffInfo.old_string.split('\n');
// Find the line where the old content starts
let startLineIndex = -1;
for (let i = 0; i <= lines.length - oldLines.length; i++) {
let matches = true;
for (let j = 0; j < oldLines.length; j++) {
if (lines[i + j] !== oldLines[j]) {
matches = false;
break;
}
}
if (matches) {
startLineIndex = i;
break;
}
}
update() {}
destroy() {}
})
];
}, [file.diffInfo, showDiff]);
if (startLineIndex >= 0) {
let pos = 0;
// Calculate position to start of old content
for (let i = 0; i < startLineIndex; i++) {
pos += lines[i].length + 1; // +1 for newline
}
// Highlight old lines (to be removed)
for (let i = 0; i < oldLines.length; i++) {
const lineStart = pos;
const lineEnd = pos + oldLines[i].length;
builder.add(lineStart, lineEnd, Decoration.line({
class: isDarkMode ? 'diff-removed-dark' : 'diff-removed-light'
}));
pos += oldLines[i].length + 1;
}
}
return builder.finish();
};
// Create diff navigation panel extension
const diffNavigationPanel = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
// Diff decoration theme
const diffTheme = EditorView.theme({
'.diff-removed-light': {
backgroundColor: '#fef2f2',
borderLeft: '3px solid #ef4444'
},
'.diff-removed-dark': {
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderLeft: '3px solid #ef4444'
},
'.diff-added-light': {
backgroundColor: '#f0fdf4',
borderLeft: '3px solid #22c55e'
},
'.diff-added-dark': {
backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderLeft: '3px solid #22c55e'
}
});
const createPanel = (view) => {
const dom = document.createElement('div');
dom.className = 'cm-diff-navigation-panel';
let currentIndex = 0;
const updatePanel = () => {
// Use getChunks API to get ALL chunks regardless of viewport
const chunksData = getChunks(view.state);
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
dom.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="Next change" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
`;
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;
// Navigate to the chunk - use fromB which is the position in the current document
const chunk = chunks[currentIndex];
if (chunk) {
// Scroll to the start of the chunk in the B side (current document)
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;
// Navigate to the chunk - use fromB which is the position in the current document
const chunk = chunks[currentIndex];
if (chunk) {
// Scroll to the start of the chunk in the B side (current document)
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
};
updatePanel();
return {
top: true,
dom,
update: updatePanel
};
};
return [showPanel.of(createPanel)];
}, [file.diffInfo, showDiff]);
// Get language extension based on file extension
const getLanguageExtension = (filename) => {
@@ -139,13 +200,24 @@ function CodeEditor({ file, onClose, projectPath }) {
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) {
@@ -159,37 +231,41 @@ function CodeEditor({ file, onClose, projectPath }) {
loadFileContent();
}, [file, projectPath]);
// Update diff decorations when content or diff info changes
const editorRef = useRef(null);
useEffect(() => {
if (editorRef.current && content && file.diffInfo && showDiff) {
const decorations = createDiffDecorations(content, file.diffInfo);
const view = editorRef.current.view;
if (view) {
view.dispatch({
effects: diffEffect.of(decorations)
});
}
}
}, [content, file.diffInfo, showDiff, isDarkMode]);
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 errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
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();
// Show success feedback
console.log('Save successful:', result);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000); // Hide after 2 seconds
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
console.error('Error saving file:', error);
alert(`Error saving file: ${error.message}`);
@@ -258,11 +334,80 @@ function CodeEditor({ file, onClose, projectPath }) {
}
return (
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`bg-white shadow-2xl flex flex-col ${
<>
<style>
{`
/* Light background for full line changes */
.cm-deletedChunk {
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
padding-left: 4px !important;
}
.cm-insertedChunk {
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
padding-left: 4px !important;
}
/* Override linear-gradient underline and use solid darker background for partial changes */
.cm-editor.cm-merge-b .cm-changedText {
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-editor .cm-deletedChunk .cm-changedText {
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
/* Minimap gutter styling */
.cm-gutter.cm-gutter-minimap {
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
}
/* Diff navigation panel styling */
.cm-diff-navigation-panel {
padding: 8px 12px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
color: ${isDarkMode ? '#d1d5db' : '#374151'};
font-size: 14px;
}
.cm-diff-nav-btn {
padding: 4px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
}
.cm-diff-nav-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
.cm-diff-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`}
</style>
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
@@ -287,7 +432,7 @@ function CodeEditor({ file, onClose, projectPath }) {
<p className="text-sm text-gray-500 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
{file.diffInfo && (
<button
@@ -298,19 +443,19 @@ function CodeEditor({ file, onClose, projectPath }) {
{showDiff ? <EyeOff className="w-5 h-5 md:w-4 md:h-4" /> : <Eye className="w-5 h-5 md:w-4 md:h-4" />}
</button>
)}
<button
onClick={() => setWordWrap(!wordWrap)}
className={`p-2 md:p-2 rounded-md hover:bg-gray-100 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center ${
wordWrap
? 'text-blue-600 bg-blue-50'
wordWrap
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-gray-900'
}`}
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<span className="text-sm md:text-xs font-mono font-bold"></span>
</button>
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
@@ -318,7 +463,7 @@ function CodeEditor({ file, onClose, projectPath }) {
>
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
</button>
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
@@ -326,13 +471,13 @@ function CodeEditor({ file, onClose, projectPath }) {
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={handleSave}
disabled={saving}
className={`px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors min-h-[44px] md:min-h-0 ${
saveSuccess
? 'bg-green-600 hover:bg-green-700'
saveSuccess
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
@@ -350,7 +495,7 @@ function CodeEditor({ file, onClose, projectPath }) {
</>
)}
</button>
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
@@ -358,7 +503,7 @@ function CodeEditor({ file, onClose, projectPath }) {
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
<button
onClick={onClose}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
@@ -377,8 +522,21 @@ function CodeEditor({ file, onClose, projectPath }) {
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
diffField,
diffTheme,
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
? [
unifiedMergeView({
original: file.diffInfo.old_string,
mergeControls: false,
highlightChanges: true,
syntaxHighlightDeletions: false,
gutter: true
// NOTE: NO collapseUnchanged - this shows the full file!
}),
...minimapExtension,
...scrollToFirstChunkExtension,
...diffNavigationPanel
]
: []),
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
@@ -409,14 +567,15 @@ function CodeEditor({ file, onClose, projectPath }) {
<span>Characters: {content.length}</span>
<span>Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Press Ctrl+S to save Esc to close
</div>
</div>
</div>
</div>
</>
);
}
export default CodeEditor;
export default CodeEditor;