Changeset 0.31.0 (#203)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
531
packages/editor/css/main.css
Normal file
531
packages/editor/css/main.css
Normal file
@@ -0,0 +1,531 @@
|
||||
/* ==========================================
|
||||
Chat Switchboard — Editor Surface (v0.25.0)
|
||||
==========================================
|
||||
Replaces editor-mode.css for the pane-based editor.
|
||||
Covers: topbar, bootstrap, and component-specific
|
||||
overrides within the editor context.
|
||||
========================================== */
|
||||
|
||||
/* ── Surface Shell ─────────────────────────── */
|
||||
|
||||
.surface-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #0e0e10);
|
||||
}
|
||||
|
||||
/* ── Topbar ────────────────────────────────── */
|
||||
|
||||
.editor-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.editor-topbar-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-3, #777);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.editor-topbar-back:hover {
|
||||
color: var(--text, #eee);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.editor-topbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.editor-topbar-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
/* ── Workspace Selector ────────────────────── */
|
||||
|
||||
.editor-ws-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-ws-selector-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.editor-ws-selector-btn:hover {
|
||||
border-color: var(--border, #2a2a2e);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.editor-ws-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 8px;
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown.open { display: block; }
|
||||
|
||||
.editor-ws-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text, #eee);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-item.active {
|
||||
color: var(--accent, #b38a4e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-ws-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border, #2a2a2e);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.editor-ws-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.editor-topbar-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-topbar-branch-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--purple, #a078ff);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
/* ── Body ──────────────────────────────────── */
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Bootstrap (no workspace) ──────────────── */
|
||||
|
||||
.editor-bootstrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor-bootstrap-card {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 12px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.editor-bootstrap-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
margin-bottom: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor-bootstrap-input:focus {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
}
|
||||
|
||||
.editor-bootstrap-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: var(--accent, #b38a4e);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.editor-bootstrap-btn:hover { opacity: 0.9; }
|
||||
.editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Workspace list in bootstrap */
|
||||
.editor-bootstrap-ws-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg, #0e0e10);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
color: var(--text, #eee);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
margin-bottom: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-item:hover {
|
||||
border-color: var(--accent, #b38a4e);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editor-bootstrap-ws-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #777);
|
||||
}
|
||||
|
||||
/* ── FileTree overrides (in editor context) ── */
|
||||
|
||||
.surface-editor .file-tree {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2, #999);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-2, #999);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-row.active {
|
||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
||||
color: var(--text, #eee);
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-arrow {
|
||||
width: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3, #555);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-icon {
|
||||
font-size: 13px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.surface-editor .file-tree-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Git status indicators */
|
||||
.surface-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning, #e5a842); }
|
||||
.surface-editor .file-tree-row.git-added .file-tree-name { color: var(--success, #4caf50); }
|
||||
.surface-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3, #555); font-style: italic; }
|
||||
.surface-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger, #f44336); text-decoration: line-through; }
|
||||
|
||||
/* Context menu */
|
||||
.file-tree-ctx-menu {
|
||||
position: fixed;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border: 1px solid var(--border, #2a2a2e);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.file-tree-ctx-item {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text, #eee);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-tree-ctx-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ── CodeEditor overrides ──────────────────── */
|
||||
|
||||
.surface-editor .code-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
height: 32px;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
|
||||
|
||||
.surface-editor .code-editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--text-3, #777);
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--border, #2a2a2e);
|
||||
transition: background 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tab:hover { background: var(--bg-hover); }
|
||||
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
|
||||
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }
|
||||
|
||||
.surface-editor .code-editor-tab-icon { font-size: 12px; }
|
||||
.surface-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
|
||||
|
||||
.surface-editor .code-editor-tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-3, #555);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
margin-left: 4px;
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-tab-close:hover {
|
||||
background: var(--danger-dim, rgba(244, 67, 54, 0.15));
|
||||
color: var(--danger, #f44336);
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-welcome {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-cm-wrap {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-cm-wrap .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 12px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary, #151517);
|
||||
border-top: 1px solid var(--border, #2a2a2e);
|
||||
font-size: 11px;
|
||||
color: var(--text-3, #777);
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
}
|
||||
|
||||
.surface-editor .code-editor-textarea-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg, #0e0e10);
|
||||
color: var(--text, #eee);
|
||||
border: none;
|
||||
padding: 12px;
|
||||
font-family: var(--mono, 'SF Mono', monospace);
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Tabbed assist pane overrides ──────────── */
|
||||
|
||||
.surface-editor .pane-tabbed {
|
||||
border-left: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
/* ChatPane in editor tabbed pane */
|
||||
.surface-editor .chat-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Notes in editor pane */
|
||||
.surface-editor .note-editor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.surface-editor .note-editor-list-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.surface-editor .notes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Compact notes toolbar for narrow pane */
|
||||
.surface-editor .notes-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
||||
}
|
||||
|
||||
.surface-editor .notes-toolbar .btn-small {
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-search-row {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-filter-row {
|
||||
padding: 2px 8px 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.surface-editor .notes-filter-select {
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
676
packages/editor/js/main.js
Normal file
676
packages/editor/js/main.js
Normal file
@@ -0,0 +1,676 @@
|
||||
// ==========================================
|
||||
// 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 || window.__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 || window.__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
|
||||
const chatPanel = assistPaneInfo.getTabPanel('chat');
|
||||
if (chatPanel) {
|
||||
const chatPane = sw.chat(chatPanel, { id: PREFIX, standalone: true });
|
||||
if (chatPane) {
|
||||
chatPane.showWelcome();
|
||||
_initAssistChat(chatPane, codeEditor);
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
// ── Assist Chat ─────────────────────────────
|
||||
|
||||
function _initAssistChat(chatPane, codeEditor) {
|
||||
const inputEl = chatPane.inputEl;
|
||||
const sendBtn = chatPane.sendBtnEl;
|
||||
const headerEl = document.getElementById(PREFIX + 'ChatHeader');
|
||||
const selectEl = document.getElementById(PREFIX + 'ChatSelect');
|
||||
const newBtnEl = document.getElementById(PREFIX + 'ChatNewBtn');
|
||||
const modelSelEl = document.getElementById(PREFIX + 'ModelSel');
|
||||
if (!inputEl) return;
|
||||
|
||||
if (headerEl) headerEl.style.display = '';
|
||||
|
||||
let _channelId = null, _messages = [], _sending = false, _abortController = null;
|
||||
let _selectedModel = App.settings?.model || '';
|
||||
|
||||
async function _initModelSelector() {
|
||||
if (!modelSelEl) return;
|
||||
if (!App.models?.length && typeof fetchModels === 'function') await fetchModels();
|
||||
const models = App.models || [];
|
||||
if (!models.length) return;
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'chat-pane-model-select';
|
||||
sel.title = 'Select model';
|
||||
models.forEach(m => {
|
||||
if (m.hidden) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.isPersona ? (m.personaId || m.id) : m.id;
|
||||
opt.textContent = (m.isPersona ? '\ud83c\udfad ' : '') + (m.name || m.id);
|
||||
if (m.id === _selectedModel || m.personaId === _selectedModel) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.addEventListener('change', () => { _selectedModel = sel.value; });
|
||||
modelSelEl.innerHTML = '';
|
||||
modelSelEl.appendChild(sel);
|
||||
}
|
||||
|
||||
async function _loadChatHistory() {
|
||||
if (!selectEl) return;
|
||||
try {
|
||||
const resp = await API.listChannels(1, 20, 'direct');
|
||||
const channels = resp.data || resp || [];
|
||||
selectEl.innerHTML = '<option value="">New conversation</option>';
|
||||
channels.forEach(ch => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = ch.id;
|
||||
opt.textContent = ch.title || 'Untitled';
|
||||
if (ch.id === _channelId) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (selectEl) selectEl.addEventListener('change', () => { selectEl.value ? _switchToChannel(selectEl.value) : _newChat(); });
|
||||
if (newBtnEl) newBtnEl.addEventListener('click', _newChat);
|
||||
|
||||
async function _switchToChannel(channelId) {
|
||||
_channelId = channelId;
|
||||
_messages = [];
|
||||
chatPane.clear();
|
||||
chatPane.appendTyping();
|
||||
try {
|
||||
const resp = await API._get('/api/v1/channels/' + channelId + '/messages?page=1&per_page=100');
|
||||
_messages = resp.data || resp || [];
|
||||
chatPane.removeTyping();
|
||||
_renderAllMessages();
|
||||
} catch (e) {
|
||||
chatPane.removeTyping();
|
||||
_appendSystemMsg('Failed to load messages: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function _newChat() {
|
||||
_channelId = null;
|
||||
_messages = [];
|
||||
chatPane.showWelcome();
|
||||
if (selectEl) selectEl.value = '';
|
||||
}
|
||||
|
||||
const ta = document.createElement('textarea');
|
||||
ta.placeholder = 'Ask about your code\u2026';
|
||||
ta.rows = 1;
|
||||
inputEl.appendChild(ta);
|
||||
ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; });
|
||||
|
||||
async function sendMessage() {
|
||||
const text = ta.value.trim();
|
||||
if (!text || _sending) return;
|
||||
_sending = true;
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
ta.value = '';
|
||||
ta.style.height = 'auto';
|
||||
|
||||
if (!_channelId) {
|
||||
try {
|
||||
const title = text.slice(0, 50) + (text.length > 50 ? '\u2026' : '');
|
||||
const resp = await API.createChannel(title, _selectedModel, '', 'direct');
|
||||
_channelId = resp.id;
|
||||
_loadChatHistory();
|
||||
} catch (e) {
|
||||
_appendSystemMsg('Failed to create chat: ' + e.message);
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_messages.push({ role: 'user', content: text });
|
||||
_renderAllMessages();
|
||||
|
||||
try {
|
||||
_abortController = new AbortController();
|
||||
const fileCtx = _getFileContext(codeEditor);
|
||||
const content = fileCtx
|
||||
? text + '\n\n[Context: Currently editing ' + fileCtx.path + ']\n```\n' + fileCtx.content + '\n```'
|
||||
: text;
|
||||
const resp = await API.streamCompletion(_channelId, content, _selectedModel, _abortController.signal);
|
||||
await _handleStream(resp, chatPane);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') _appendSystemMsg('Error: ' + e.message);
|
||||
}
|
||||
_abortController = null;
|
||||
_sending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
if (sendBtn) sendBtn.addEventListener('click', sendMessage);
|
||||
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
|
||||
|
||||
async function _handleStream(response, pane) {
|
||||
const messagesEl = pane.messagesEl;
|
||||
if (!messagesEl) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.className = 'chat-msg__content';
|
||||
div.appendChild(contentEl);
|
||||
messagesEl.appendChild(div);
|
||||
let content = '';
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
try {
|
||||
const delta = JSON.parse(data).choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
content += delta;
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
contentEl.innerHTML = DOMPurify.sanitize(marked.parse(content));
|
||||
} else {
|
||||
contentEl.textContent = content;
|
||||
}
|
||||
pane.scrollToBottom();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') content += '\n\n**[Stream error: ' + e.message + ']**';
|
||||
}
|
||||
div.classList.remove('chat-msg--streaming');
|
||||
_messages.push({ role: 'assistant', content });
|
||||
}
|
||||
|
||||
function _renderAllMessages() {
|
||||
if (!chatPane.messagesEl) return;
|
||||
chatPane.messagesEl.innerHTML = '';
|
||||
_messages.forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--' + m.role;
|
||||
const c = document.createElement('div');
|
||||
c.className = 'chat-msg__content';
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
c.innerHTML = DOMPurify.sanitize(marked.parse(m.content || ''));
|
||||
} else {
|
||||
c.textContent = m.content || '';
|
||||
}
|
||||
div.appendChild(c);
|
||||
chatPane.messagesEl.appendChild(div);
|
||||
});
|
||||
chatPane.scrollToBottom();
|
||||
}
|
||||
|
||||
function _appendSystemMsg(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--system';
|
||||
div.innerHTML = '<div class="chat-msg__content" style="color:var(--danger,#f44336);font-size:12px;">' + esc(text) + '</div>';
|
||||
chatPane.messagesEl?.appendChild(div);
|
||||
chatPane.scrollToBottom();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function _deferredInit() { _initModelSelector(); _loadChatHistory(); }
|
||||
if (App.models?.length) _deferredInit();
|
||||
else document.addEventListener('sb:ready', _deferredInit, { once: true });
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
})();
|
||||
17
packages/editor/manifest.json
Normal file
17
packages/editor/manifest.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "editor",
|
||||
"title": "Editor",
|
||||
"type": "full",
|
||||
"version": "0.31.0",
|
||||
"tier": "browser",
|
||||
"author": "Chat Switchboard",
|
||||
"description": "Code editor with workspace management, file tree, and AI assist",
|
||||
"route": "/s/editor",
|
||||
"layout": "editor",
|
||||
"permissions": [],
|
||||
"settings": [
|
||||
{ "key": "font_size", "label": "Font Size", "type": "number", "default": 13 },
|
||||
{ "key": "tab_size", "label": "Tab Size", "type": "number", "default": 4 },
|
||||
{ "key": "word_wrap", "label": "Word Wrap", "type": "boolean", "default": false }
|
||||
]
|
||||
}
|
||||
150
packages/icd-test-runner/js/crud/editor-package.js
Normal file
150
packages/icd-test-runner/js/crud/editor-package.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* ICD Test Runner — CRUD: Editor Package (v0.31.0)
|
||||
* Tests the editor .pkg lifecycle: install → surface accessible →
|
||||
* settings → export/import round-trip → core removal verification.
|
||||
* Uses T.crud.buildTestZip() from _helpers.js.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.ICD;
|
||||
if (!T) return;
|
||||
if (!T.crud) T.crud = {};
|
||||
|
||||
var buildTestZip = T.crud.buildTestZip;
|
||||
|
||||
T.crud.editorPackage = async function (testTag) {
|
||||
|
||||
if (T.user.role !== 'admin') return;
|
||||
|
||||
// ── edpkg: Editor package install + lifecycle ──
|
||||
|
||||
var editorPkgId = null;
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: POST /admin/packages/install (editor .pkg)', async function () {
|
||||
var manifest = JSON.stringify({
|
||||
id: 'editor',
|
||||
title: 'Editor',
|
||||
type: 'full',
|
||||
version: '0.31.0',
|
||||
tier: 'browser',
|
||||
author: 'Chat Switchboard',
|
||||
description: 'Code editor with workspace management',
|
||||
route: '/s/editor',
|
||||
layout: 'editor',
|
||||
permissions: [],
|
||||
settings: [
|
||||
{ key: 'font_size', label: 'Font Size', type: 'number', 'default': 13 },
|
||||
{ key: 'tab_size', label: 'Tab Size', type: 'number', 'default': 4 },
|
||||
{ key: 'word_wrap', label: 'Word Wrap', type: 'boolean', 'default': false }
|
||||
]
|
||||
});
|
||||
var zipBlob = buildTestZip({
|
||||
'manifest.json': manifest,
|
||||
'js/main.js': '// editor package entry point\nconsole.log("[EditorPkg] loaded");',
|
||||
'css/main.css': '.surface-editor { display: flex; }'
|
||||
});
|
||||
|
||||
var d = await T.apiUpload('/admin/packages/install', zipBlob, 'editor.pkg');
|
||||
T.assert(d.id === 'editor', 'package id should be "editor", got: ' + d.id);
|
||||
editorPkgId = d.id;
|
||||
|
||||
T.registerCleanup(async function () {
|
||||
if (editorPkgId) {
|
||||
var token = await T.getAuthToken();
|
||||
return T.authFetch(token, 'DELETE', '/admin/packages/' + editorPkgId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!editorPkgId) return;
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/packages/:id (verify type full)', async function () {
|
||||
var d = await T.apiGet('/admin/packages/' + editorPkgId);
|
||||
T.assertShape(d, T.S.extension, 'editor package');
|
||||
T.assert(d.type === 'full', 'type should be "full", got: ' + d.type);
|
||||
T.assert(d.tier === 'browser', 'tier should be "browser", got: ' + d.tier);
|
||||
T.assert(d.source === 'extension', 'source should be "extension", got: ' + d.source);
|
||||
T.assert(d.enabled === true, 'newly installed package should be enabled');
|
||||
T.assert(d.version === '0.31.0', 'version mismatch');
|
||||
});
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/surfaces (editor in list)', async function () {
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
T.assertHasKey(d, 'data', '/admin/surfaces');
|
||||
var found = d.data.some(function (s) { return s.id === 'editor'; });
|
||||
T.assert(found, 'editor package should appear in surfaces list');
|
||||
});
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: core /editor removed (404)', async function () {
|
||||
// The old /editor route should no longer exist — it was removed in CS1.
|
||||
// We test by checking the surfaces list for a core editor entry.
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
var surfaces = d.data || [];
|
||||
var coreEditor = surfaces.find(function (s) { return s.id === 'editor' && s.source === 'core'; });
|
||||
T.assert(!coreEditor, 'no core editor surface should exist — editor is now a package');
|
||||
});
|
||||
|
||||
// ── Settings ──
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/packages/:id/settings', async function () {
|
||||
var d = await T.apiGet('/admin/packages/' + editorPkgId + '/settings');
|
||||
// Settings endpoint returns the current settings (or defaults)
|
||||
T.assert(typeof d === 'object', 'settings should be an object');
|
||||
});
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: PUT /admin/packages/:id/settings', async function () {
|
||||
var settings = { font_size: 16, tab_size: 2, word_wrap: true };
|
||||
var d = await T.apiPut('/admin/packages/' + editorPkgId + '/settings', settings);
|
||||
T.assert(d._status === 200 || d._status === 204 || !d._status, 'settings PUT should succeed');
|
||||
|
||||
// Verify persistence
|
||||
var check = await T.apiGet('/admin/packages/' + editorPkgId + '/settings');
|
||||
T.assert(check.font_size === 16 || check.data?.font_size === 16,
|
||||
'font_size should persist as 16');
|
||||
});
|
||||
|
||||
// ── Export/Import Round-Trip ──
|
||||
|
||||
var exportBlob = null;
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: GET /admin/packages/:id/export', async function () {
|
||||
var token = await T.getAuthToken();
|
||||
var resp = await fetch(T.base + '/api/v1/admin/packages/' + editorPkgId + '/export', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
T.assert(resp.ok, 'export should return 200, got: ' + resp.status);
|
||||
exportBlob = await resp.blob();
|
||||
T.assert(exportBlob.size > 0, 'export blob should not be empty');
|
||||
});
|
||||
|
||||
if (exportBlob) {
|
||||
await T.test('crud', 'editorPackage', 'edpkg: DELETE + re-import round-trip', async function () {
|
||||
// Delete the editor package
|
||||
var token = await T.getAuthToken();
|
||||
var delResp = await T.authFetch(token, 'DELETE', '/admin/packages/' + editorPkgId);
|
||||
T.assert(!delResp._status || delResp._status < 300, 'delete should succeed');
|
||||
|
||||
// Re-import from exported blob
|
||||
var d = await T.apiUpload('/admin/packages/install', exportBlob, 'editor.pkg');
|
||||
T.assert(d.id === 'editor', 'reimported package id should be "editor"');
|
||||
|
||||
// Verify it's back
|
||||
var check = await T.apiGet('/admin/packages/' + editorPkgId);
|
||||
T.assert(check.id === 'editor', 'reimported editor should be readable');
|
||||
T.assert(check.type === 'full', 'reimported type should be "full"');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Extension Nav ──
|
||||
|
||||
await T.test('crud', 'editorPackage', 'edpkg: editor in extension nav items', async function () {
|
||||
// When installed, editor should appear in the surfaces list as an extension surface
|
||||
var d = await T.apiGet('/admin/surfaces');
|
||||
var surfaces = d.data || [];
|
||||
var editorSurface = surfaces.find(function (s) { return s.id === 'editor'; });
|
||||
T.assert(editorSurface, 'editor should be in surfaces list');
|
||||
T.assert(editorSurface.source === 'extension', 'editor source should be "extension"');
|
||||
T.assert(editorSurface.enabled === true, 'editor should be enabled');
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -78,6 +78,7 @@
|
||||
'crud/personas.js',
|
||||
'crud/extensions.js',
|
||||
'crud/surfaces.js',
|
||||
'crud/editor-package.js',
|
||||
// Orchestrator (must come after all crud/*.js)
|
||||
'tier-crud.js',
|
||||
'tier-authz.js',
|
||||
|
||||
@@ -30,5 +30,6 @@
|
||||
if (C.personas) await C.personas(testTag);
|
||||
if (C.extensions) await C.extensions(testTag);
|
||||
if (C.surfaces) await C.surfaces(testTag);
|
||||
if (C.editorPackage) await C.editorPackage(testTag);
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user