Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
363 lines
15 KiB
JavaScript
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);
|