Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
474 lines
21 KiB
JavaScript
474 lines
21 KiB
JavaScript
// ==========================================
|
|
// Chat Switchboard — 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);
|
|
})();
|