558 lines
20 KiB
JavaScript
558 lines
20 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – REPL Console
|
||
// ==========================================
|
||
// Fourth tab in the debug modal (Ctrl+Shift+L).
|
||
// Evaluates JavaScript with top-level await support
|
||
// via AsyncFunction. Injects app globals for introspection.
|
||
//
|
||
// Features:
|
||
// - AsyncFunction wrapper (top-level await)
|
||
// - Injected globals: API, Events, Extensions, DebugLog, Surfaces, Stores
|
||
// - Pretty-print results (collapsible JSON, red errors with stack)
|
||
// - Command history: ↑/↓ with sessionStorage
|
||
// - Tab-completion on live object graphs
|
||
// - Event label hints on Events.on(' / Events.emit('
|
||
// - Multi-line: Shift+Enter for newline, Enter to run
|
||
// - Admin-gated OR ?debug=1 URL param
|
||
// ==========================================
|
||
//
|
||
// Exports: window.REPL
|
||
|
||
|
||
const REPL = {
|
||
|
||
// ── State ────────────────────────────────
|
||
_history: [],
|
||
_historyIndex: -1,
|
||
_maxHistory: 100,
|
||
_storageKey: 'cs::repl::history',
|
||
_initialized: false,
|
||
|
||
// ── Initialization ───────────────────────
|
||
|
||
init() {
|
||
if (this._initialized) return;
|
||
this._initialized = true;
|
||
|
||
// Restore history from sessionStorage
|
||
try {
|
||
const saved = sessionStorage.getItem(this._storageKey);
|
||
if (saved) this._history = JSON.parse(saved);
|
||
} catch { /* ignore */ }
|
||
|
||
const input = document.getElementById('replInput');
|
||
if (!input) return;
|
||
|
||
input.addEventListener('keydown', (e) => this._onKeyDown(e));
|
||
input.addEventListener('input', () => this._autoResize(input));
|
||
|
||
// Gate: hide REPL tab if not admin and no ?debug=1
|
||
this._applyGate();
|
||
},
|
||
|
||
/**
|
||
* Hide the REPL tab unless user is admin or ?debug=1 is set.
|
||
*/
|
||
_applyGate() {
|
||
const tab = document.querySelector('.debug-tab[data-tab="repl"]');
|
||
if (!tab) return;
|
||
|
||
const isAdmin = typeof API !== 'undefined' && API.user?.role === 'admin';
|
||
const debugParam = new URLSearchParams(window.location.search).has('debug');
|
||
|
||
if (!isAdmin && !debugParam) {
|
||
tab.style.display = 'none';
|
||
} else {
|
||
tab.style.display = '';
|
||
}
|
||
},
|
||
|
||
// ── Execution ────────────────────────────
|
||
|
||
/**
|
||
* Evaluate an expression with top-level await support.
|
||
* Injects app globals as local variables.
|
||
*/
|
||
async run(code) {
|
||
if (!code.trim()) return;
|
||
|
||
// Add to history
|
||
this._history.push(code);
|
||
if (this._history.length > this._maxHistory) this._history.shift();
|
||
this._historyIndex = -1;
|
||
this._saveHistory();
|
||
|
||
// Render input
|
||
this._appendInput(code);
|
||
|
||
const startTime = performance.now();
|
||
|
||
try {
|
||
// Build an AsyncFunction with injected globals.
|
||
// The last expression is auto-returned if no explicit return.
|
||
const wrappedCode = this._wrapCode(code);
|
||
|
||
// Injected globals accessible inside the REPL
|
||
const globals = this._getGlobals();
|
||
const argNames = Object.keys(globals);
|
||
const argValues = Object.values(globals);
|
||
|
||
// eslint-disable-next-line no-new-func
|
||
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||
const fn = new AsyncFunction(...argNames, wrappedCode);
|
||
|
||
const result = await fn(...argValues);
|
||
const elapsed = (performance.now() - startTime).toFixed(1);
|
||
|
||
if (result !== undefined) {
|
||
this._appendResult(result, elapsed);
|
||
} else {
|
||
this._appendInfo(`(undefined) [${elapsed}ms]`);
|
||
}
|
||
} catch (err) {
|
||
this._appendError(err);
|
||
}
|
||
|
||
this._scrollToBottom();
|
||
},
|
||
|
||
/**
|
||
* Wrap code for AsyncFunction evaluation.
|
||
* If the code is a single expression (no semicolons, no let/const/var/return),
|
||
* auto-return it. Otherwise, execute as-is.
|
||
*/
|
||
_wrapCode(code) {
|
||
const trimmed = code.trim();
|
||
|
||
// Multi-statement: check for declarations or multiple statements
|
||
if (/^(let |const |var |return |if |for |while |switch |try |class |function )/.test(trimmed)) {
|
||
return trimmed;
|
||
}
|
||
|
||
// Single expression — auto-return
|
||
// Handle edge case: object literals starting with { need wrapping
|
||
if (trimmed.startsWith('{') && !trimmed.startsWith('{(')) {
|
||
return `return (${trimmed})`;
|
||
}
|
||
|
||
return `return (${trimmed})`;
|
||
},
|
||
|
||
/**
|
||
* Get the set of globals injected into REPL scope.
|
||
*/
|
||
_getGlobals() {
|
||
const globals = {};
|
||
|
||
// Core app objects
|
||
if (typeof API !== 'undefined') globals.API = API;
|
||
if (typeof Events !== 'undefined') globals.Events = Events;
|
||
if (typeof Extensions !== 'undefined') globals.Extensions = Extensions;
|
||
if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog;
|
||
if (typeof PanelRegistry !== 'undefined') globals.Panels = PanelRegistry;
|
||
if (typeof UI !== 'undefined') globals.UI = UI;
|
||
|
||
// Convenience aliases
|
||
globals.$ = (sel) => document.querySelector(sel);
|
||
globals.$$ = (sel) => [...document.querySelectorAll(sel)];
|
||
globals.sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||
|
||
return globals;
|
||
},
|
||
|
||
// ── Key Handling ─────────────────────────
|
||
|
||
_onKeyDown(e) {
|
||
const input = e.target;
|
||
|
||
// Enter (without Shift) → execute
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
this.run(input.value);
|
||
input.value = '';
|
||
this._autoResize(input);
|
||
return;
|
||
}
|
||
|
||
// ↑ / ↓ → history navigation (only when cursor is on first/last line)
|
||
if (e.key === 'ArrowUp' && this._isCursorOnFirstLine(input)) {
|
||
e.preventDefault();
|
||
this._navigateHistory(-1, input);
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowDown' && this._isCursorOnLastLine(input)) {
|
||
e.preventDefault();
|
||
this._navigateHistory(1, input);
|
||
return;
|
||
}
|
||
|
||
// Tab → completion
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
this._tabComplete(input);
|
||
return;
|
||
}
|
||
},
|
||
|
||
_isCursorOnFirstLine(input) {
|
||
return input.value.substring(0, input.selectionStart).indexOf('\n') === -1;
|
||
},
|
||
|
||
_isCursorOnLastLine(input) {
|
||
return input.value.substring(input.selectionStart).indexOf('\n') === -1;
|
||
},
|
||
|
||
_autoResize(input) {
|
||
input.style.height = 'auto';
|
||
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||
},
|
||
|
||
// ── History ──────────────────────────────
|
||
|
||
_navigateHistory(direction, input) {
|
||
if (this._history.length === 0) return;
|
||
|
||
if (this._historyIndex === -1) {
|
||
// Starting navigation — save current input
|
||
this._historyScratch = input.value;
|
||
this._historyIndex = this._history.length;
|
||
}
|
||
|
||
this._historyIndex += direction;
|
||
|
||
if (this._historyIndex < 0) this._historyIndex = 0;
|
||
if (this._historyIndex >= this._history.length) {
|
||
// Past end → restore scratch
|
||
this._historyIndex = -1;
|
||
input.value = this._historyScratch || '';
|
||
} else {
|
||
input.value = this._history[this._historyIndex];
|
||
}
|
||
|
||
this._autoResize(input);
|
||
// Move cursor to end
|
||
input.selectionStart = input.selectionEnd = input.value.length;
|
||
},
|
||
|
||
_saveHistory() {
|
||
try {
|
||
sessionStorage.setItem(this._storageKey, JSON.stringify(this._history));
|
||
} catch { /* quota exceeded — ignore */ }
|
||
},
|
||
|
||
// ── Tab Completion ──────────────────────
|
||
|
||
_tabComplete(input) {
|
||
const cursor = input.selectionStart;
|
||
const text = input.value.substring(0, cursor);
|
||
|
||
// Event label completion: Events.on(' or Events.emit('
|
||
const eventMatch = text.match(/Events\.(on|once|emit|off)\s*\(\s*['"]([^'"]*)?$/);
|
||
if (eventMatch) {
|
||
this._completeEventLabels(input, cursor, eventMatch[2] || '');
|
||
return;
|
||
}
|
||
|
||
// Object property completion: obj.prop or obj.prop.sub
|
||
const propMatch = text.match(/([\w$.]+)\.([\w]*)$/);
|
||
if (propMatch) {
|
||
this._completeProperty(input, cursor, propMatch[1], propMatch[2]);
|
||
return;
|
||
}
|
||
|
||
// Top-level global completion
|
||
const wordMatch = text.match(/([\w$]+)$/);
|
||
if (wordMatch) {
|
||
this._completeGlobal(input, cursor, wordMatch[1]);
|
||
}
|
||
},
|
||
|
||
_completeEventLabels(input, cursor, partial) {
|
||
// Gather known event labels from the Events handler map
|
||
if (typeof Events === 'undefined') return;
|
||
const labels = Array.from(Events._handlers?.keys() || []);
|
||
const matches = labels.filter(l => l.startsWith(partial)).sort();
|
||
|
||
if (matches.length === 0) return;
|
||
if (matches.length === 1) {
|
||
this._insertCompletion(input, cursor, partial, matches[0]);
|
||
} else {
|
||
this._appendInfo('Completions: ' + matches.join(', '));
|
||
// Complete common prefix
|
||
const common = this._commonPrefix(matches);
|
||
if (common.length > partial.length) {
|
||
this._insertCompletion(input, cursor, partial, common);
|
||
}
|
||
}
|
||
},
|
||
|
||
_completeProperty(input, cursor, objExpr, partial) {
|
||
try {
|
||
// Resolve the object in global scope
|
||
const globals = this._getGlobals();
|
||
// eslint-disable-next-line no-new-func
|
||
const obj = new Function(...Object.keys(globals), `return ${objExpr}`)(...Object.values(globals));
|
||
if (obj == null) return;
|
||
|
||
const keys = [];
|
||
// Own + prototype properties
|
||
for (let o = obj; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
|
||
for (const k of Object.getOwnPropertyNames(o)) {
|
||
if (k.startsWith(partial) && !k.startsWith('_')) keys.push(k);
|
||
}
|
||
}
|
||
const unique = [...new Set(keys)].sort();
|
||
|
||
if (unique.length === 0) return;
|
||
if (unique.length === 1) {
|
||
this._insertCompletion(input, cursor, partial, unique[0]);
|
||
} else {
|
||
this._appendInfo('Completions: ' + unique.slice(0, 30).join(', ') + (unique.length > 30 ? ' …' : ''));
|
||
const common = this._commonPrefix(unique);
|
||
if (common.length > partial.length) {
|
||
this._insertCompletion(input, cursor, partial, common);
|
||
}
|
||
}
|
||
} catch { /* can't resolve — ignore */ }
|
||
},
|
||
|
||
_completeGlobal(input, cursor, partial) {
|
||
const globals = Object.keys(this._getGlobals());
|
||
// Add common window globals
|
||
const candidates = [...globals, 'document', 'window', 'console', 'fetch', 'JSON', 'Math',
|
||
'Array', 'Object', 'String', 'Number', 'Boolean', 'Promise', 'Map', 'Set'];
|
||
const matches = [...new Set(candidates)].filter(c => c.startsWith(partial)).sort();
|
||
|
||
if (matches.length === 0) return;
|
||
if (matches.length === 1) {
|
||
this._insertCompletion(input, cursor, partial, matches[0]);
|
||
} else {
|
||
this._appendInfo('Completions: ' + matches.join(', '));
|
||
const common = this._commonPrefix(matches);
|
||
if (common.length > partial.length) {
|
||
this._insertCompletion(input, cursor, partial, common);
|
||
}
|
||
}
|
||
},
|
||
|
||
_insertCompletion(input, cursor, partial, completion) {
|
||
const before = input.value.substring(0, cursor - partial.length);
|
||
const after = input.value.substring(cursor);
|
||
input.value = before + completion + after;
|
||
const newPos = before.length + completion.length;
|
||
input.selectionStart = input.selectionEnd = newPos;
|
||
},
|
||
|
||
_commonPrefix(strs) {
|
||
if (strs.length === 0) return '';
|
||
let prefix = strs[0];
|
||
for (let i = 1; i < strs.length; i++) {
|
||
while (!strs[i].startsWith(prefix)) {
|
||
prefix = prefix.slice(0, -1);
|
||
}
|
||
}
|
||
return prefix;
|
||
},
|
||
|
||
// ── Output Rendering ────────────────────
|
||
|
||
_appendInput(code) {
|
||
const output = document.getElementById('replOutput');
|
||
if (!output) return;
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'repl-entry repl-entry-input';
|
||
|
||
const pre = document.createElement('pre');
|
||
pre.textContent = '> ' + code;
|
||
div.appendChild(pre);
|
||
output.appendChild(div);
|
||
},
|
||
|
||
_appendResult(value, elapsed) {
|
||
const output = document.getElementById('replOutput');
|
||
if (!output) return;
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'repl-entry repl-entry-result';
|
||
|
||
const formatted = this._formatValue(value);
|
||
div.appendChild(formatted);
|
||
|
||
if (elapsed) {
|
||
const time = document.createElement('span');
|
||
time.className = 'repl-elapsed';
|
||
time.textContent = `[${elapsed}ms]`;
|
||
div.appendChild(time);
|
||
}
|
||
|
||
output.appendChild(div);
|
||
},
|
||
|
||
_appendError(err) {
|
||
const output = document.getElementById('replOutput');
|
||
if (!output) return;
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'repl-entry repl-entry-error';
|
||
|
||
const msg = document.createElement('pre');
|
||
msg.className = 'repl-error-msg';
|
||
msg.textContent = err.message || String(err);
|
||
div.appendChild(msg);
|
||
|
||
if (err.stack) {
|
||
const stack = document.createElement('pre');
|
||
stack.className = 'repl-error-stack';
|
||
stack.textContent = err.stack.split('\n').slice(1, 5).join('\n');
|
||
stack.style.display = 'none';
|
||
|
||
const toggle = document.createElement('button');
|
||
toggle.className = 'repl-stack-toggle';
|
||
toggle.textContent = '▸ stack';
|
||
toggle.onclick = () => {
|
||
const shown = stack.style.display !== 'none';
|
||
stack.style.display = shown ? 'none' : '';
|
||
toggle.textContent = shown ? '▸ stack' : '▾ stack';
|
||
};
|
||
|
||
div.appendChild(toggle);
|
||
div.appendChild(stack);
|
||
}
|
||
|
||
output.appendChild(div);
|
||
},
|
||
|
||
_appendInfo(text) {
|
||
const output = document.getElementById('replOutput');
|
||
if (!output) return;
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'repl-entry repl-entry-info';
|
||
div.textContent = text;
|
||
output.appendChild(div);
|
||
},
|
||
|
||
/**
|
||
* Format a JS value for display.
|
||
* Objects/arrays get collapsible JSON; primitives get plain text.
|
||
*/
|
||
_formatValue(value) {
|
||
if (value === null) return this._textEl('null', 'repl-null');
|
||
if (value === undefined) return this._textEl('undefined', 'repl-undefined');
|
||
|
||
const type = typeof value;
|
||
|
||
if (type === 'string') return this._textEl(JSON.stringify(value), 'repl-string');
|
||
if (type === 'number' || type === 'bigint') return this._textEl(String(value), 'repl-number');
|
||
if (type === 'boolean') return this._textEl(String(value), 'repl-boolean');
|
||
if (type === 'function') return this._textEl(`[Function: ${value.name || 'anonymous'}]`, 'repl-function');
|
||
if (type === 'symbol') return this._textEl(String(value), 'repl-symbol');
|
||
|
||
if (value instanceof HTMLElement) {
|
||
return this._textEl(`<${value.tagName.toLowerCase()}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}>`, 'repl-dom');
|
||
}
|
||
|
||
if (value instanceof Error) {
|
||
return this._textEl(`${value.constructor.name}: ${value.message}`, 'repl-error-msg');
|
||
}
|
||
|
||
// Objects and arrays → collapsible JSON
|
||
try {
|
||
const json = JSON.stringify(value, null, 2);
|
||
if (json.length < 200) {
|
||
return this._textEl(json, 'repl-json');
|
||
}
|
||
return this._collapsibleJson(json, value);
|
||
} catch {
|
||
return this._textEl(String(value), 'repl-object');
|
||
}
|
||
},
|
||
|
||
_textEl(text, className) {
|
||
const pre = document.createElement('pre');
|
||
pre.className = className || '';
|
||
pre.textContent = text;
|
||
return pre;
|
||
},
|
||
|
||
_collapsibleJson(json, value) {
|
||
const container = document.createElement('div');
|
||
container.className = 'repl-collapsible';
|
||
|
||
const summary = Array.isArray(value)
|
||
? `Array(${value.length})`
|
||
: `Object {${Object.keys(value).slice(0, 3).join(', ')}${Object.keys(value).length > 3 ? ', …' : ''}}`;
|
||
|
||
const toggle = document.createElement('button');
|
||
toggle.className = 'repl-json-toggle';
|
||
toggle.textContent = '▸ ' + summary;
|
||
|
||
const pre = document.createElement('pre');
|
||
pre.className = 'repl-json';
|
||
pre.textContent = json;
|
||
pre.style.display = 'none';
|
||
|
||
toggle.onclick = () => {
|
||
const shown = pre.style.display !== 'none';
|
||
pre.style.display = shown ? 'none' : '';
|
||
toggle.textContent = (shown ? '▸ ' : '▾ ') + summary;
|
||
};
|
||
|
||
container.appendChild(toggle);
|
||
container.appendChild(pre);
|
||
return container;
|
||
},
|
||
|
||
_scrollToBottom() {
|
||
const output = document.getElementById('replOutput');
|
||
if (output) output.scrollTop = output.scrollHeight;
|
||
},
|
||
|
||
// ── Toolbar Actions ──────────────────────
|
||
|
||
clear() {
|
||
const output = document.getElementById('replOutput');
|
||
if (output) output.innerHTML = '';
|
||
},
|
||
|
||
copyOutput() {
|
||
const output = document.getElementById('replOutput');
|
||
if (!output) return;
|
||
const text = output.innerText;
|
||
navigator.clipboard.writeText(text)
|
||
.then(() => { if (typeof showToast === 'function') showToast('📋 REPL output copied', 'success'); })
|
||
.catch(() => {});
|
||
},
|
||
|
||
showHelp() {
|
||
const globals = Object.keys(this._getGlobals());
|
||
const help = [
|
||
'╭─ REPL Help ─────────────────────────────╮',
|
||
'│ Enter Run expression │',
|
||
'│ Shift+Enter New line │',
|
||
'│ ↑ / ↓ Command history │',
|
||
'│ Tab Auto-complete │',
|
||
'╰──────────────────────────────────────────╯',
|
||
'',
|
||
'Injected globals: ' + globals.join(', '),
|
||
'',
|
||
'Top-level await is supported:',
|
||
' const r = await API._get("/api/v1/me")',
|
||
'',
|
||
'Shortcuts:',
|
||
' $(sel) → document.querySelector(sel)',
|
||
' $$(sel) → document.querySelectorAll(sel)',
|
||
' sleep(ms) → await sleep(1000)',
|
||
];
|
||
help.forEach(line => this._appendInfo(line));
|
||
this._scrollToBottom();
|
||
},
|
||
};
|
||
|
||
// Initialized from app.js startApp() after auth is confirmed,
|
||
// ensuring API.user.role is available for admin gate check.
|
||
|
||
// ── Exports ─────────────────────────────────
|
||
sb.ns('REPL', REPL);
|