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/packages/editor/js/main.js
Jeffrey Smith c470037fb5
Some checks failed
CI/CD / detect-changes (push) Successful in 23s
CI/CD / test-sqlite (push) Failing after 20s
CI/CD / test-frontend (push) Failing after 21s
CI/CD / test-go-pg (push) Failing after 38s
CI/CD / build-and-deploy (push) Has been skipped
rebrand + purge orphaned frontend: Chat Switchboard → Switchboard Core
Branding: bulk rename across 50+ files (Go, JS, CSS, HTML, YAML, JSON,
shell scripts). Fix Helm chart description and git repo URLs.
Fix test helper taglines.

Admin surface: strip 14 gutted tabs (providers, models, personas,
roles, knowledge, memory, tasks, health, routing, capabilities,
channels, dashboard, usage, stats). Collapse to 4 categories:
People, Workflows, System, Monitoring.

Settings surface: strip 11 gutted tabs (models, personas, providers,
roles, knowledge, memory, usage, workflows, tasks, data, gitkeys).
Keep: general, appearance, profile, teams, connections, notifications.

Team-admin surface: strip 5 gutted tabs (personas, providers,
knowledge, tasks, usage). Keep: members, groups, connections,
workflows, settings, activity.

Components: delete chat-pane/ (13 files, 1938 lines) and
notes-pane/ (10 files, 2381 lines).

main.go: remove stale Health.Prune maintenance goroutine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:28:09 +00:00

474 lines
21 KiB
JavaScript

// ==========================================
// Switchboard Core — Editor Package (v0.31.0)
// ==========================================
// Installable .pkg that provides the code editor surface.
// Mounts into #extension-mount (surface-extension template).
//
// Uses Component.mount() for all sub-components — single source
// of truth for DOM structure. No duplicated template partials.
//
// Dependencies (loaded by base.html):
// FileTree, CodeEditor, ChatPane, NotePanel, note-graph, PaneContainer
// API, UI, App, sw, esc (ui-primitives)
//
// Dynamically loaded:
// codemirror.bundle.js
// ==========================================
(function () {
'use strict';
const SURFACE_ID = 'editor';
const PREFIX = 'ed';
const NOTES_PREFIX = 'edNotes';
const STATE_DEBOUNCE_MS = 2000;
if (window.__SURFACE__ !== SURFACE_ID) return;
const base = window.__BASE__ || '';
// ── Dynamic Script Loader ────────────────────
function _loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector('script[src*="' + src.split('?')[0] + '"]')) {
resolve();
return;
}
const s = document.createElement('script');
s.src = base + src;
s.type = 'module';
s.onload = resolve;
s.onerror = () => { resolve(); }; // Non-fatal
document.head.appendChild(s);
});
}
// ── Init ─────────────────────────────────────
async function _init() {
const mount = document.getElementById('extension-mount');
if (!mount) return;
// Hide server-rendered user menu (we mount our own in the topbar)
const serverMenu = document.getElementById('userMenuWrap');
if (serverMenu) serverMenu.style.display = 'none';
// Wrap in surface container for CSS scoping
const surface = document.createElement('div');
surface.className = 'surface-editor';
surface.id = 'editorSurface';
mount.appendChild(surface);
// Read workspace ID from query param
const params = new URL(window.location.href).searchParams;
const wsId = params.get('ws') || '';
// Load workspace name
let wsName = 'Editor';
if (wsId && typeof API !== 'undefined') {
try {
const ws = await API._get('/api/v1/workspaces/' + wsId);
wsName = ws?.name || ws?.data?.name || wsName;
} catch (_) {}
}
// Build topbar
const topbar = _buildTopbar(wsName);
surface.appendChild(topbar);
// Mount user menu via SDK (flyout drops down from topbar)
if (typeof sw !== 'undefined' && sw.userMenu) {
sw.userMenu(topbar, { flyout: 'down' });
}
// Build body + bootstrap
const body = document.createElement('div');
body.className = 'editor-body';
body.id = 'editorBody';
surface.appendChild(body);
const bootstrap = _buildBootstrap();
surface.appendChild(bootstrap);
// Load dynamic dependencies (codemirror only — ChatPane, NotePanel, note-graph are platform scripts in base.html)
const ver = window.__VERSION__ || '';
const verQ = ver ? '?v=' + ver : '';
await _loadScript('/vendor/codemirror/codemirror.bundle.js' + verQ);
_initWsSelector(wsId);
if (!wsId) {
body.style.display = 'none';
bootstrap.style.display = '';
_loadBootstrapList();
_initBootstrapCreate();
return;
}
_mountEditor(wsId, wsName);
}
// ── Topbar ──────────────────────────────────
function _buildTopbar(wsName) {
const el = document.createElement('div');
el.className = 'editor-topbar';
el.id = 'editorTopbar';
el.innerHTML =
'<a href="' + esc(base) + '/" class="editor-topbar-back" title="Back to chat">' +
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
'Back' +
'</a>' +
'<div class="editor-topbar-sep"></div>' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' +
'<div class="editor-ws-selector" id="editorWsSelector">' +
'<button class="editor-ws-selector-btn" id="editorWsSelectorBtn">' +
'<span id="editorWorkspaceName">' + esc(wsName || 'Editor') + '</span>' +
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
'</button>' +
'<div class="editor-ws-dropdown" id="editorWsDropdown">' +
'<div id="editorWsList" class="editor-ws-list"></div>' +
'<div class="editor-ws-dropdown-divider"></div>' +
'<button class="editor-ws-dropdown-item editor-ws-new" id="editorWsNewBtn">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
'New Workspace' +
'</button>' +
'</div>' +
'</div>' +
'<div class="editor-topbar-branch" id="editorBranchBadge" style="display:none;">' +
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>' +
'<span id="editorBranchName" class="editor-topbar-branch-text">main</span>' +
'</div>' +
'<div style="flex:1;"></div>' +
'<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>' +
'</button>';
return el;
}
// ── Bootstrap (no workspace) ────────────────
function _buildBootstrap() {
const el = document.createElement('div');
el.className = 'editor-bootstrap';
el.id = 'editorBootstrap';
el.style.display = 'none';
el.innerHTML =
'<div class="editor-bootstrap-card">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">' +
'<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>' +
'</svg>' +
'<h3 style="margin:0 0 16px;font-size:16px;">Open a Workspace</h3>' +
'<div id="editorBootstrapList" style="margin-bottom:16px;">' +
'<div style="font-size:12px;color:var(--text-3);">Loading workspaces\u2026</div>' +
'</div>' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">' +
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
'<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>' +
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
'</div>' +
'<input type="text" id="editorBootstrapName" class="editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
'<button id="editorBootstrapBtn" class="editor-bootstrap-btn">Create Workspace</button>' +
'</div>';
return el;
}
// ── Workspace Selector ──────────────────────
function _initWsSelector(currentWsId) {
const btn = document.getElementById('editorWsSelectorBtn');
const dropdown = document.getElementById('editorWsDropdown');
if (!btn || !dropdown) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (dropdown.classList.toggle('open')) _loadWsDropdown(currentWsId);
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
});
document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
dropdown.classList.remove('open');
const name = window.prompt('Workspace name:');
if (!name) return;
try {
const userId = sw.user?.id;
if (!userId) throw new Error('Not authenticated');
const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
const newId = resp.id || resp.data?.id;
if (newId) window.location.href = base + '/s/editor?ws=' + newId;
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
});
}
async function _loadWsDropdown(currentWsId) {
const listEl = document.getElementById('editorWsList');
if (!listEl) return;
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Loading\u2026</div>';
try {
const resp = await API._get('/api/v1/workspaces');
const workspaces = resp.data || resp || [];
listEl.innerHTML = '';
if (!workspaces.length) {
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">No workspaces</div>';
return;
}
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
item.textContent = ws.name || ws.id?.slice(0, 8);
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
listEl.appendChild(item);
});
} catch (_) {
listEl.innerHTML = '<div style="padding:6px 12px;font-size:11px;color:var(--text-3)">Failed to load</div>';
}
}
// ── Bootstrap ────────────────────────────────
async function _loadBootstrapList() {
const listEl = document.getElementById('editorBootstrapList');
if (!listEl) return;
try {
const resp = await API._get('/api/v1/workspaces');
const workspaces = resp.data || resp || [];
if (!workspaces.length) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">No workspaces yet</div>';
return;
}
listEl.innerHTML = '';
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'editor-bootstrap-ws-item';
item.innerHTML =
'<span class="editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
listEl.appendChild(item);
});
} catch (_) {
listEl.innerHTML = '<div style="font-size:12px;color:var(--text-3);">Failed to load workspaces</div>';
}
}
function _initBootstrapCreate() {
const btn = document.getElementById('editorBootstrapBtn');
const input = document.getElementById('editorBootstrapName');
if (!btn || !input) return;
btn.addEventListener('click', async () => {
const name = input.value.trim() || 'workspace';
btn.disabled = true;
btn.textContent = 'Creating\u2026';
try {
const userId = sw.user?.id;
if (!userId) throw new Error('Not authenticated');
const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
const newId = resp.id || resp.data?.id;
if (!newId) throw new Error('No workspace ID returned');
window.location.href = base + '/s/editor?ws=' + newId;
} catch (e) {
btn.disabled = false;
btn.textContent = 'Create Workspace';
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
});
}
// ── Mount Pane Layout ───────────────────────
function _mountEditor(wsId, wsName) {
const body = document.getElementById('editorBody');
if (!body) return;
// ── Layout via SDK ──
const layout = sw.layout(body, 'editor', { workspaceId: wsId });
if (!layout) return;
const filesPaneEl = layout._panes.get('files')?.el;
const editorPaneEl = layout._panes.get('editor')?.el;
const assistPaneInfo = layout._panes.get('assist');
// ── FileTree via SDK ──
let fileTree;
if (filesPaneEl) {
fileTree = sw.fileTree(filesPaneEl, {
id: PREFIX, workspaceId: wsId,
onSelect: (path) => { codeEditor?.openFile(path); _saveState(wsId, codeEditor); },
onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
onNewFile: () => _createNewFile(wsId, fileTree),
});
layout._panes.get('files').component = fileTree;
}
// ── CodeEditor via SDK ──
let codeEditor;
if (editorPaneEl) {
codeEditor = sw.codeEditor(editorPaneEl, {
id: PREFIX, workspaceId: wsId,
onSave: () => fileTree?.refresh(),
onActivate: (path) => { fileTree?.setActiveFile(path); _saveState(wsId, codeEditor); },
});
layout._panes.get('editor').component = codeEditor;
}
// ── Assist pane (tabbed: chat + notes) via SDK ──
if (assistPaneInfo?.tabs) {
// Chat tab — standalone mode handles everything (streaming, model selector, history)
const chatPanel = assistPaneInfo.getTabPanel('chat');
if (chatPanel) {
const chatPane = sw.chat(chatPanel, {
id: PREFIX,
standalone: true,
getContext: () => _getFileContext(codeEditor),
});
if (chatPane) {
const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
if (chatTab) chatTab.instance = chatPane;
}
}
// Notes tab
const notesPanel = assistPaneInfo.getTabPanel('notes');
if (notesPanel) {
const notePanel = sw.notes(notesPanel, { projectId: null });
if (notePanel) {
const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
if (notesTab) {
notesTab.instance = notePanel;
const _loadNotes = () => {
if (!notesTab._loaded) {
notesTab._loaded = true;
notePanel.loadNotesList();
notePanel.loadNoteFolders();
}
};
notesTab.btn.addEventListener('click', _loadNotes);
if (notesTab._activated) _loadNotes();
}
}
}
}
// Git branch
_refreshGitBranch(wsId, codeEditor);
// Toolbar
document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
fileTree?.refresh();
_refreshGitBranch(wsId, codeEditor);
});
// Initial load
fileTree?.refresh();
_restoreState(wsId, codeEditor);
console.log('[EditorPkg] Mounted for workspace', wsId);
}
// ── File Operations ─────────────────────────
async function _deleteFile(wsId, path, fileTree, codeEditor) {
const ok = typeof showConfirm === 'function'
? await showConfirm('Delete ' + path + '?')
: window.confirm('Delete ' + path + '?');
if (!ok) return;
try {
await API.deleteWorkspaceFile(wsId, path);
if (codeEditor?.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
fileTree?.refresh();
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
}
}
async function _createNewFile(wsId, fileTree) {
const name = window.prompt('File name (e.g. src/main.go):');
if (!name) return;
try {
await API.writeWorkspaceFile(wsId, name.trim(), '');
fileTree?.refresh();
if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
} catch (e) {
if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
}
}
async function _refreshGitBranch(wsId, codeEditor) {
try {
const resp = await API.getWorkspaceGitBranches(wsId);
const branch = resp.current || null;
if (branch) {
const badge = document.getElementById('editorBranchBadge');
const name = document.getElementById('editorBranchName');
if (badge) badge.style.display = '';
if (name) name.textContent = branch;
}
if (codeEditor) codeEditor.setBranch(branch);
} catch (_) {}
}
// ── File Context (editor-specific, passed to ChatPane via getContext) ──
function _getFileContext(editor) {
if (!editor) return null;
try {
const openFiles = editor.getOpenFiles();
if (!openFiles.length) return null;
const activeTab = document.querySelector('.code-editor-tab.active');
const path = activeTab?.dataset?.path || openFiles[0];
const inst = CodeEditor._instances?.get(PREFIX);
if (inst?._files) {
const file = inst._files.get(path);
if (file?.view) return { path, content: file.view.state.doc.toString().slice(0, 4000) };
}
} catch (_) {}
return null;
}
// ── State Persistence (localStorage) ─────────
const STATE_KEY_PREFIX = 'sb:editor:state:';
let _stateSaveTimer = null;
function _stateKey(wsId) {
return STATE_KEY_PREFIX + (sw?.user?.id || '') + ':' + wsId;
}
function _restoreState(wsId, codeEditor) {
if (!wsId) return;
try {
const raw = localStorage.getItem(_stateKey(wsId));
if (!raw) return;
const state = JSON.parse(raw);
if (state.open_tabs && Array.isArray(state.open_tabs)) {
for (const path of state.open_tabs) codeEditor.openFile(path);
if (state.active_tab) codeEditor.activateFile(state.active_tab);
}
} catch (_) {}
}
function _saveState(wsId, codeEditor) {
if (_stateSaveTimer) clearTimeout(_stateSaveTimer);
_stateSaveTimer = setTimeout(() => {
if (!wsId) return;
try {
const openFiles = codeEditor.getOpenFiles();
const activeTab = document.querySelector('.code-editor-tab.active');
localStorage.setItem(_stateKey(wsId), JSON.stringify({
open_tabs: openFiles,
active_tab: activeTab?.dataset?.path || '',
updated_at: new Date().toISOString(),
}));
} catch (_) {}
}, STATE_DEBOUNCE_MS);
}
// ── Boot ─────────────────────────────────────
document.addEventListener('DOMContentLoaded', _init);
})();