This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/code-editor.js
Jeffrey Smith 071dea8904 Changeset 0.31.0 (#203)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-19 00:06:16 +00:00

363 lines
15 KiB
JavaScript

// ==========================================
// 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();
//
// Exports: window.CodeEditor
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 =
'<span class="code-editor-tab-icon">' + _ceFileIcon(fileName) + '</span>' +
'<span class="code-editor-tab-name">' + esc(fileName) + '</span>' +
'<span class="code-editor-tab-modified" style="display:none">●</span>' +
'<button class="code-editor-tab-close" title="Close">✕</button>';
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;
},
// v0.31.0: Self-mount — creates DOM + binds in one call.
mount(container, opts) {
const pfx = opts.id || 'ce';
const el = document.createElement('div');
el.className = 'code-editor';
el.id = pfx + 'CodeEditor';
el.innerHTML =
'<div class="code-editor-tabs" id="' + pfx + 'EditorTabs"></div>' +
'<div class="code-editor-content" id="' + pfx + 'EditorContent">' +
'<div class="code-editor-welcome" id="' + pfx + 'EditorWelcome">' +
'<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">' +
'<div style="text-align:center;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>' +
'<p style="margin:0;font-size:14px;">Open a file to start editing</p>' +
'<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="code-editor-statusbar" id="' + pfx + 'EditorStatus">' +
'<span id="' + pfx + 'EditorStatusFile"></span>' +
'<span id="' + pfx + 'EditorStatusLang"></span>' +
'<span id="' + pfx + 'EditorStatusBranch"></span>' +
'</div>';
container.appendChild(el);
const instance = this.create(opts);
instance.bind();
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';
}
// ── Exports ─────────────────────────────────
sb.ns('CodeEditor', CodeEditor);