// ========================================== // Chat Switchboard — CodeEditor Component // ========================================== // v0.25.0: Extracted from editor-mode.js (lines 536-940). // Tabbed code editor with CM6 integration, auto-save, language detection, // modified indicators, and status bar. // // Usage: // const editor = CodeEditor.create({ // id: 'editor', // workspaceId: 'ws-123', // onSave: (path, content) => fileTree.refresh(), // onActivate: (path) => fileTree.setActiveFile(path), // }); // await editor.openFile('src/main.go'); // editor.getActiveFile(); // → 'src/main.go' // editor.isModified('src/main.go'); // → false // await editor.saveAll(); // editor.destroy(); const CodeEditor = { ...createComponentRegistry('CodeEditor'), create(opts) { const pfx = opts.id || 'editor'; const instance = componentMixin({ id: pfx, tabsEl: document.getElementById(pfx + 'EditorTabs'), contentEl: document.getElementById(pfx + 'EditorContent'), welcomeEl: document.getElementById(pfx + 'EditorWelcome'), statusEl: document.getElementById(pfx + 'EditorStatus'), statusFileEl: document.getElementById(pfx + 'EditorStatusFile'), statusLangEl: document.getElementById(pfx + 'EditorStatusLang'), statusBranchEl: document.getElementById(pfx + 'EditorStatusBranch'), workspaceId: opts.workspaceId || null, onSave: opts.onSave || null, onActivate: opts.onActivate || null, _openFiles: new Map(), _activeFile: null, // ── Public API ────────────────────── setWorkspace(wsId) { this.workspaceId = wsId; }, async openFile(path, content, language) { if (this._openFiles.has(path)) { this.activateFile(path); return; } // Fetch if content not provided if (content === undefined) { try { content = await API.readWorkspaceFile(this.workspaceId, path); } catch (e) { console.error('[CodeEditor] Failed to read:', e); if (typeof UI !== 'undefined') UI.toast('Failed to open ' + path, 'error'); return; } } const lang = language || _ceDetectLanguage(path); // Create tab const tab = document.createElement('div'); tab.className = 'code-editor-tab'; tab.dataset.path = path; const fileName = path.split('/').pop(); tab.innerHTML = '' + _ceFileIcon(fileName) + '' + '' + esc(fileName) + '' + '' + ''; const self = this; tab.addEventListener('click', (e) => { if (!e.target.closest('.code-editor-tab-close')) self.activateFile(path); }); tab.querySelector('.code-editor-tab-close').addEventListener('click', (e) => { e.stopPropagation(); self.closeFile(path); }); if (this.tabsEl) this.tabsEl.appendChild(tab); // Create editor wrapper const editorWrap = document.createElement('div'); editorWrap.className = 'code-editor-cm-wrap'; editorWrap.style.display = 'none'; if (this.contentEl) this.contentEl.appendChild(editorWrap); // Create CM6 editor or textarea fallback let editor = null; if (window.CM?.codeEditor) { editor = CM.codeEditor(editorWrap, { language: lang, value: content, lineNumbers: true, }); if (editor.onUpdate) { editor.onUpdate(() => self._markModified(path)); } } else { const ta = document.createElement('textarea'); ta.className = 'code-editor-textarea-fallback'; ta.value = content; ta.addEventListener('input', () => self._markModified(path)); editorWrap.appendChild(ta); editor = { getValue: () => ta.value, setValue: (v) => { ta.value = v; }, focus: () => ta.focus(), destroy: () => {}, _textarea: true, }; } this._openFiles.set(path, { tab, editorWrap, editor, content, modified: false, language: lang, }); this.activateFile(path); }, activateFile(path) { if (this._activeFile === path) return; // Auto-save previous if modified if (this._activeFile && this._openFiles.has(this._activeFile)) { const prev = this._openFiles.get(this._activeFile); if (prev.modified) this.saveFile(this._activeFile); prev.tab.classList.remove('active'); prev.editorWrap.style.display = 'none'; } this._activeFile = path; const f = this._openFiles.get(path); if (!f) return; f.tab.classList.add('active'); f.editorWrap.style.display = ''; if (this.welcomeEl) this.welcomeEl.style.display = 'none'; this._updateStatusBar(); if (this.onActivate) this.onActivate(path); if (f.editor?.focus) setTimeout(() => f.editor.focus(), 10); }, async closeFile(path) { const f = this._openFiles.get(path); if (!f) return; if (f.modified) { const save = await this._confirmClose(path); if (save === null) return; // cancelled if (save) await this.saveFile(path); } f.tab.remove(); f.editorWrap.remove(); if (f.editor?.destroy) f.editor.destroy(); this._openFiles.delete(path); if (this._activeFile === path) { this._activeFile = null; const remaining = Array.from(this._openFiles.keys()); if (remaining.length) { this.activateFile(remaining[remaining.length - 1]); } else { if (this.welcomeEl) this.welcomeEl.style.display = ''; this._updateStatusBar(); } } }, async saveFile(path) { const f = this._openFiles.get(path); if (!f) return; const content = f.editor?.getValue?.() ?? ''; try { await API.writeWorkspaceFile(this.workspaceId, path, content); f.content = content; f.modified = false; f.tab.querySelector('.code-editor-tab-modified').style.display = 'none'; f.tab.classList.remove('modified'); if (path === this._activeFile) this._updateStatusBar(); if (typeof UI !== 'undefined') UI.toast('Saved ' + path.split('/').pop(), 'success'); if (this.onSave) this.onSave(path, content); } catch (e) { console.error('[CodeEditor] Save failed:', e); if (typeof UI !== 'undefined') UI.toast('Failed to save: ' + e.message, 'error'); } }, async saveAll() { for (const [path, f] of this._openFiles) { if (f.modified) await this.saveFile(path); } }, getActiveFile() { return this._activeFile; }, isModified(path) { const f = this._openFiles.get(path); return f ? f.modified : false; }, hasUnsaved() { for (const [, f] of this._openFiles) { if (f.modified) return true; } return false; }, getOpenFiles() { return Array.from(this._openFiles.keys()); }, // ── Git Branch Display ────────────── setBranch(branch) { if (this.statusBranchEl) { this.statusBranchEl.textContent = branch ? '⎇ ' + branch : ''; } }, // ── Keyboard Shortcuts ────────────── bind() { this._on(document, 'keydown', (e) => { // Ctrl/Cmd+S — save active file if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (this._activeFile) this.saveFile(this._activeFile); } }); }, _cleanup() { for (const [, f] of this._openFiles) { if (f.editor?.destroy) f.editor.destroy(); f.tab.remove(); f.editorWrap.remove(); } this._openFiles.clear(); this._activeFile = null; }, // ── Internal ──────────────────────── _markModified(path) { const f = this._openFiles.get(path); if (!f || f.modified) return; const currentVal = f.editor?.getValue?.() ?? ''; if (currentVal === f.content) return; f.modified = true; f.tab.querySelector('.code-editor-tab-modified').style.display = ''; f.tab.classList.add('modified'); if (path === this._activeFile) this._updateStatusBar(); }, _updateStatusBar() { if (!this.statusFileEl) return; if (!this._activeFile) { this.statusFileEl.textContent = 'No file open'; if (this.statusLangEl) this.statusLangEl.textContent = ''; return; } const f = this._openFiles.get(this._activeFile); this.statusFileEl.textContent = this._activeFile + (f?.modified ? ' ●' : ''); if (this.statusLangEl && f) { this.statusLangEl.textContent = f.language || ''; } }, async _confirmClose(path) { const fileName = path.split('/').pop(); if (typeof showConfirm === 'function') { return showConfirm('Save changes to ' + fileName + '?', { confirmLabel: 'Save', cancelLabel: 'Discard', showCancel: true }); } return Promise.resolve(true); }, }, CodeEditor); CodeEditor._register(pfx, instance); return instance; }, }; // ── Helpers ───────────────────────────────── function _ceFileIcon(name) { const ext = name.split('.').pop()?.toLowerCase(); const icons = { js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛', go: '🔵', py: '🐍', rs: '🦀', rb: '💎', html: '🌐', css: '🎨', scss: '🎨', less: '🎨', json: '📋', yaml: '📋', yml: '📋', toml: '📋', md: '📝', txt: '📄', svg: '🖼', sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙', dockerfile: '🐳', makefile: '🔧', mod: '📦', sum: '📦', lock: '🔒', }; return icons[ext] || '📄'; } function _ceDetectLanguage(path) { const ext = path.split('.').pop()?.toLowerCase(); const map = { js: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', tsx: 'typescript', jsx: 'javascript', go: 'go', py: 'python', rs: 'rust', rb: 'ruby', html: 'html', htm: 'html', css: 'css', scss: 'css', less: 'css', json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown', mdx: 'markdown', sql: 'sql', sh: 'shell', bash: 'shell', zsh: 'shell', xml: 'xml', svg: 'xml', c: 'cpp', cpp: 'cpp', h: 'cpp', hpp: 'cpp', java: 'java', kt: 'java', php: 'php', pl: 'perl', dockerfile: 'dockerfile', }; return map[ext] || 'text'; }