Expand browser editor runtime and LED simulation workflows.

Add Docker deployment support, richer Selenium/LED pattern tests, in-browser diagnostics, responsive UI improvements, and 16x16 panel simulation tooling to speed iteration and hardware-style prototyping.

Made-with: Cursor
This commit is contained in:
2026-05-01 20:24:05 +12:00
parent f204109a84
commit e4c811f51d
30 changed files with 1478 additions and 60 deletions

View File

@@ -1,4 +1,6 @@
import { EditorView, basicSetup } from "/static/codemirror.bundle.mjs";
import { EditorView, basicSetup } from "https://esm.sh/codemirror";
import { Compartment } from "https://esm.sh/@codemirror/state";
import { python } from "https://esm.sh/@codemirror/lang-python";
class TextEditor {
constructor() {
@@ -7,6 +9,7 @@ class TextEditor {
this.pyWorkerMsgId = 0;
this.pyWorkerHandlers = new Map();
this.pyodideInited = false;
this.workerWarmupPromise = null;
this.pyRunGeneration = 0;
this.editor = null;
this.currentFilePath = null;
@@ -26,11 +29,19 @@ class TextEditor {
this.completionIndex = 0;
this.completionOpen = false;
this.completionRequestId = 0;
this.diagnosticsRequestId = 0;
this.diagnosticsTimer = null;
this.draggedItemPath = null;
this.draggedItemIsDirectory = false;
this.dragHoverExpandTimer = null;
this.dragHoverTargetPath = null;
this.savedSession = null;
this.languageCompartment = new Compartment();
this.autoCompleteTimer = null;
this.ledSimWindow = null;
this.ledPanelDismissed = false;
this.lastLedFrame = null;
this.ledPanelWindow = null;
this.init();
}
@@ -47,10 +58,44 @@ class TextEditor {
this.loadSessionState();
this.setupEditor();
this.setupEventListeners();
this.setupDevAutoReload();
this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
this.prewarmPyWorker();
this.loadInitialDirectoryState().then(() => this.restoreSessionTabs());
}
setupDevAutoReload() {
const isLocalhost =
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
if (!isLocalhost) return;
let lastToken = null;
const poll = async () => {
try {
const resp = await fetch(`/static/.reload-token?ts=${Date.now()}`, { cache: 'no-store' });
if (!resp.ok) {
return;
}
const token = (await resp.text()).trim();
if (lastToken === null) {
lastToken = token;
return;
}
if (token && token !== lastToken) {
window.location.reload();
return;
}
} catch (_err) {
// Dev hook file may not exist yet.
}
};
setInterval(poll, 1000);
poll();
}
apiFetch(url, init = {}) {
const next = { ...init };
const headers = new Headers(init.headers || {});
@@ -77,7 +122,7 @@ class TextEditor {
ensurePyWorker() {
if (!this.pyWorker) {
const worker = new Worker('/static/pyodide-worker.js');
const worker = new Worker('/static/pyodide-worker.js?v=3');
this.pyWorker = worker;
worker.onmessage = (event) => this.handlePyWorkerMessage(event);
}
@@ -126,6 +171,19 @@ class TextEditor {
this.pyodideInited = true;
}
prewarmPyWorker() {
if (this.pyodideInited || this.workerWarmupPromise) {
return;
}
this.workerWarmupPromise = this.ensurePyodideReady()
.catch(() => {
// Ignore warm-up failures; next foreground run will retry.
})
.finally(() => {
this.workerWarmupPromise = null;
});
}
loadSessionState() {
try {
const raw = localStorage.getItem(this.sessionStorageKey);
@@ -139,11 +197,13 @@ class TextEditor {
saveSessionState() {
try {
const runFileSelect = document.getElementById('run-file-select');
const runMainCheckbox = document.getElementById('run-main-checkbox');
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
const session = {
openTabPaths: this.openTabs.map((tab) => tab.path),
activeTabPath: this.activeTabPath,
selectedRunFile: runFileSelect ? runFileSelect.value : '',
runMainChecked: Boolean(runMainCheckbox && runMainCheckbox.checked),
panel16x16Checked: Boolean(panelModeCheckbox && panelModeCheckbox.checked),
expandedDirs: Array.from(this.expandedDirs || []),
selectedPath: this.selectedPath || '',
selectedIsDirectory: Boolean(this.selectedIsDirectory)
@@ -170,9 +230,13 @@ class TextEditor {
if (session.activeTabPath && this.findTab(session.activeTabPath)) {
this.switchToTab(session.activeTabPath);
}
const runFileSelect = document.getElementById('run-file-select');
if (runFileSelect && typeof session.selectedRunFile === 'string') {
runFileSelect.value = session.selectedRunFile;
const runMainCheckbox = document.getElementById('run-main-checkbox');
if (runMainCheckbox && typeof session.runMainChecked === 'boolean') {
runMainCheckbox.checked = session.runMainChecked;
}
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
if (panelModeCheckbox && typeof session.panel16x16Checked === 'boolean') {
panelModeCheckbox.checked = session.panel16x16Checked;
}
this.saveSessionState();
}
@@ -256,7 +320,7 @@ class TextEditor {
setupEditor() {
this.editor = new EditorView({
doc: '',
extensions: [basicSetup],
extensions: [basicSetup, this.languageCompartment.of([])],
parent: document.getElementById('editor')
});
@@ -323,10 +387,88 @@ class TextEditor {
this.hideCompletionDropdown();
this.updateActiveTabContent();
this.markAsModified();
this.scheduleAutoCompletion();
this.scheduleDiagnostics();
}
})(this.editor.dispatch);
}
setLspStatus(text, title = '') {
const status = document.getElementById('lsp-status');
if (!status) return;
status.textContent = text;
status.title = title || text;
}
scheduleDiagnostics() {
if (this.diagnosticsTimer) {
clearTimeout(this.diagnosticsTimer);
}
this.diagnosticsTimer = setTimeout(() => {
this.runDiagnostics();
}, 220);
}
async runDiagnostics() {
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
return;
}
const requestId = ++this.diagnosticsRequestId;
this.setLspStatus('LSP: checking...', 'Running Jedi syntax diagnostics');
try {
await this.ensurePyodideReady();
const extraFiles = {};
for (const tab of this.openTabs) {
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
extraFiles[tab.path] = tab.content;
}
}
const data = await this.callPyWorker('diagnostics', {
path: this.currentFilePath,
content: this.editor.state.doc.toString(),
extra_files: extraFiles
});
if (requestId !== this.diagnosticsRequestId) return;
const diagnostics = Array.isArray(data.diagnostics) ? data.diagnostics : [];
if (diagnostics.length === 0) {
this.setLspStatus('LSP: OK', 'No syntax errors');
} else {
const first = diagnostics[0];
const msg = `${first.message || 'Syntax error'} (line ${first.line ?? '?'})`;
this.setLspStatus(`LSP: ${diagnostics.length} issue(s)`, msg);
}
} catch (_error) {
this.setLspStatus('LSP: unavailable', 'Diagnostics failed');
}
}
scheduleAutoCompletion() {
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
return;
}
const cursor = this.editor.state.selection.main.head;
const doc = this.editor.state.doc.toString();
const prev = cursor > 0 ? doc[cursor - 1] : '';
if (!(prev === '.' || /[A-Za-z0-9_]/.test(prev))) {
return;
}
if (this.autoCompleteTimer) {
clearTimeout(this.autoCompleteTimer);
}
this.autoCompleteTimer = setTimeout(() => {
this.showCompletionDropdown();
}, 140);
}
setLanguageForPath(path) {
const ext = (path || '').toLowerCase();
const language = ext.endsWith('.py') ? python() : [];
this.editor.dispatch({
effects: this.languageCompartment.reconfigure(language),
});
}
getCursorLineAndColumn() {
const cursor = this.editor.state.selection.main.head;
const lineInfo = this.editor.state.doc.lineAt(cursor);
@@ -566,6 +708,27 @@ class TextEditor {
this.stopPython();
});
const ledRunBtn = document.getElementById('led-run-btn');
if (ledRunBtn) {
ledRunBtn.addEventListener('click', () => {
this.runPython();
});
}
const ledStopBtn = document.getElementById('led-stop-btn');
if (ledStopBtn) {
ledStopBtn.addEventListener('click', () => {
this.stopPython();
});
}
const ledCloseBtn = document.getElementById('led-close-btn');
if (ledCloseBtn) {
ledCloseBtn.addEventListener('click', () => {
this.ledPanelDismissed = true;
const panel = document.getElementById('led-sim-panel');
if (panel) panel.classList.add('hidden');
});
}
document.getElementById('create-file-btn').addEventListener('click', () => {
this.createNewFile();
});
@@ -590,15 +753,26 @@ class TextEditor {
window.location.href = '/';
});
document.getElementById('run-file-select').addEventListener('change', (event) => {
const selectedPath = event.target.value;
if (!selectedPath) return;
const tab = this.findTab(selectedPath);
if (tab) {
this.switchToTab(selectedPath);
}
this.saveSessionState();
});
const runMainCheckbox = document.getElementById('run-main-checkbox');
if (runMainCheckbox) {
runMainCheckbox.addEventListener('change', () => {
this.saveSessionState();
this.updateRunButtonState();
});
}
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
if (panelModeCheckbox) {
panelModeCheckbox.addEventListener('change', () => {
this.saveSessionState();
if (!panelModeCheckbox.checked && this.ledPanelWindow && !this.ledPanelWindow.closed) {
this.ledPanelWindow.close();
this.ledPanelWindow = null;
}
if (this.lastLedFrame) {
this.renderLedSimulation(this.lastLedFrame);
}
});
}
}
@@ -644,29 +818,9 @@ class TextEditor {
this.closeTab(closeButton.dataset.path);
});
});
this.renderRunFileSelect();
this.saveSessionState();
}
renderRunFileSelect() {
const select = document.getElementById('run-file-select');
if (!select) return;
const pythonTabs = this.openTabs
.map((tab) => tab.path)
.filter((path) => typeof path === 'string' && path.toLowerCase().endsWith('.py'));
const currentValue = select.value;
select.innerHTML = '<option value="">Run active file</option>';
pythonTabs.forEach((path) => {
const option = document.createElement('option');
option.value = path;
option.textContent = path.startsWith('code/') ? path.slice('code/'.length) : path;
if (path === currentValue || path === this.currentFilePath) {
option.selected = true;
}
select.appendChild(option);
});
}
findTab(path) {
return this.openTabs.find((tab) => tab.path === path);
}
@@ -705,10 +859,12 @@ class TextEditor {
}
});
this.ignoreNextChange = false;
this.setLanguageForPath(path);
const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = path;
this.setEditorReadOnly(this.isReadOnlyPath(path));
this.updateRunButtonState();
this.scheduleDiagnostics();
if (tab.isModified) {
this.markAsModified();
} else {
@@ -737,6 +893,7 @@ class TextEditor {
this.activeTabPath = null;
this.currentFilePath = null;
this.setEditorReadOnly(false);
this.setLanguageForPath('');
this.ignoreNextChange = true;
this.editor.dispatch({
changes: {
@@ -749,6 +906,7 @@ class TextEditor {
const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = 'No file selected';
this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
this.markAsSaved();
}
}
@@ -1021,12 +1179,14 @@ class TextEditor {
}
});
this.ignoreNextChange = false;
this.setLanguageForPath(filePath);
this.markAsSaved();
this.setEditorReadOnly(this.isReadOnlyPath(filePath));
const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = filePath;
this.updateRunButtonState();
this.scheduleDiagnostics();
this.renderTabs();
this.saveSessionState();
} catch (error) {
@@ -1107,6 +1267,7 @@ class TextEditor {
const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = 'No file selected';
this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
}
this.directoryCache.clear();
@@ -1129,6 +1290,7 @@ class TextEditor {
} else {
this.activeTabPath = null;
this.currentFilePath = null;
this.setLanguageForPath('');
this.ignoreNextChange = true;
this.editor.dispatch({
changes: {
@@ -1150,9 +1312,13 @@ class TextEditor {
updateRunButtonState() {
const runButton = document.getElementById('run-btn');
const stopButton = document.getElementById('stop-btn');
const runMainCheckbox = document.getElementById('run-main-checkbox');
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
runButton.disabled = !hasPythonFile || this.isPythonRunning;
const canRun = runMainSelected || hasPythonFile;
runButton.disabled = !canRun;
stopButton.disabled = !this.isPythonRunning;
this.updateLedWindowControls();
}
clearConsole() {
@@ -1165,9 +1331,147 @@ class TextEditor {
}
}
maybePrepareLedWindow(files) {
const importsNeoPixel = Object.values(files || {}).some((content) =>
typeof content === 'string' &&
/\bimport\s+neopixel\b|\bfrom\s+neopixel\s+import\b/.test(content)
);
if (!importsNeoPixel) return;
this.ledPanelDismissed = false;
this.ensureLedWindow();
}
ensureLedWindow() {
const panel = document.getElementById('led-sim-panel');
if (!panel) return null;
if (!this.ledPanelDismissed) {
panel.classList.remove('hidden');
}
this.updateLedWindowControls();
return panel;
}
ensureLedPanelWindow() {
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
return this.ledPanelWindow;
}
const win = window.open('', 'neopixel-16x16', 'width=760,height=760');
if (!win) return null;
win.document.title = 'NeoPixel 16x16 Panel';
win.document.body.innerHTML = `
<style>
body { margin: 0; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; padding: 12px; }
.meta { margin-bottom: 10px; color: #9ca3af; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(16, 24px); grid-auto-rows: 24px; gap: 6px; }
.led { width: 24px; height: 24px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); box-shadow: inset 0 0 8px rgba(0,0,0,0.45); }
</style>
<div id="meta" class="meta">Waiting for frame...</div>
<div id="grid" class="grid"></div>
`;
this.ledPanelWindow = win;
return win;
}
closeLedPanelWindow() {
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
this.ledPanelWindow.close();
}
this.ledPanelWindow = null;
}
updateLedWindowControls() {
const runBtn = document.getElementById('led-run-btn');
const stopBtn = document.getElementById('led-stop-btn');
if (!runBtn || !stopBtn) return;
const runMainCheckbox = document.getElementById('run-main-checkbox');
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
runBtn.disabled = !(runMainSelected || hasPythonFile);
stopBtn.disabled = !this.isPythonRunning;
}
renderLedSimulation(frame) {
this.lastLedFrame = frame;
this.ledPanelDismissed = false;
if (!frame) return;
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
const panelMode = Boolean(panelModeCheckbox && panelModeCheckbox.checked);
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
if (panelMode) {
const panelWindow = this.ensureLedPanelWindow();
if (!panelWindow || panelWindow.closed) return;
const meta = panelWindow.document.getElementById('meta');
const grid = panelWindow.document.getElementById('grid');
if (!meta || !grid) return;
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3} | mode=16x16`;
grid.innerHTML = '';
const panelSize = 16 * 16;
for (let panelIndex = 0; panelIndex < panelSize; panelIndex += 1) {
const row = Math.floor(panelIndex / 16);
const col = panelIndex % 16;
const ledIndex = row % 2 === 0 ? row * 16 + (15 - col) : row * 16 + col;
const px = pixels[ledIndex] || [0, 0, 0];
const r = Number(px?.[0] ?? 0);
const g = Number(px?.[1] ?? 0);
const b = Number(px?.[2] ?? 0);
const div = panelWindow.document.createElement('div');
div.className = 'led';
div.title = `panel(${row},${col}) -> #${ledIndex} (${r}, ${g}, ${b})`;
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
grid.appendChild(div);
}
} else {
const panel = this.ensureLedWindow();
if (!panel) return;
const meta = document.getElementById('led-meta');
const grid = document.getElementById('led-grid');
if (!meta || !grid) return;
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3}`;
grid.innerHTML = '';
grid.classList.remove('panel-mode');
pixels.forEach((px, i) => {
const r = Number(px?.[0] ?? 0);
const g = Number(px?.[1] ?? 0);
const b = Number(px?.[2] ?? 0);
const div = document.createElement('div');
div.className = 'led';
div.title = `#${i} (${r}, ${g}, ${b})`;
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
grid.appendChild(div);
});
}
}
processSimulationLines(lines) {
for (const line of lines || []) {
if (typeof line !== 'string') continue;
const marker = '[neopixel-json]';
const idx = line.indexOf(marker);
if (idx === -1) continue;
const jsonPart = line.slice(idx + marker.length).trim();
if (!jsonPart) continue;
try {
const payload = JSON.parse(jsonPart);
if (payload && payload.type === 'neopixel') {
this.renderLedSimulation(payload);
}
} catch (_err) {
// Ignore malformed simulation payloads.
}
}
}
appendConsoleOutput(lines) {
if (!Array.isArray(lines) || lines.length === 0) return;
this.consolePendingText += lines.join('');
this.processSimulationLines(lines);
const visibleLines = lines.filter((line) => {
if (typeof line !== 'string') return false;
return !line.includes('[neopixel-json]');
});
if (visibleLines.length === 0) return;
this.consolePendingText += visibleLines.join('');
if (this.consoleFlushTimer) return;
this.consoleFlushTimer = setTimeout(() => {
const consoleOutput = document.getElementById('console-output');
@@ -1185,8 +1489,8 @@ class TextEditor {
}
async runPython() {
const runFileSelect = document.getElementById('run-file-select');
const selectedRunFile = runFileSelect && runFileSelect.value ? runFileSelect.value : this.currentFilePath;
const runMainCheckbox = document.getElementById('run-main-checkbox');
const selectedRunFile = runMainCheckbox && runMainCheckbox.checked ? 'code/main.py' : this.currentFilePath;
if (selectedRunFile && selectedRunFile !== this.currentFilePath && this.findTab(selectedRunFile)) {
this.switchToTab(selectedRunFile);
}
@@ -1214,6 +1518,7 @@ class TextEditor {
files[tab.path] = tab.content;
}
}
this.maybePrepareLedWindow(files);
this.clearConsole();
const args = [];
this.appendConsoleOutput([`$ pyodide ${selectedRunFile}\n`]);
@@ -1228,6 +1533,7 @@ class TextEditor {
});
if (generation === this.pyRunGeneration) {
this.appendConsoleOutput(['\n[Finished]\n']);
this.closeLedPanelWindow();
}
} catch (error) {
this.appendConsoleOutput([`\n${error.message}\n`]);
@@ -1244,8 +1550,10 @@ class TextEditor {
this.pyRunGeneration += 1;
this.disposePyWorker();
this.isPythonRunning = false;
this.closeLedPanelWindow();
this.appendConsoleOutput(['\n[Execution stopped — Pyodide worker was reset]\n']);
this.updateRunButtonState();
this.prewarmPyWorker();
}
async deleteSelected() {