mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-14 10:22:03 +08:00
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.
582 lines
22 KiB
JavaScript
582 lines
22 KiB
JavaScript
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, Eye, EyeOff } from 'lucide-react';
|
|
import { api } from '../utils/api';
|
|
|
|
function CodeEditor({ file, onClose, projectPath }) {
|
|
const [content, setContent] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const [isDarkMode, setIsDarkMode] = useState(true);
|
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
|
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
|
|
const [wordWrap, setWordWrap] = useState(false);
|
|
const editorRef = useRef(null);
|
|
|
|
// 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);
|
|
}
|
|
|
|
update() {}
|
|
destroy() {}
|
|
})
|
|
];
|
|
}, [file.diffInfo, showDiff]);
|
|
|
|
// Create diff navigation panel extension
|
|
const diffNavigationPanel = useMemo(() => {
|
|
if (!file.diffInfo || !showDiff) return [];
|
|
|
|
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) => {
|
|
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);
|
|
};
|
|
|
|
// 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 (
|
|
<>
|
|
<style>
|
|
{`
|
|
.code-editor-loading {
|
|
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
|
}
|
|
.code-editor-loading:hover {
|
|
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
|
}
|
|
`}
|
|
</style>
|
|
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
|
|
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
|
<div className="flex items-center gap-3">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
|
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<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]')
|
|
}`}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0 min-w-0">
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
|
|
<span className="text-white text-sm font-mono">
|
|
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
|
|
</span>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<h3 className="font-medium text-gray-900 truncate">{file.name}</h3>
|
|
{file.diffInfo && (
|
|
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
|
|
📝 Has changes
|
|
</span>
|
|
)}
|
|
</div>
|
|
<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
|
|
onClick={() => setShowDiff(!showDiff)}
|
|
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"
|
|
title={showDiff ? "Hide diff highlighting" : "Show diff highlighting"}
|
|
>
|
|
{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'
|
|
: '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"
|
|
title="Toggle theme"
|
|
>
|
|
<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"
|
|
title="Download file"
|
|
>
|
|
<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'
|
|
: 'bg-blue-600 hover:bg-blue-700'
|
|
}`}
|
|
>
|
|
{saveSuccess ? (
|
|
<>
|
|
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<span className="hidden sm:inline">Saved!</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="w-5 h-5 md:w-4 md:h-4" />
|
|
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
|
|
</>
|
|
)}
|
|
</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"
|
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
>
|
|
{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"
|
|
title="Close"
|
|
>
|
|
<X className="w-6 h-6 md:w-4 md:h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Editor */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<CodeMirror
|
|
ref={editorRef}
|
|
value={content}
|
|
onChange={setContent}
|
|
extensions={[
|
|
...getLanguageExtension(file.name),
|
|
...(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}
|
|
height="100%"
|
|
style={{
|
|
fontSize: '14px',
|
|
height: '100%',
|
|
}}
|
|
basicSetup={{
|
|
lineNumbers: true,
|
|
foldGutter: true,
|
|
dropCursor: false,
|
|
allowMultipleSelections: false,
|
|
indentOnInput: true,
|
|
bracketMatching: true,
|
|
closeBrackets: true,
|
|
autocompletion: true,
|
|
highlightSelectionMatches: true,
|
|
searchKeymap: true,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
|
|
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
|
<span>Lines: {content.split('\n').length}</span>
|
|
<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;
|