Changeset 0.37.13 (#225)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
50
CHANGELOG.md
50
CHANGELOG.md
@@ -1,5 +1,55 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.37.13] — 2026-03-22
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Scorched Earth III — third dead-code purge. 5 files deleted (−2,269 net lines).
|
||||||
|
The massive `ui-primitives.js` (1,133 LOC) is gone; its few remaining consumers
|
||||||
|
(`debug.js`, `switchboard-sdk.js`) received inlined replacements. Theme management
|
||||||
|
migrated from the global `Theme` object to the Preact SDK's `createTheme` module.
|
||||||
|
SDK slimmed by removing 6 dead factory wrappers.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `src/js/ui-primitives.js` (1,133 LOC) — `esc()`, `createComponentRegistry()`,
|
||||||
|
`componentMixin()`, `Providers`, `Roles`, `showConfirm()`, `showPrompt()`,
|
||||||
|
`openModal()`, `closeModal()`, `Theme`, render helpers. All consumers deleted
|
||||||
|
or inlined.
|
||||||
|
- `src/js/code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor, zero consumers
|
||||||
|
- `src/js/file-tree.js` (290 LOC) — workspace file browser widget, zero consumers
|
||||||
|
- `src/js/user-menu.js` (206 LOC) — vanilla flyout menu; all surfaces use
|
||||||
|
Preact `sw/shell/user-menu.js`
|
||||||
|
- `src/js/app-state.js` (139 LOC) — global `window.App` state; Preact surfaces
|
||||||
|
use local state hooks; `debug.js` state snapshot already guards with `typeof`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `src/js/debug.js` — inlined `openModal()`, `closeModal()`, `showConfirm()`,
|
||||||
|
`_escHtml()` (~44 LOC) to replace dependency on deleted `ui-primitives.js`
|
||||||
|
- `src/js/switchboard-sdk.js` — removed 6 dead factory wrappers (`sw.notes()`,
|
||||||
|
`sw.panels()`, `sw.fileTree()`, `sw.codeEditor()`, `sw.layout()`,
|
||||||
|
`sw.userMenu()` + `_hydrateUserMenu()`); replaced global `Theme` with
|
||||||
|
`import { createTheme } from './sw/sdk/theme.js'`; inlined `esc()` and
|
||||||
|
`openModal`/`closeModal` helpers; net −213 LOC
|
||||||
|
- `server/pages/templates/base.html` — removed 5 script tags (`app-state.js`,
|
||||||
|
`ui-primitives.js`, `user-menu.js`, `file-tree.js`, `code-editor.js`);
|
||||||
|
consolidated removal comments
|
||||||
|
- `src/sw.js` — removed 5 entries from `SHELL_FILES` cache manifest
|
||||||
|
|
||||||
|
### Design Notes
|
||||||
|
|
||||||
|
- **6 old JS files survive:** `sb.js` (debug modal onclick handlers),
|
||||||
|
`events.js` (extension bridge), `switchboard-sdk.js` (extension compatibility),
|
||||||
|
`debug.js` + `repl.js` (active tooling), `workflow-surfaces.js` (deferred to
|
||||||
|
Preact rewrite). Targeted for removal as extensions migrate.
|
||||||
|
- **`user-menu.css` kept:** extension surface template still renders server-side
|
||||||
|
user-menu HTML; CSS provides styling for the placeholder.
|
||||||
|
- **Cumulative scorched earth:** 49 files deleted, ~−17,710 lines across three
|
||||||
|
passes (v0.37.10, v0.37.12, v0.37.13).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.37.12] — 2026-03-22
|
## [0.37.12] — 2026-03-22
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
|
|||||||
│ v0.37.2 Primitives ✅ │
|
│ v0.37.2 Primitives ✅ │
|
||||||
│ v0.37.3 SDK │
|
│ v0.37.3 SDK │
|
||||||
│ v0.37.4 Shell │
|
│ v0.37.4 Shell │
|
||||||
│ v0.37.5–12 Surfaces ✅ │
|
│ v0.37.5–13 Surfaces ✅ │
|
||||||
│ v0.37.13–18 Surfaces │
|
│ v0.37.14–18 Surfaces │
|
||||||
│ │ │
|
│ │ │
|
||||||
══════╪════════════╪═════════════════╪══════
|
══════╪════════════╪═════════════════╪══════
|
||||||
│ MVP v0.50.0 │
|
│ MVP v0.50.0 │
|
||||||
@@ -637,10 +637,10 @@ Each surface rebuilt as Preact component tree. Old JS deleted per surface.
|
|||||||
| v0.37.10 | Chat surface ✅ | Scorched earth: 21 files deleted (−12,841 net), Preact surface + ChatPane kit upgrades |
|
| v0.37.10 | Chat surface ✅ | Scorched earth: 21 files deleted (−12,841 net), Preact surface + ChatPane kit upgrades |
|
||||||
| v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix |
|
| v0.37.11 | Notes surface ✅ | Preact surface (7 JS + 1 CSS), sidebar folders/tags, UserMenu, template rewrite, Menu primitive fix |
|
||||||
| v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned |
|
| v0.37.12 | Scorched Earth II ✅ | 23 files deleted (~2,600 lines): 9 JS, 6 CSS, 8 admin templates; sw.js + base.html cleaned |
|
||||||
| v0.37.13 | Workflow surfaces | Form renderer, review views |
|
| v0.37.13 | Scorched Earth III ✅ | 5 JS deleted (−2,269 lines): ui-primitives, code-editor, file-tree, user-menu, app-state; SDK slimmed, Theme→Preact |
|
||||||
| v0.37.14 | Pane audit | Collaborative walkthrough: each pane checked for form and function |
|
| v0.37.14 | Pane audit | Collaborative walkthrough: core features + theming checked |
|
||||||
| v0.37.15 | Projects surface | Project views, note/channel associations |
|
| v0.37.15 | Workflow surfaces | Form renderer, review views (design-first) |
|
||||||
| v0.37.16 | Workflow assignment | Workflow routing, assignment UX |
|
| v0.37.16 | Projects surface | Project views, note/channel associations (design-first) |
|
||||||
| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI |
|
| v0.37.17 | Debug / model surface | Debug tooling, model configuration UI |
|
||||||
| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified |
|
| v0.37.18 | Tag | Light mode CSS audit, dead code hunt, all features verified |
|
||||||
|
|
||||||
@@ -695,15 +695,39 @@ Kept (11 JS files — extension surface + debug dependencies):
|
|||||||
`file-tree.js`, `code-editor.js`, `switchboard-sdk.js`, `debug.js`,
|
`file-tree.js`, `code-editor.js`, `switchboard-sdk.js`, `debug.js`,
|
||||||
`repl.js`, `workflow-surfaces.js`
|
`repl.js`, `workflow-surfaces.js`
|
||||||
|
|
||||||
|
**v0.37.13 — Scorched Earth III ✅:**
|
||||||
|
|
||||||
|
Third dead-code purge. 5 files deleted (−2,269 net lines). The 1,133-line
|
||||||
|
`ui-primitives.js` is gone; Theme management migrated to Preact SDK module.
|
||||||
|
SDK slimmed by removing 6 dead factory wrappers.
|
||||||
|
|
||||||
|
Deleted (5 files):
|
||||||
|
- [x] `ui-primitives.js` (1,133 LOC) — `esc`, component lifecycle, Providers,
|
||||||
|
Roles, modal/confirm/prompt, Theme, render helpers
|
||||||
|
- [x] `code-editor.js` (362 LOC) — tabbed CodeMirror 6 editor
|
||||||
|
- [x] `file-tree.js` (290 LOC) — workspace file browser
|
||||||
|
- [x] `user-menu.js` (206 LOC) — vanilla flyout menu
|
||||||
|
- [x] `app-state.js` (139 LOC) — global `window.App` state
|
||||||
|
- [x] 5 script tags removed from `base.html`
|
||||||
|
- [x] 5 entries removed from `sw.js` SHELL_FILES
|
||||||
|
- [x] `switchboard-sdk.js` slimmed: 6 dead wrappers removed, Theme→Preact SDK
|
||||||
|
|
||||||
|
Kept (6 JS files — extension + debug dependencies):
|
||||||
|
`sb.js`, `events.js`, `switchboard-sdk.js`, `debug.js`, `repl.js`,
|
||||||
|
`workflow-surfaces.js`
|
||||||
|
|
||||||
|
Cumulative scorched earth: 49 files, ~−17,710 lines (v0.37.10 + v0.37.12 + v0.37.13)
|
||||||
|
|
||||||
**v0.37.14 — Pane Audit:**
|
**v0.37.14 — Pane Audit:**
|
||||||
|
|
||||||
Collaborative walkthrough of each pane (ChatPane, NotesPane) and surface
|
Collaborative walkthrough of core features and theming. Each pane and surface
|
||||||
checking form and function. Fixes discovered during the audit are applied
|
checked for form, function, and visual consistency. Fixes applied in-place.
|
||||||
in-place. No new architecture — just making everything work correctly.
|
No new architecture — making everything work correctly.
|
||||||
|
|
||||||
- [ ] ChatPane: message rendering, streaming, model selector, markdown, code blocks
|
- [ ] ChatPane: message rendering, streaming, model selector, markdown, code blocks
|
||||||
- [ ] NotesPane: list, editor, reader, graph, quick switcher, save-to-note
|
- [ ] NotesPane: list, editor, reader, graph, quick switcher, save-to-note
|
||||||
- [ ] Cross-pane: toast, dialog, user menu, keyboard shortcuts
|
- [ ] Cross-pane: toast, dialog, user menu, keyboard shortcuts
|
||||||
|
- [ ] Theming: light/dark mode consistency audit across all surfaces
|
||||||
- [ ] Kit enhancement backlog triage (above items evaluated for inclusion)
|
- [ ] Kit enhancement backlog triage (above items evaluated for inclusion)
|
||||||
|
|
||||||
**v0.37.15 — Projects Surface:**
|
**v0.37.15 — Projects Surface:**
|
||||||
|
|||||||
@@ -93,34 +93,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/sb.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script>
|
{{/* v0.37.13 Scorched Earth III: app-state.js, ui-primitives.js,
|
||||||
{{/* api.js removed in v0.37.10. Extension surfaces using Switchboard.init()
|
user-menu.js, file-tree.js, code-editor.js removed.
|
||||||
should migrate to the Preact SDK (sw/sdk/). */}}
|
Earlier: api.js, ui-core.js, pages.js (v0.37.10),
|
||||||
|
ui-format.js, virtual-scroll.js, model-selector.js, drag-resize.js (v0.37.12),
|
||||||
|
chat-pane.js, pane-container.js (v0.37.10). */}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script>
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives.js?v={{.Version}}"></script>
|
{{/* v0.28.5: SDK — composition layer over globals. */}}
|
||||||
{{/* marked.min.js + purify.min.js removed from base in v0.37.12 — surfaces load their own copies */}}
|
|
||||||
{{/* ui-format.js, virtual-scroll.js removed in v0.37.12 */}}
|
|
||||||
{{/* ui-core.js + pages.js removed in v0.37.10 */}}
|
|
||||||
{{/* v0.25.0: Component scripts — available on all surfaces */}}
|
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/user-menu.js?v={{.Version}}"></script>
|
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/file-tree.js?v={{.Version}}"></script>
|
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/code-editor.js?v={{.Version}}"></script>
|
|
||||||
{{/* chat-pane.js + pane-container.js removed in v0.37.10 — see sw/components/chat-pane/ */}}
|
|
||||||
{{/* model-selector.js, drag-resize.js removed in v0.37.12 */}}
|
|
||||||
{{/* v0.28.5: SDK — composition layer over globals. Must load after all
|
|
||||||
primitives/components it wraps, before extensions.js. */}}
|
|
||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/switchboard-sdk.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/switchboard-sdk.js?v={{.Version}}"></script>
|
||||||
|
|
||||||
{{/* ── Universal init: SDK boot for ALL surfaces ── */}}
|
{{/* ── Universal init: SDK boot for ALL surfaces ── */}}
|
||||||
<script type="module" nonce="{{.CSPNonce}}">
|
<script type="module" nonce="{{.CSPNonce}}">
|
||||||
// v0.28.5: SDK handles theme, appearance, and UserMenu hydration.
|
// v0.28.5: SDK handles theme + appearance. Idempotent.
|
||||||
// Surfaces that load app.js call Switchboard.init() again (idempotent).
|
|
||||||
Switchboard.init();
|
Switchboard.init();
|
||||||
|
|
||||||
// v0.28.0: Universal logout — available on every surface.
|
// Universal logout — available on every surface.
|
||||||
// Chat/editor/notes load app.js which overrides this with a
|
// Preact surfaces override with richer versions.
|
||||||
// richer version (showConfirm, App.chats cleanup, etc.).
|
|
||||||
// Extension surfaces get this baseline version.
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
if (!confirm('Sign out?')) return;
|
if (!confirm('Sign out?')) return;
|
||||||
if (typeof Events !== 'undefined') { Events.disconnect(); Events.clear(); }
|
if (typeof Events !== 'undefined') { Events.disconnect(); Events.clear(); }
|
||||||
@@ -141,8 +129,7 @@
|
|||||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/surfaces/{{.Surface}}/js/main.js?v={{.Version}}"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{/* v0.28.5: UserMenu hydration moved into Switchboard.init() (switchboard-sdk.js).
|
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
|
||||||
The SDK handles create/bind/setUser for all non-chat surfaces automatically. */}}
|
|
||||||
|
|
||||||
{{/* ── Debug Log Modal (all surfaces) ───── */}}
|
{{/* ── Debug Log Modal (all surfaces) ───── */}}
|
||||||
<div id="debugModal" class="modal-overlay">
|
<div id="debugModal" class="modal-overlay">
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
const Version = "0.37.12"
|
const Version = "0.37.13"
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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 ──────────────────────────
|
// ── Modal Controls ──────────────────────────
|
||||||
|
|
||||||
function openDebugModal() {
|
function openDebugModal() {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// v0.28.5: Composition layer over platform globals. Surface and
|
// v0.28.5: Composition layer over platform globals. Surface and
|
||||||
// extension authors consume this instead of hunting through 15 JS files.
|
// 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:
|
// Usage:
|
||||||
// const sw = Switchboard.init();
|
// const sw = Switchboard.init();
|
||||||
@@ -10,12 +13,12 @@
|
|||||||
// sw.on('chat.message.*', (payload) => { ... });
|
// sw.on('chat.message.*', (payload) => { ... });
|
||||||
// sw.pipe.render(50, (ctx) => { ... });
|
// 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)
|
// Exports: window.Switchboard, window.sw (convenience alias)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
'use strict';
|
import { createTheme } from './sw/sdk/theme.js';
|
||||||
|
|
||||||
const Switchboard = {
|
const Switchboard = {
|
||||||
|
|
||||||
@@ -190,45 +193,10 @@ const Switchboard = {
|
|||||||
Events.emit(label, payload, opts);
|
Events.emit(label, payload, opts);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Theme ────────────────────────────────────────────
|
// ── Theme (v0.37.13: uses Preact SDK module directly) ──
|
||||||
|
|
||||||
sw.theme = {
|
const _theme = createTheme((label, payload, opts) => Events.emit(label, payload, opts));
|
||||||
/** Resolved theme: 'dark' or 'light' (never 'system'). */
|
sw.theme = _theme;
|
||||||
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 () => {};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── UI Primitives ────────────────────────────────────
|
// ── UI Primitives ────────────────────────────────────
|
||||||
|
|
||||||
@@ -239,16 +207,19 @@ const Switchboard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
sw.confirm = function (message, opts) {
|
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);
|
if (typeof showConfirm === 'function') return showConfirm(message, opts);
|
||||||
return Promise.resolve(window.confirm(message));
|
return Promise.resolve(window.confirm(message));
|
||||||
};
|
};
|
||||||
|
|
||||||
sw.modal = {
|
sw.modal = {
|
||||||
open(contentOrId) {
|
open(id) {
|
||||||
if (typeof openModal === 'function') openModal(contentOrId);
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.classList.add('active');
|
||||||
},
|
},
|
||||||
close(id) {
|
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');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'flyout-item' + (item.danger ? ' flyout-danger' : '');
|
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', () => {
|
btn.addEventListener('click', () => {
|
||||||
result.close();
|
result.close();
|
||||||
if (item.onClick) item.onClick();
|
if (item.onClick) item.onClick();
|
||||||
@@ -470,87 +442,8 @@ const Switchboard = {
|
|||||||
return ChatPane.mount(container, opts || {});
|
return ChatPane.mount(container, opts || {});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// v0.37.13: sw.notes, sw.panels, sw.fileTree, sw.codeEditor,
|
||||||
* Mount a NotePanel instance into a container element.
|
// sw.layout removed (Scorched Earth III — globals deleted).
|
||||||
* 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 || {});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Workflow ─────────────────────────────────────────
|
// ── Workflow ─────────────────────────────────────────
|
||||||
// v0.30.2: Workflow stage surface management.
|
// v0.30.2: Workflow stage surface management.
|
||||||
@@ -780,77 +673,15 @@ const Switchboard = {
|
|||||||
_runRender(ctx) { return _runChain('render', ctx); },
|
_runRender(ctx) { return _runChain('render', ctx); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── UserMenu ─────────────────────────────────────────
|
// v0.37.13: sw.userMenu + _hydrateUserMenu removed
|
||||||
|
// (Scorched Earth III — all surfaces use Preact 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Perform Init ─────────────────────────────────────
|
// ── Perform Init ─────────────────────────────────────
|
||||||
|
|
||||||
// Theme + appearance (safe on all surfaces)
|
// Theme (v0.37.13: uses imported Preact SDK module)
|
||||||
if (typeof Theme !== 'undefined') Theme.init();
|
_theme.init();
|
||||||
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
|
if (typeof UI !== 'undefined' && UI.restoreAppearance) UI.restoreAppearance();
|
||||||
|
|
||||||
// UserMenu hydration
|
|
||||||
_hydrateUserMenu();
|
|
||||||
|
|
||||||
// Emit ready event
|
// Emit ready event
|
||||||
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
|
||||||
@@ -35,13 +35,9 @@ const SHELL_FILES = [
|
|||||||
'./css/sw-notes-pane.css',
|
'./css/sw-notes-pane.css',
|
||||||
'./css/sw-notes-surface.css',
|
'./css/sw-notes-surface.css',
|
||||||
// JS — platform globals (extension + debug deps)
|
// JS — platform globals (extension + debug deps)
|
||||||
|
// v0.37.13: app-state, ui-primitives, user-menu, file-tree, code-editor removed
|
||||||
'./js/sb.js',
|
'./js/sb.js',
|
||||||
'./js/app-state.js',
|
|
||||||
'./js/events.js',
|
'./js/events.js',
|
||||||
'./js/ui-primitives.js',
|
|
||||||
'./js/user-menu.js',
|
|
||||||
'./js/file-tree.js',
|
|
||||||
'./js/code-editor.js',
|
|
||||||
'./js/switchboard-sdk.js',
|
'./js/switchboard-sdk.js',
|
||||||
'./js/debug.js',
|
'./js/debug.js',
|
||||||
'./js/repl.js',
|
'./js/repl.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user