feat: Implement CodeMirror 6 file editor with tab support

Implement Phase 1 of the file editor & chat UI redesign:
- CodeMirror 6 integration with syntax highlighting
- Multi-file tab support with dirty state tracking
- Custom dark theme matching GitHub's color scheme
- Keyboard shortcuts (Ctrl+S to save, Ctrl+W to close tab)
- Mobile-responsive design with proper touch targets
- Fallback to basic textarea if CodeMirror fails to load

Technical details:
- Import map for ESM modules from node_modules
- Language support for JS, Python, HTML, CSS, JSON, Markdown
- Auto-initialization on DOM ready
- Global window.fileEditor instance for integration
- Serve node_modules at /claude/node_modules for import map

Files added:
- public/claude-ide/components/file-editor.js (main component)
- public/claude-ide/components/file-editor.css (responsive styles)

Files modified:
- public/claude-ide/index.html (import map, script tags)
- public/claude-ide/ide.js (updated loadFile function)
- server.js (serve node_modules for CodeMirror)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
uroma
2026-01-21 08:49:01 +00:00
Unverified
parent 9e445bf653
commit b765c537fc
5 changed files with 1185 additions and 49 deletions

View File

@@ -0,0 +1,663 @@
/**
* File Editor with CodeMirror 6
* Supports: Multi-file tabs, syntax highlighting, dirty state tracking
*/
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, keymap, highlightSpecialChars, drawSelection, dropCursor, lineNumbers, rectangularSelection, crosshairCursor, highlightActiveLine, highlightSelectionMatches, EditorView as cmEditorView } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { searchKeymap, highlightSelectionMatches as searchHighlightMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { bracketMatching, codeFolding, foldGutter, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
import { tags } from '@lezer/highlight';
// Language support
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';
// Custom dark theme matching GitHub's dark theme
const customDarkTheme = cmEditorView.theme({
'&': {
backgroundColor: '#0d1117',
color: '#c9d1d9',
fontSize: '14px',
fontFamily: "'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monospace"
},
'.cm-scroller': {
fontFamily: 'inherit',
overflow: 'auto'
},
'.cm-content': {
padding: '12px 0',
minHeight: '100%'
},
'.cm-line': {
padding: '0 12px'
},
'.cm-gutters': {
backgroundColor: '#0d1117',
color: '#484f58',
border: 'none'
},
'.cm-activeLineGutter': {
backgroundColor: 'transparent',
color: '#c9d1d9'
},
'.cm-activeLine': {
backgroundColor: '#161b22'
},
'.cm-focused': {
outline: 'none'
},
'.cm-selectionBackground': {
backgroundColor: '#264f78'
},
'&.cm-focused .cm-selectionBackground': {
backgroundColor: '#264f78'
},
'.cm-selectionMatch': {
backgroundColor: '#264f7855'
},
'.cm-cursor': {
borderLeftColor: '#58a6ff'
},
'.cm-foldPlaceholder': {
backgroundColor: 'transparent',
border: 'none',
color: '#484f58'
},
'.cm-tooltip': {
border: '1px solid #30363d',
backgroundColor: '#161b22',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.5)'
},
'.cm-tooltip-autocomplete': {
'& > ul': {
maxHeight: '200px',
fontFamily: 'inherit',
'& > li': {
padding: '4px 8px',
'&[aria-selected]': {
backgroundColor: '#1f6feb',
color: '#ffffff'
}
}
}
}
}, {
dark: true
});
// State compartments for dynamic reconfiguration
const languageCompartment = new Compartment();
const tabSizeCompartment = new Compartment();
/**
* Get language extension based on file extension
*/
function getLanguageExtension(filePath) {
const ext = filePath.split('.').pop().toLowerCase();
const languageMap = {
'js': javascript(),
'jsx': javascript({ jsx: true }),
'ts': javascript({ typescript: true }),
'tsx': javascript({ typescript: true, jsx: true }),
'mjs': javascript(),
'cjs': javascript(),
'py': python(),
'html': html(),
'htm': html(),
'css': css(),
'scss': css(),
'sass': css(),
'json': json(),
'md': markdown(),
'markdown': markdown(),
'txt': null
};
return languageMap[ext] || javascript();
}
/**
* Create CodeMirror editor state
*/
function createEditorState(filePath, content, onChange) {
const language = getLanguageExtension(filePath);
return EditorState.create({
doc: content || '',
extensions: [
lineNumbers(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
codeFolding(),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
{
key: 'Mod-s',
run: () => {
// Trigger save - will be handled by the component
const event = new CustomEvent('editor-save', { bubbles: true });
document.dispatchEvent(event);
return true;
}
},
{
key: 'Mod-w',
run: (view) => {
// Trigger close tab - will be handled by the component
const event = new CustomEvent('editor-close-tab', { bubbles: true });
document.dispatchEvent(event);
return true;
}
}
]),
customDarkTheme,
languageCompartment.of(language || []),
tabSizeCompartment.of(EditorState.tabSize.of(4)),
cmEditorView.updateListener.of((update) => {
if (update.docChanged) {
// Notify that content changed
if (onChange) onChange();
}
})
]
});
}
/**
* FileEditor Class
*/
class FileEditor {
constructor(container) {
this.container = container;
this.editors = new Map(); // tabId -> EditorView
this.activeTab = null;
this.tabs = [];
this.nextTabId = 1;
this.initialize();
}
initialize() {
// Create editor container structure
this.container.innerHTML = `
<div class="file-editor-container">
<div class="editor-header">
<div class="editor-tabs" id="editor-tabs">
<!-- Tabs will be rendered here -->
</div>
<div class="editor-actions">
<button class="btn-icon" id="btn-save-all" title="Save All (Ctrl+S)">💾</button>
<button class="btn-icon" id="btn-close-all" title="Close All">✕</button>
</div>
</div>
<div class="editor-content" id="editor-content">
<div class="editor-placeholder">
<div class="placeholder-icon">📄</div>
<h2>No file open</h2>
<p>Select a file from the sidebar to start editing</p>
</div>
</div>
</div>
`;
// Set up event listeners
this.setupEventListeners();
}
setupEventListeners() {
// Save all button
const saveAllBtn = this.container.querySelector('#btn-save-all');
if (saveAllBtn) {
saveAllBtn.addEventListener('click', () => this.saveAllFiles());
}
// Close all button
const closeAllBtn = this.container.querySelector('#btn-close-all');
if (closeAllBtn) {
closeAllBtn.addEventListener('click', () => this.closeAllTabs());
}
// Keyboard shortcuts
document.addEventListener('editor-save', () => this.saveCurrentFile());
document.addEventListener('editor-close-tab', () => this.closeCurrentTab());
// Handle window resize
window.addEventListener('resize', () => this.refreshActiveEditor());
}
/**
* Open a file in the editor
*/
async openFile(filePath, content) {
// Check if file is already open
const existingTab = this.tabs.find(tab => tab.path === filePath);
if (existingTab) {
this.activateTab(existingTab.id);
return;
}
// Create new tab
const tabId = `tab-${this.nextTabId++}`;
const tab = {
id: tabId,
path: filePath,
name: filePath.split('/').pop(),
dirty: false,
originalContent: content || ''
};
this.tabs.push(tab);
// Create CodeMirror editor instance
const editorContainer = document.createElement('div');
editorContainer.className = 'cm-editor-instance';
editorContainer.style.display = 'none';
const contentArea = this.container.querySelector('#editor-content');
// Remove placeholder if it exists
const placeholder = contentArea.querySelector('.editor-placeholder');
if (placeholder) {
placeholder.remove();
}
contentArea.appendChild(editorContainer);
const state = createEditorState(filePath, content || '', () => {
this.markDirty(tabId);
});
const editor = new EditorView({
state: state,
parent: editorContainer
});
this.editors.set(tabId, editor);
// Activate the new tab
this.activateTab(tabId);
return tabId;
}
/**
* Activate a tab
*/
activateTab(tabId) {
if (!this.editors.has(tabId)) {
console.error('[FileEditor] Tab not found:', tabId);
return;
}
// Hide all editors
this.editors.forEach((editor, id) => {
const container = editor.dom.parentElement;
if (container) {
container.style.display = id === tabId ? 'block' : 'none';
}
});
this.activeTab = tabId;
this.renderTabs();
// Refresh the active editor to ensure proper rendering
setTimeout(() => this.refreshActiveEditor(), 10);
}
/**
* Close a tab
*/
async closeTab(tabId) {
const tab = this.tabs.find(t => t.id === tabId);
if (!tab) return;
// Check for unsaved changes
if (tab.dirty) {
const shouldSave = confirm(`Save changes to ${tab.name} before closing?`);
if (shouldSave) {
await this.saveFile(tabId);
}
}
// Destroy editor
const editor = this.editors.get(tabId);
if (editor) {
editor.destroy();
this.editors.delete(tabId);
}
// Remove tab from list
this.tabs = this.tabs.filter(t => t.id !== tabId);
// If we closed the active tab, activate another one
if (this.activeTab === tabId) {
if (this.tabs.length > 0) {
this.activateTab(this.tabs[0].id);
} else {
this.activeTab = null;
this.showPlaceholder();
}
}
this.renderTabs();
}
/**
* Close current tab
*/
closeCurrentTab() {
if (this.activeTab) {
this.closeTab(this.activeTab);
}
}
/**
* Close all tabs
*/
async closeAllTabs() {
if (this.tabs.length === 0) return;
const hasUnsaved = this.tabs.some(t => t.dirty);
if (hasUnsaved) {
const shouldSaveAll = confirm('Some files have unsaved changes. Save all before closing?');
if (shouldSaveAll) {
await this.saveAllFiles();
}
}
// Destroy all editors
this.editors.forEach(editor => editor.destroy());
this.editors.clear();
this.tabs = [];
this.activeTab = null;
this.renderTabs();
this.showPlaceholder();
}
/**
* Save a file
*/
async saveFile(tabId) {
const tab = this.tabs.find(t => t.id === tabId);
if (!tab) return;
const editor = this.editors.get(tabId);
if (!editor) return;
const content = editor.state.doc.toString();
try {
const response = await fetch(`/claude/api/file/${encodeURIComponent(tab.path)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
// Update tab state
tab.dirty = false;
tab.originalContent = content;
this.renderTabs();
// Show success toast
if (typeof showToast === 'function') {
showToast(`✅ Saved ${tab.name}`, 'success', 2000);
}
return true;
} catch (error) {
console.error('[FileEditor] Error saving file:', error);
if (typeof showToast === 'function') {
showToast(`❌ Failed to save ${tab.name}: ${error.message}`, 'error', 3000);
}
return false;
}
}
/**
* Save current file
*/
async saveCurrentFile() {
if (this.activeTab) {
await this.saveFile(this.activeTab);
}
}
/**
* Save all files
*/
async saveAllFiles() {
const dirtyTabs = this.tabs.filter(t => t.dirty);
if (dirtyTabs.length === 0) {
if (typeof showToast === 'function') {
showToast('No unsaved changes', 'info', 2000);
}
return;
}
let saved = 0;
let failed = 0;
for (const tab of dirtyTabs) {
const result = await this.saveFile(tab.id);
if (result) {
saved++;
} else {
failed++;
}
}
if (typeof showToast === 'function') {
if (failed === 0) {
showToast(`✅ Saved ${saved} file${saved > 1 ? 's' : ''}`, 'success', 2000);
} else {
showToast(`⚠️ Saved ${saved} file${saved > 1 ? 's' : ''}, ${failed} failed`, 'warning', 3000);
}
}
}
/**
* Mark tab as dirty (unsaved changes)
*/
markDirty(tabId) {
const tab = this.tabs.find(t => t.id === tabId);
if (tab && !tab.dirty) {
tab.dirty = true;
this.renderTabs();
}
}
/**
* Check if any tab is dirty
*/
hasUnsavedChanges() {
return this.tabs.some(t => t.dirty);
}
/**
* Get current file content
*/
getCurrentContent() {
if (!this.activeTab) return null;
const editor = this.editors.get(this.activeTab);
return editor ? editor.state.doc.toString() : null;
}
/**
* Get current file path
*/
getCurrentFilePath() {
if (!this.activeTab) return null;
const tab = this.tabs.find(t => t.id === this.activeTab);
return tab ? tab.path : null;
}
/**
* Refresh active editor
*/
refreshActiveEditor() {
if (!this.activeTab) return;
const editor = this.editors.get(this.activeTab);
if (editor) {
editor.requestMeasure();
}
}
/**
* Show placeholder when no files are open
*/
showPlaceholder() {
const contentArea = this.container.querySelector('#editor-content');
if (contentArea) {
contentArea.innerHTML = `
<div class="editor-placeholder">
<div class="placeholder-icon">📄</div>
<h2>No file open</h2>
<p>Select a file from the sidebar to start editing</p>
</div>
`;
}
}
/**
* Render tabs
*/
renderTabs() {
const tabsContainer = this.container.querySelector('#editor-tabs');
if (!tabsContainer) return;
if (this.tabs.length === 0) {
tabsContainer.innerHTML = '';
return;
}
tabsContainer.innerHTML = this.tabs.map(tab => `
<div class="editor-tab ${tab.id === this.activeTab ? 'active' : ''} ${tab.dirty ? 'dirty' : ''}"
data-tab-id="${tab.id}">
<span class="tab-name">${this.escapeHtml(tab.name)}</span>
${tab.dirty ? '<span class="tab-dirty-indicator">●</span>' : ''}
<button class="tab-close" title="Close tab">×</button>
</div>
`).join('');
// Tab click handlers
tabsContainer.querySelectorAll('.editor-tab').forEach(tabEl => {
tabEl.addEventListener('click', (e) => {
if (!e.target.classList.contains('tab-close')) {
this.activateTab(tabEl.dataset.tabId);
}
});
const closeBtn = tabEl.querySelector('.tab-close');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.closeTab(tabEl.dataset.tabId);
});
}
});
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Get tab count
*/
getTabCount() {
return this.tabs.length;
}
/**
* Get dirty tab count
*/
getDirtyTabCount() {
return this.tabs.filter(t => t.dirty).length;
}
/**
* Destroy editor and cleanup
*/
destroy() {
// Destroy all editors
this.editors.forEach(editor => editor.destroy());
this.editors.clear();
this.tabs = [];
this.activeTab = null;
}
}
// Export for use in other scripts
if (typeof module !== 'undefined' && module.exports) {
module.exports = { FileEditor };
}
// Export to window for use from non-module scripts
if (typeof window !== 'undefined') {
window.FileEditor = FileEditor;
console.log('[FileEditor] Component loaded and available globally');
}
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
// Create global file editor instance
if (!window.fileEditor) {
window.fileEditor = new FileEditor(document.getElementById('file-editor'));
console.log('[FileEditor] Auto-initialized on DOMContentLoaded');
}
});
} else {
// DOM is already ready
if (!window.fileEditor) {
window.fileEditor = new FileEditor(document.getElementById('file-editor'));
console.log('[FileEditor] Auto-initialized (DOM already ready)');
}
}
}