Changeset 0.37.13 (#225)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-22 12:52:00 +00:00
committed by xcaliber
parent aeda4fdd0c
commit fcb998bff9
13 changed files with 165 additions and 2360 deletions

View File

@@ -1,139 +0,0 @@
// ==========================================
// Chat Switchboard Shared Application State
// ==========================================
// Loaded by base.html for ALL surfaces. Contains the App state
// object and universal data-loading functions (models, settings).
// Surface-specific logic lives in app.js (chat), admin-scaffold.js, etc.
//
// Exports: window.App, window.fetchModels
// ==========================================
const App = {
chats: [],
models: [],
hiddenModels: new Set(),
isGenerating: false,
abortController: null,
serverSettings: {},
policies: {},
stagedFiles: [],
channelFiles: {},
storageConfigured: false,
projects: [],
collapsedProjects: {},
activeProjectId: null,
showArchivedProjects: false,
// v0.23.2: Unified active conversation
activeConversation: null, // { id, type } where type = direct|dm|group|channel
// v0.23.1: Multi-user
channels: [], // DMs + named channels (sidebar display data)
folders: [], // chat folders (user-scoped)
collapsedFolders: {},
presence: {}, // { userId: 'online' | 'offline' }
users: [], // v0.23.2: [{id, username, displayName}] for @mention autocomplete
activeParticipants: [], // v0.24.0: user participants of active channel (for scoped autocomplete)
/** Get active conversation ID (shorthand). */
get activeId() { return this.activeConversation?.id || null; },
/** Get active conversation type. */
get activeType() { return this.activeConversation?.type || null; },
/** Set the active conversation. Clears if id is null. */
setActive(id, type) {
this.activeConversation = id ? { id, type: type || 'direct' } : null;
this.activeParticipants = [];
},
/** Find the active conversation's chat object (with cached messages). */
getActiveChat() {
const id = this.activeConversation?.id;
if (!id) return null;
return this.chats.find(c => c.id === id) || null;
},
findModel(id) {
if (!id) return null;
let m = App.models.find(m => m.id === id);
if (m) return m;
return App.models.find(m => m.baseModelId === id) || null;
},
settings: {
model: '',
stream: true,
showThinking: false,
systemPrompt: '',
maxTokens: 0,
temperature: 0.7,
},
};
// ── Capability Resolution ──────────────────
// Backend is source of truth. Frontend passes through as-is.
function resolveCapabilities(backendCaps, modelId) {
return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
}
// ── Model Loading ──────────────────────────
async function fetchModels() {
try {
const data = await API.listEnabledModels();
App.defaultModel = data.default_model || '';
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.data || prefData.preferences || []).filter(p => p.hidden).map(p =>
(p.provider_config_id || '') + ':' + p.model_id
)
);
} catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set();
}
App.models = (data.data || data.models || []).map(m => {
const isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id || '';
const id = isPersona
? (m.persona_id || m.id)
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
return {
id,
baseModelId,
name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '',
configId: cfgId || null,
model_type: m.model_type || 'chat',
capabilities: resolveCapabilities(m.capabilities, baseModelId),
isPersona,
personaId: m.persona_id || null,
personaHandle: m.persona_handle || null,
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
source: m.source || (isPersona ? 'persona' : 'global'),
teamName: m.persona_team_name || null,
hidden: !isPersona && App.hiddenModels.has((cfgId || '') + ':' + baseModelId),
};
});
const scopeOrder = { global: 0, team: 1, personal: 2 };
App.models.sort((a, b) => {
if (a.isPersona && !b.isPersona) return -1;
if (!a.isPersona && b.isPersona) return 1;
if (a.isPersona && b.isPersona) {
const sa = scopeOrder[a.personaScope] ?? 9;
const sb = scopeOrder[b.personaScope] ?? 9;
if (sa !== sb) return sa - sb;
}
return a.name.localeCompare(b.name);
});
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPersona).length} personas, ${App.models.filter(m => m.hidden).length} hidden)`);
} catch (e) { console.warn('Model fetch failed:', e.message); }
}
sb.ns('App', App);
sb.register('fetchModels', fetchModels);

View File

@@ -1,362 +0,0 @@
// ==========================================
// 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);

View File

@@ -575,6 +575,53 @@ const DebugLog = {
}
};
// ── Inlined from ui-primitives.js (Scorched Earth III, v0.37.13) ──
function _escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function openModal(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.add('active');
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
}
function showConfirm(message, opts = {}) {
return new Promise(resolve => {
const title = opts.title || 'Confirm';
const okText = opts.ok || 'Confirm';
const caText = opts.cancel || 'Cancel';
const danger = opts.danger !== false;
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
overlay.innerHTML = `
<div class="confirm-dialog">
<div class="confirm-header">${_escHtml(title)}</div>
<div class="confirm-body">${_escHtml(message)}</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">${_escHtml(caText)}</button>
<button class="btn-small ${danger ? 'btn-danger' : 'btn-primary'}" data-action="ok">${_escHtml(okText)}</button>
</div>
</div>`;
function close(result) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(result); }
function onKey(e) { if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); }
overlay.querySelector('[data-action="ok"]').addEventListener('click', () => close(true));
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
overlay.querySelector('[data-action="cancel"]').focus();
});
}
// ── Modal Controls ──────────────────────────
function openDebugModal() {

View File

@@ -1,290 +0,0 @@
// ==========================================
// Chat Switchboard — FileTree Component
// ==========================================
// v0.25.0: Extracted from editor-mode.js (lines 336-535).
// Workspace file browser with directory expand/collapse, git status
// badges, context menu, and file icon mapping.
//
// Usage:
// const tree = FileTree.create({
// id: 'editor',
// workspaceId: 'ws-123',
// onSelect: (path) => codeEditor.openFile(path),
// onDelete: (path) => { ... },
// onNewFile: () => { ... },
// });
// await tree.refresh();
// tree.setActiveFile('src/main.go');
// tree.destroy();
//
// Exports: window.FileTree
const FileTree = {
...createComponentRegistry('FileTree'),
create(opts) {
const pfx = opts.id || 'editor';
const instance = componentMixin({
id: pfx,
itemsEl: document.getElementById(pfx + 'TreeItems'),
newFileEl: document.getElementById(pfx + 'TreeNewFile'),
workspaceId: opts.workspaceId || null,
onSelect: opts.onSelect || null,
onDelete: opts.onDelete || null,
onNewFile: opts.onNewFile || null,
_treeData: [],
_expandedDirs: new Set(['']),
_gitStatusMap: new Map(),
_activeFile: null,
// ── Public API ──────────────────────
setWorkspace(wsId) { this.workspaceId = wsId; },
async refresh() {
if (!this.workspaceId) return;
try {
const resp = await API.listWorkspaceFiles(this.workspaceId, '', true);
this._treeData = resp.files || resp.data || resp || [];
this._render();
} catch (e) {
console.error('[FileTree] Failed to load:', e);
if (this.itemsEl) this.itemsEl.innerHTML = '<div class="file-tree-error">Failed to load files</div>';
}
// Non-blocking git status refresh
this._refreshGitStatus();
},
setActiveFile(path) {
this._activeFile = path;
if (!this.itemsEl) return;
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
row.classList.toggle('active', row.dataset.path === path);
});
},
getActiveFile() { return this._activeFile; },
// ── Git Status ──────────────────────
async _refreshGitStatus() {
if (!this.workspaceId) return;
this._gitStatusMap.clear();
try {
const resp = await API.getWorkspaceGitStatus(this.workspaceId);
const files = resp.files || [];
for (const f of files) {
this._gitStatusMap.set(f.path, f.status || '?');
}
// Apply to rendered rows
if (this.itemsEl) {
this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => {
const p = row.dataset.path;
const st = this._gitStatusMap.get(p);
row.classList.remove('git-modified', 'git-added', 'git-untracked', 'git-deleted');
if (st === 'M') row.classList.add('git-modified');
else if (st === 'A') row.classList.add('git-added');
else if (st === '?') row.classList.add('git-untracked');
else if (st === 'D') row.classList.add('git-deleted');
});
}
} catch (_) {
// Git not configured — ignore
}
},
// ── Tree Rendering ──────────────────
_render() {
if (!this.itemsEl) return;
this.itemsEl.innerHTML = '';
if (!this._treeData.length) {
this.itemsEl.innerHTML = '<div class="file-tree-empty">No files</div>';
return;
}
const tree = this._buildStructure(this._treeData);
this._renderNodes(tree, this.itemsEl, 0);
},
_buildStructure(files) {
const root = { name: '', path: '', isDir: true, children: [], file: null };
const dirs = new Map();
dirs.set('', root);
const sorted = [...files].sort((a, b) => {
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
return a.path.localeCompare(b.path);
});
for (const f of sorted) {
const parts = f.path.split('/');
const name = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join('/');
let parent = dirs.get(parentPath);
if (!parent) {
let accumulated = '';
for (let i = 0; i < parts.length - 1; i++) {
const prev = accumulated;
accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i];
if (!dirs.has(accumulated)) {
const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null };
dirs.set(accumulated, dirNode);
const p = dirs.get(prev);
if (p) p.children.push(dirNode);
}
}
parent = dirs.get(parentPath);
}
if (f.is_directory) {
let existing = dirs.get(f.path);
if (!existing) {
existing = { name, path: f.path, isDir: true, children: [], file: f };
dirs.set(f.path, existing);
if (parent) parent.children.push(existing);
} else {
existing.file = f;
}
} else {
const node = { name, path: f.path, isDir: false, children: [], file: f };
if (parent) parent.children.push(node);
}
}
return root.children;
},
_renderNodes(nodes, container, depth) {
const self = this;
for (const node of nodes) {
const row = document.createElement('div');
row.className = 'file-tree-row' + (node.path === this._activeFile ? ' active' : '');
row.style.paddingLeft = (12 + depth * 16) + 'px';
row.dataset.path = node.path;
if (node.isDir) {
const expanded = this._expandedDirs.has(node.path);
row.innerHTML =
'<span class="file-tree-arrow ' + (expanded ? 'expanded' : '') + '">' + (expanded ? '▾' : '▸') + '</span>' +
'<span class="file-tree-icon">📁</span>' +
'<span class="file-tree-name">' + esc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self._expandedDirs.has(node.path)) {
self._expandedDirs.delete(node.path);
} else {
self._expandedDirs.add(node.path);
}
self._render();
});
container.appendChild(row);
if (expanded && node.children.length) {
this._renderNodes(node.children, container, depth + 1);
}
} else {
row.innerHTML =
'<span class="file-tree-icon">' + _ftFileIcon(node.name) + '</span>' +
'<span class="file-tree-name">' + esc(node.name) + '</span>';
row.addEventListener('click', () => {
if (self.onSelect) self.onSelect(node.path);
});
row.addEventListener('contextmenu', (e) => {
e.preventDefault();
self._showContextMenu(e, node);
});
container.appendChild(row);
}
}
},
// ── Context Menu ────────────────────
_showContextMenu(e, node) {
const existing = document.querySelector('.file-tree-ctx-menu');
if (existing) existing.remove();
const menu = document.createElement('div');
menu.className = 'file-tree-ctx-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
const items = [
{ label: 'Open', action: () => { if (this.onSelect) this.onSelect(node.path); } },
{ label: 'Delete', action: () => { if (this.onDelete) this.onDelete(node.path); } },
];
for (const item of items) {
const row = document.createElement('div');
row.className = 'file-tree-ctx-item';
row.textContent = item.label;
row.addEventListener('click', () => { menu.remove(); item.action(); });
menu.appendChild(row);
}
document.body.appendChild(menu);
const dismiss = (ev) => {
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); }
};
setTimeout(() => document.addEventListener('click', dismiss), 0);
},
// ── Event Wiring ────────────────────
bind() {
this._on(this.newFileEl, 'click', () => {
if (this.onNewFile) this.onNewFile();
});
},
_cleanup() {
this._treeData = [];
this._expandedDirs = new Set(['']);
this._gitStatusMap.clear();
},
}, FileTree);
FileTree._register(pfx, instance);
return instance;
},
// v0.31.0: Self-mount — creates DOM + binds in one call.
mount(container, opts) {
const pfx = opts.id || 'ft';
const el = document.createElement('div');
el.className = 'file-tree';
el.id = pfx + 'FileTree';
el.innerHTML =
'<div class="file-tree-header" id="' + pfx + 'TreeHeader">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><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>' +
'<span class="file-tree-title">Files</span>' +
'<button class="icon-btn" id="' + pfx + 'TreeNewFile" title="New file">' +
'<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>' +
'</button>' +
'</div>' +
'<div class="file-tree-items" id="' + pfx + 'TreeItems"></div>';
container.appendChild(el);
const instance = this.create(opts);
instance.bind();
return instance;
},
};
// ── Helpers ─────────────────────────────────
function _ftFileIcon(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] || '📄';
}
// ── Exports ─────────────────────────────────
sb.ns('FileTree', FileTree);

View File

@@ -3,6 +3,9 @@
// ==========================================
// v0.28.5: Composition layer over platform globals. Surface and
// extension authors consume this instead of hunting through 15 JS files.
// v0.37.13: Scorched Earth III — removed dead wrappers (FileTree,
// CodeEditor, UserMenu, NotePanel, PaneContainer, PaneLayout),
// inlined Theme via Preact SDK module, inlined esc/modal helpers.
//
// Usage:
// const sw = Switchboard.init();
@@ -10,12 +13,12 @@
// sw.on('chat.message.*', (payload) => { ... });
// sw.pipe.render(50, (ctx) => { ... });
//
// Load order: after ui-core.js + pane-container.js, before extensions.js.
// Load order: after sb.js + events.js, before extensions.js.
//
// Exports: window.Switchboard, window.sw (convenience alias)
// ==========================================
'use strict';
import { createTheme } from './sw/sdk/theme.js';
const Switchboard = {
@@ -190,45 +193,10 @@ const Switchboard = {
Events.emit(label, payload, opts);
};
// ── Theme ────────────────────────────────────────────
// ── Theme (v0.37.13: uses Preact SDK module directly) ──
sw.theme = {
/** Resolved theme: 'dark' or 'light' (never 'system'). */
get current() {
if (typeof Theme !== 'undefined') return Theme.resolved();
return 'dark';
},
/** User preference: 'dark', 'light', or 'system'. */
get mode() {
if (typeof Theme !== 'undefined') return Theme.get();
return 'system';
},
/** Set theme mode. */
set(mode) {
if (typeof Theme !== 'undefined') Theme.set(mode);
},
/**
* Subscribe to theme changes.
* @param {string} event — 'change'
* @param {Function} fn — receives resolved theme string
* @returns {Function} unsubscribe
*/
on(event, fn) {
if (event === 'change') {
return Events.on('theme.changed', (payload) => {
const resolved = typeof Theme !== 'undefined'
? Theme.resolved()
: 'dark';
fn(resolved);
});
}
console.warn(`[Switchboard] theme.on: unknown event '${event}'`);
return () => {};
},
};
const _theme = createTheme((label, payload, opts) => Events.emit(label, payload, opts));
sw.theme = _theme;
// ── UI Primitives ────────────────────────────────────
@@ -239,16 +207,19 @@ const Switchboard = {
};
sw.confirm = function (message, opts) {
// v0.37.13: delegate to debug.js's showConfirm if loaded, else native
if (typeof showConfirm === 'function') return showConfirm(message, opts);
return Promise.resolve(window.confirm(message));
};
sw.modal = {
open(contentOrId) {
if (typeof openModal === 'function') openModal(contentOrId);
open(id) {
const el = document.getElementById(id);
if (el) el.classList.add('active');
},
close(id) {
if (typeof closeModal === 'function') closeModal(id);
const el = document.getElementById(id);
if (el) el.classList.remove('active');
},
};
@@ -284,7 +255,8 @@ const Switchboard = {
}
const btn = document.createElement('button');
btn.className = 'flyout-item' + (item.danger ? ' flyout-danger' : '');
btn.innerHTML = (item.icon || '') + '<span>' + (typeof esc === 'function' ? esc(item.label) : item.label) + '</span>';
const d = document.createElement('div'); d.textContent = item.label;
btn.innerHTML = (item.icon || '') + '<span>' + d.innerHTML + '</span>';
btn.addEventListener('click', () => {
result.close();
if (item.onClick) item.onClick();
@@ -470,87 +442,8 @@ const Switchboard = {
return ChatPane.mount(container, opts || {});
};
/**
* Mount a NotePanel instance into a container element.
* Builds DOM + creates + wires events in one call.
*
* @param {HTMLElement} container — mount target
* @param {object} opts — { projectId, onOpenGraph }
* @returns {object} NotePanel instance (loadNotesList, openNoteEditor, destroy, etc.)
*/
sw.notes = function (container, opts) {
// v0.37.9: Prefer new Preact NotesPane via sw.notesPane() when SDK is loaded
if (window.sw?.notesPane) {
return window.sw.notesPane(container, opts || {});
}
console.warn('[Switchboard] sw.notes() — use sw.notesPane() instead (v0.37.9+)');
return null;
};
/**
* Access the PanelRegistry through the SDK.
* Returns a thin wrapper with open/close/toggle/isOpen/active/cycle/register.
*
* @returns {object|null} Panel control object
*/
sw.panels = function () {
if (typeof PanelRegistry === 'undefined') {
console.warn('[Switchboard] PanelRegistry not available');
return null;
}
return {
open(name) { PanelRegistry.open(name); },
close(name) { PanelRegistry.close(name); },
toggle(name) { PanelRegistry.toggle(name); },
isOpen(name) { return PanelRegistry.isOpen(name); },
active() { return PanelRegistry.active(); },
cycle() { PanelRegistry.cycle(); },
register(name, opts) { PanelRegistry.register(name, opts); },
};
};
/**
* Mount a FileTree into a container.
* @param {HTMLElement} container — mount target
* @param {object} opts — { id, workspaceId, onSelect, onDelete, onNewFile }
* @returns {object|null} FileTree instance
*/
sw.fileTree = function (container, opts) {
if (typeof FileTree === 'undefined') {
console.error('[Switchboard] FileTree not available');
return null;
}
return FileTree.mount(container, opts || {});
};
/**
* Mount a CodeEditor into a container.
* @param {HTMLElement} container — mount target
* @param {object} opts — { id, workspaceId, onSave, onActivate }
* @returns {object|null} CodeEditor instance
*/
sw.codeEditor = function (container, opts) {
if (typeof CodeEditor === 'undefined') {
console.error('[Switchboard] CodeEditor not available');
return null;
}
return CodeEditor.mount(container, opts || {});
};
/**
* Mount a PaneContainer layout into a container.
* @param {HTMLElement} container — mount target
* @param {string} layoutId — layout preset name (e.g. 'editor')
* @param {object} opts — { workspaceId }
* @returns {object|null} PaneContainer layout instance
*/
sw.layout = function (container, layoutId, opts) {
if (typeof PaneContainer === 'undefined') {
console.error('[Switchboard] PaneContainer not available');
return null;
}
return PaneContainer.mount(container, layoutId, opts || {});
};
// v0.37.13: sw.notes, sw.panels, sw.fileTree, sw.codeEditor,
// sw.layout removed (Scorched Earth III — globals deleted).
// ── Workflow ─────────────────────────────────────────
// v0.30.2: Workflow stage surface management.
@@ -780,77 +673,15 @@ const Switchboard = {
_runRender(ctx) { return _runChain('render', ctx); },
};
// ── UserMenu ─────────────────────────────────────────
const _defaultMenuHandlers = {
onSettings: () => { window.location.href = (window.__BASE__ || '') + '/settings'; },
onAdmin: () => { window.location.href = (window.__BASE__ || '') + '/admin'; },
onDebug: () => { sb.call('openDebugModal'); },
onSignout: () => { sb.call('handleLogout'); },
};
/**
* Mount a UserMenu into a container.
* @param {HTMLElement} container — mount target
* @param {object} [opts] — { flyout: 'down'|'up', compact, user, handlers }
* @returns {object} UserMenu instance
*/
sw.userMenu = function (container, opts) {
if (typeof UserMenu === 'undefined') {
console.error('[Switchboard] UserMenu not available');
return null;
}
const _opts = Object.assign({
user: window.__USER__ || null,
handlers: _defaultMenuHandlers,
_positionFn: _positionFlyout,
}, opts || {});
const instance = UserMenu.mount(container, _opts);
if (!UserMenu.primary) UserMenu.primary = instance;
return instance;
};
/**
* Hydrate server-rendered UserMenu (non-chat surfaces).
* Falls back to mount() if server-rendered DOM not found.
*/
function _hydrateUserMenu() {
if (typeof UserMenu === 'undefined') return;
if (UserMenu.primary) return;
const surface = window.__SURFACE__ || '';
if (surface === 'chat') return;
// Extension surfaces: package owns its own menu via sw.userMenu().
// Hide the server-rendered placeholder so the package can mount fresh.
const isExtension = document.querySelector('.extension-surface') !== null;
if (isExtension) {
const placeholder = document.getElementById('userMenuWrap');
if (placeholder) placeholder.style.display = 'none';
return;
}
// Non-extension surfaces (admin, settings, notes): hydrate server-rendered DOM
const btn = document.getElementById('userMenuBtn');
if (btn) {
const menu = UserMenu.create({ id: '' });
UserMenu.primary = menu;
const user = window.__USER__ || {};
menu.setUser(user);
menu.showAdmin(user.role === 'admin');
menu.bind(_defaultMenuHandlers);
}
}
// v0.37.13: sw.userMenu + _hydrateUserMenu removed
// (Scorched Earth III — all surfaces use Preact UserMenu).
// ── Perform Init ─────────────────────────────────────
// Theme + appearance (safe on all surfaces)
if (typeof Theme !== 'undefined') Theme.init();
// Theme (v0.37.13: uses imported Preact SDK module)
_theme.init();
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
// UserMenu hydration
_hydrateUserMenu();
// Emit ready event
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));

File diff suppressed because it is too large Load Diff

View File

@@ -1,206 +0,0 @@
// ==========================================
// Chat Switchboard — UserMenu Component
// ==========================================
// v0.25.0: Extracted from chat.html + ui-core.js.
// Reusable across all authenticated surfaces.
// Pattern: Go template partial (user-menu.html) + JS factory (this file).
//
// Usage:
// const menu = UserMenu.create({ id: 'global' });
// menu.setUser({ display_name: 'Jeff', username: 'jeff', avatar: null });
// menu.showAdmin(true);
// menu.destroy();
//
// Exports: window.UserMenu
const UserMenu = {
...createComponentRegistry('UserMenu'),
create(opts) {
const pfx = opts.id || '';
const instance = componentMixin({
id: pfx,
btnEl: document.getElementById(pfx + 'userMenuBtn'),
avatarEl: document.getElementById(pfx + 'userAvatar'),
letterEl: document.getElementById(pfx + 'avatarLetter'),
nameEl: document.getElementById(pfx + 'userName'),
flyoutEl: document.getElementById(pfx + 'userFlyout'),
settingsEl: document.getElementById(pfx + 'menuSettings'),
adminEl: document.getElementById(pfx + 'menuAdmin'),
teamAdminEl: document.getElementById(pfx + 'menuTeamAdmin'),
debugEl: document.getElementById(pfx + 'menuDebug'),
signoutEl: document.getElementById(pfx + 'menuSignout'),
_open: false,
// ── User display ────────────────────
setUser(user) {
if (!user) return;
const name = user.display_name || user.username || 'User';
const letter = (name[0] || '?').toUpperCase();
// Avatar image or initial letter
if (this.avatarEl) {
let img = this.avatarEl.querySelector('.user-avatar-img');
if (user.avatar) {
if (this.letterEl) this.letterEl.style.display = 'none';
if (img) {
img.src = user.avatar;
} else {
img = document.createElement('img');
img.src = user.avatar;
img.alt = '';
img.className = 'user-avatar-img';
img.onerror = function() {
this.remove();
if (instance.letterEl) {
instance.letterEl.style.display = '';
instance.letterEl.textContent = letter;
}
};
this.avatarEl.insertBefore(img, this.letterEl);
}
} else {
if (img) img.remove();
if (this.letterEl) {
this.letterEl.style.display = '';
this.letterEl.textContent = letter;
}
}
}
if (this.nameEl) this.nameEl.textContent = name;
},
// ── Visibility controls ─────────────
showAdmin(show) {
if (this.adminEl) this.adminEl.style.display = show ? '' : 'none';
if (this.debugEl) this.debugEl.style.display = show ? '' : 'none';
},
showTeamAdmin(show) {
if (this.teamAdminEl) this.teamAdminEl.style.display = show ? '' : 'none';
},
// ── Flyout toggle ───────────────────
open() {
if (this.flyoutEl) this.flyoutEl.classList.add('open');
this._open = true;
},
close() {
if (this.flyoutEl) this.flyoutEl.classList.remove('open');
this._open = false;
},
toggle() {
if (this._open) this.close(); else this.open();
},
// ── Event wiring ────────────────────
/**
* Wire event handlers. Accepts callbacks for menu actions.
* Call once after create().
*/
bind(handlers) {
handlers = handlers || {};
// Toggle flyout on avatar click
this._on(this.btnEl, 'click', (e) => {
e.stopPropagation();
this.toggle();
});
// Close on outside click
this._on(document, 'click', (e) => {
if (this._open && !e.target.closest('#' + pfx + 'userMenuWrap')) {
this.close();
}
});
// Menu items
this._on(this.settingsEl, 'click', () => { this.close(); if (handlers.onSettings) handlers.onSettings(); });
this._on(this.adminEl, 'click', () => { this.close(); if (handlers.onAdmin) handlers.onAdmin(); });
this._on(this.teamAdminEl, 'click', () => { this.close(); if (handlers.onTeamAdmin) handlers.onTeamAdmin(); });
this._on(this.debugEl, 'click', () => { this.close(); if (handlers.onDebug) handlers.onDebug(); });
this._on(this.signoutEl, 'click', () => { this.close(); if (handlers.onSignout) handlers.onSignout(); });
},
}, UserMenu);
UserMenu._register(pfx, instance);
return instance;
},
};
// Self-mount: creates DOM + create + bind in one call.
// opts.flyout: 'down' (default) or 'up' (sidebar-style)
// opts.compact: hide name label
// opts.user: user object for setUser()
// opts.handlers: { onSettings, onAdmin, onDebug, onSignout }
UserMenu.mount = function (container, opts) {
const _opts = opts || {};
const pfx = _opts.id || 'um' + Date.now().toString(36).slice(-4);
const flyout = _opts.flyout || 'down';
const el = document.createElement('div');
el.className = 'user-menu-wrap';
el.id = pfx + 'userMenuWrap';
el.dataset.flyout = flyout;
el.innerHTML =
'<button id="' + pfx + 'userMenuBtn" class="user-btn">' +
'<div id="' + pfx + 'userAvatar" class="user-avatar">' +
'<span id="' + pfx + 'avatarLetter">?</span>' +
'</div>' +
'<span id="' + pfx + 'userName" class="sb-label"' + (_opts.compact ? ' style="display:none"' : '') + '>User</span>' +
'</button>' +
'<div id="' + pfx + 'userFlyout" class="sw-menu-flyout" data-position="' + flyout + '">' +
'<button id="' + pfx + 'menuSettings" class="flyout-item">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.32 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' +
'<span>Settings</span>' +
'</button>' +
'<button id="' + pfx + 'menuAdmin" class="flyout-item" style="display:none;">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' +
'<span>Admin</span>' +
'</button>' +
'<button id="' + pfx + 'menuTeamAdmin" class="flyout-item" style="display:none;">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' +
'<span>Team Admin</span>' +
'</button>' +
'<button id="' + pfx + 'menuDebug" class="flyout-item" style="display:none;">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/><path d="M12 6V12L16 14"/></svg>' +
'<span>Debug Log</span>' +
'</button>' +
'<hr class="flyout-divider">' +
'<button id="' + pfx + 'menuSignout" class="flyout-item flyout-danger">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>' +
'<span>Sign Out</span>' +
'</button>' +
'</div>';
container.appendChild(el);
const instance = this.create({ id: pfx });
// Wire fixed-positioning for flyouts inside overflow-clipped containers
if (_opts._positionFn) {
const _origOpen = instance.open.bind(instance);
instance.open = function () {
_opts._positionFn(instance.flyoutEl, instance.btnEl, flyout);
_origOpen();
};
}
instance.bind(_opts.handlers || {});
if (_opts.user) {
instance.setUser(_opts.user);
instance.showAdmin(_opts.user.role === 'admin');
}
return instance;
};
// ── Exports ─────────────────────────────────
sb.ns('UserMenu', UserMenu);