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>
664 lines
20 KiB
JavaScript
664 lines
20 KiB
JavaScript
/**
|
||
* 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)');
|
||
}
|
||
}
|
||
}
|