Files
SuperCharged-Claude-Code-Up…/public/claude-ide/components/file-editor.js
uroma b765c537fc 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>
2026-01-21 08:49:01 +00:00

664 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)');
}
}
}