Changeset 0.25.0 (#160)
This commit is contained in:
341
src/js/code-editor.js
Normal file
341
src/js/code-editor.js
Normal file
@@ -0,0 +1,341 @@
|
||||
// ==========================================
|
||||
// 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 = {
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'editor';
|
||||
const instance = {
|
||||
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(), // path → { tab, editorWrap, editor, content, modified, language }
|
||||
_activeFile: null,
|
||||
_listeners: [],
|
||||
|
||||
// ── 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">' + _ceEsc(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);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
// Destroy all CM6 instances
|
||||
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;
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
CodeEditor._instances.delete(this.id);
|
||||
},
|
||||
|
||||
// ── 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);
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
CodeEditor._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
function _ceEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user