/** * 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 = `
📄

No file open

Select a file from the sidebar to start editing

`; // 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 = `
📄

No file open

Select a file from the sidebar to start editing

`; } } /** * 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 => `
${this.escapeHtml(tab.name)} ${tab.dirty ? '' : ''}
`).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)'); } } }