/** * Monaco Editor Component * VS Code's editor in the browser with tab system * * Features: * - Tab-based multi-file editing * - Syntax highlighting for 100+ languages * - Auto-save on Ctrl+S * - Dirty state indicators * - Mobile responsive (CodeMirror fallback on touch devices) */ class MonacoEditor { constructor(containerId) { this.container = document.getElementById(containerId); if (!this.container) { console.error('[MonacoEditor] Container not found:', containerId); return; } this.editors = new Map(); // tabId -> editor instance this.models = new Map(); // tabId -> model instance this.tabs = []; this.activeTab = null; this.monaco = null; this.isMobile = this.detectMobile(); this.initialized = false; } detectMobile() { // Check for actual mobile device (not just touch-enabled laptop) const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); // Also check screen width as additional heuristic const isSmallScreen = window.innerWidth < 768; return isMobile || isSmallScreen; } async initialize() { if (this.initialized) return; if (this.isMobile) { // Use CodeMirror for mobile (touch-friendly) console.log('[MonacoEditor] Mobile detected, using fallback'); this.initializeFallback(); return; } try { // Wrap AMD loader in promise await new Promise((resolve, reject) => { // Configure Monaco loader require.config({ paths: { 'vs': 'https://unpkg.com/monaco-editor@0.45.0/min/vs' } }); // Load Monaco require(['vs/editor/editor.main'], (monaco) => { this.monaco = monaco; this.setupContainer(); this.setupKeyboardShortcuts(); this.loadPersistedTabs(); this.initialized = true; console.log('[MonacoEditor] Initialized successfully'); resolve(); }, (error) => { console.error('[MonacoEditor] AMD loader error:', error); reject(error); }); }); } catch (error) { console.error('[MonacoEditor] Failed to initialize:', error); this.initializeFallback(); this.initialized = true; } } setupContainer() { this.container.innerHTML = `
📄

No file open

Select a file from the sidebar to start editing

Files are automatically editable

Ln 1, Col 1 Plain Text No file
`; // Event listeners const saveCurrentBtn = this.container.querySelector('#btn-save-current'); if (saveCurrentBtn) { saveCurrentBtn.addEventListener('click', () => this.saveCurrentFile()); } const saveAllBtn = this.container.querySelector('#btn-save-all'); if (saveAllBtn) { saveAllBtn.addEventListener('click', () => this.saveAllFiles()); } const closeAllBtn = this.container.querySelector('#btn-close-all'); if (closeAllBtn) { closeAllBtn.addEventListener('click', () => this.closeAllTabs()); } } setupKeyboardShortcuts() { // Ctrl+S to save document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); this.saveCurrentFile(); } // Ctrl+W to close tab if ((e.ctrlKey || e.metaKey) && e.key === 'w') { e.preventDefault(); this.closeCurrentTab(); } }); } getLanguageFromFile(filePath) { const ext = filePath.split('.').pop().toLowerCase(); const languageMap = { 'js': 'javascript', 'jsx': 'javascript', 'ts': 'typescript', 'tsx': 'typescript', 'py': 'python', 'html': 'html', 'htm': 'html', 'css': 'css', 'scss': 'scss', 'sass': 'scss', 'json': 'json', 'md': 'markdown', 'markdown': 'markdown', 'xml': 'xml', 'yaml': 'yaml', 'yml': 'yaml', 'sql': 'sql', 'sh': 'shell', 'bash': 'shell', 'zsh': 'shell', 'txt': 'plaintext' }; return languageMap[ext] || 'plaintext'; } async openFile(filePath, content) { if (!this.initialized && !this.isMobile) { await this.initialize(); } if (this.isMobile) { this.openFileFallback(filePath, content); return; } // Check if already open const existingTab = this.tabs.find(tab => tab.path === filePath); if (existingTab) { this.activateTab(existingTab.id); return; } // Create new tab const tabId = `tab-${Date.now()}`; const tab = { id: tabId, path: filePath, name: filePath.split('/').pop(), dirty: false, originalContent: content || '' }; this.tabs.push(tab); // Create Monaco model const language = this.getLanguageFromFile(filePath); const model = this.monaco.editor.createModel(content || '', language, monaco.Uri.parse(filePath)); this.models.set(tabId, model); // Create editor instance const contentArea = this.container.querySelector('#editor-content'); // Remove placeholder const placeholder = contentArea.querySelector('.editor-placeholder'); if (placeholder) placeholder.remove(); // Create editor container const editorContainer = document.createElement('div'); editorContainer.className = 'monaco-editor-instance'; editorContainer.style.display = 'none'; contentArea.appendChild(editorContainer); // Create editor const editor = this.monaco.editor.create(editorContainer, { model: model, theme: 'vs-dark', automaticLayout: true, fontSize: 14, fontFamily: "'Fira Code', 'JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas', monaco", lineNumbers: 'on', minimap: { enabled: true }, scrollBeyondLastLine: false, wordWrap: 'off', tabSize: 4, renderWhitespace: 'selection', cursorStyle: 'line', folding: true, bracketPairColorization: { enabled: true }, guides: { indentation: true, bracketPairs: true } }); // Track cursor position editor.onDidChangeCursorPosition((e) => { this.updateCursorPosition(e.position); }); // Track content changes model.onDidChangeContent(() => { this.markDirty(tabId); }); this.editors.set(tabId, editor); // Activate the new tab this.activateTab(tabId); // Persist tabs this.saveTabsToStorage(); return tabId; } activateTab(tabId) { if (!this.editors.has(tabId)) { console.error('[MonacoEditor] Tab not found:', tabId); return; } // Hide all editors this.editors.forEach((editor, id) => { const container = editor.getDomNode(); if (container) { container.style.display = id === tabId ? 'block' : 'none'; } }); this.activeTab = tabId; this.renderTabs(); this.updateStatusbar(tabId); // Show save button for current file and editable indicator const tab = this.tabs.find(t => t.id === tabId); const saveCurrentBtn = this.container.querySelector('#btn-save-current'); const editableIndicator = this.container.querySelector('#statusbar-editable'); if (saveCurrentBtn) { saveCurrentBtn.style.display = 'inline-flex'; saveCurrentBtn.title = `Save ${tab?.name || 'file'} (Ctrl+S)`; } if (editableIndicator) { editableIndicator.style.display = 'inline-flex'; editableIndicator.textContent = tab?.dirty ? '● Unsaved changes' : '✓ Editable'; editableIndicator.style.color = tab?.dirty ? '#f48771' : '#4ec9b0'; } // Focus the active editor and ensure it's not read-only const editor = this.editors.get(tabId); if (editor) { editor.focus(); editor.updateOptions({ readOnly: false }); } } 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) { this.saveFile(tabId); } } // Dispose editor and model const editor = this.editors.get(tabId); if (editor) { editor.dispose(); this.editors.delete(tabId); } const model = this.models.get(tabId); if (model) { model.dispose(); this.models.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(); this.saveTabsToStorage(); } closeCurrentTab() { if (this.activeTab) { this.closeTab(this.activeTab); } } 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) { this.saveAllFiles(); } } // Dispose all editors and models this.editors.forEach(editor => editor.dispose()); this.models.forEach(model => model.dispose()); this.editors.clear(); this.models.clear(); this.tabs = []; this.activeTab = null; this.renderTabs(); this.showPlaceholder(); this.saveTabsToStorage(); } async saveFile(tabId) { const tab = this.tabs.find(t => t.id === tabId); if (!tab) return; const model = this.models.get(tabId); if (!model) return; const content = model.getValue(); 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('[MonacoEditor] Error saving file:', error); if (typeof showToast === 'function') { showToast(`❌ Failed to save ${tab.name}: ${error.message}`, 'error', 3000); } return false; } } async saveCurrentFile() { if (this.activeTab) { await this.saveFile(this.activeTab); } } 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); } } } markDirty(tabId) { const tab = this.tabs.find(t => t.id === tabId); if (tab && !tab.dirty) { tab.dirty = true; this.renderTabs(); } } updateCursorPosition(position) { const cursorEl = this.container.querySelector('#statusbar-cursor'); if (cursorEl && position) { cursorEl.textContent = `Ln ${position.lineNumber}, Col ${position.column}`; } } updateStatusbar(tabId) { const tab = this.tabs.find(t => t.id === tabId); if (!tab) return; const fileEl = this.container.querySelector('#statusbar-file'); const langEl = this.container.querySelector('#statusbar-language'); if (fileEl) { fileEl.textContent = tab.path; } if (langEl) { const language = this.getLanguageFromFile(tab.path); langEl.textContent = language.charAt(0).toUpperCase() + language.slice(1); } } 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); }); } }); } showPlaceholder() { const contentArea = this.container.querySelector('#editor-content'); if (contentArea) { contentArea.innerHTML = `
📄

No file open

Select a file from the sidebar to start editing

`; } } saveTabsToStorage() { const tabsData = this.tabs.map(tab => ({ path: tab.path, name: tab.name, dirty: tab.dirty, active: tab.id === this.activeTab })); try { sessionStorage.setItem('monaco-tabs', JSON.stringify(tabsData)); } catch (e) { console.error('[MonacoEditor] Failed to save tabs:', e); } } loadPersistedTabs() { try { const saved = sessionStorage.getItem('monaco-tabs'); if (saved) { const tabsData = JSON.parse(saved); console.log('[MonacoEditor] Restoring tabs:', tabsData); // Note: Files will need to be reloaded from server // This just restores the tab list structure } } catch (e) { console.error('[MonacoEditor] Failed to load tabs:', e); } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Fallback for mobile devices initializeFallback() { this.setupContainer(); this.isMobile = true; this.initialized = true; // Add message about mobile limitation const contentArea = this.container.querySelector('#editor-content'); if (contentArea) { contentArea.innerHTML = `
📱

Mobile View

Full code editing coming soon to mobile!

For now, please use a desktop or tablet device.

`; } } openFileFallback(filePath, content) { // Mobile fallback - show read-only content const contentArea = this.container.querySelector('#editor-content'); if (contentArea) { const language = this.getLanguageFromFile(filePath); contentArea.innerHTML = `

${this.escapeHtml(filePath)}

${language}
${this.escapeHtml(content || '')}
`; } } destroy() { // Dispose all editors and models this.editors.forEach(editor => editor.dispose()); this.models.forEach(model => model.dispose()); this.editors.clear(); this.models.clear(); this.tabs = []; this.activeTab = null; } } // Global instance let monacoEditor = null; // Initialize when DOM is ready async function initMonacoEditor() { monacoEditor = new MonacoEditor('file-editor'); await monacoEditor.initialize(); return monacoEditor; } // Export to window if (typeof window !== 'undefined') { window.MonacoEditor = MonacoEditor; // Auto-initialize async function autoInit() { try { const editor = await initMonacoEditor(); window.monacoEditor = editor; console.log('[MonacoEditor] Auto-initialization complete'); } catch (error) { console.error('[MonacoEditor] Auto-initialization failed:', error); window.monacoEditor = null; } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => autoInit()); } else { autoInit(); } } // Export for use in other scripts if (typeof module !== 'undefined' && module.exports) { module.exports = { MonacoEditor }; }