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";
import { LocalWorkspaceClient, isLocalModeEnabled, exitLocalMode, supportsFolderPicker } from "/static/local-workspace.js?v=4";
import { createZip, readZip } from "/static/zip-utils.js?v=2";
class TextEditor {
constructor() {
this.sessionStorageKey = 'python-editor.editor.session.v1';
this.pyWorker = null;
this.pyWorkerMsgId = 0;
this.pyWorkerHandlers = new Map();
this.pyodideInited = false;
this.workerWarmupPromise = null;
this.pyRunGeneration = 0;
this.editor = null;
this.currentFilePath = null;
this.isModified = false;
this.selectedPath = null;
this.ignoreNextChange = false;
this.expandedDirs = new Set([""]);
this.directoryCache = new Map();
this.openTabs = [];
this.activeTabPath = null;
this.currentFileReadOnly = false;
this.selectedIsDirectory = false;
this.isPythonRunning = false;
this.consolePendingText = '';
this.consoleFlushTimer = null;
this.completionItems = [];
this.completionIndex = 0;
this.completionOpen = false;
this.completionRequestId = 0;
this.diagnosticsRequestId = 0;
this.diagnosticsTimer = null;
this.workspaceSourcesCache = null;
this.workspaceSourcesCacheAt = 0;
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.workspaceUserId = null;
this.isSuperuser = false;
this.adcPinCount = 64;
this.adcSab = null;
this.adcView = null;
this.adcSliders = new Map();
this.adcDefaultValue = 32768;
this.serialCapacity = 4096;
this.serialSab = null;
this.serialIndices = null;
this.serialData = null;
this.serialPanelOpen = false;
this.serialDecoder = null;
this.pinCount = 64;
this.pinOutSab = null;
this.pinOutView = null;
this.pinInSab = null;
this.pinInView = null;
this.pinRows = new Map();
this.pinRafId = null;
/* Mirrors the latest UI state per pin so the toggle works even when
SharedArrayBuffer is unavailable (mobile over LAN IP, no COOP/COEP
cross-origin isolation, etc.). The actual delivery to Python then
goes through `worker.postMessage({type:'pinIn'})`. */
this._pinInLocal = new Array(64).fill(0);
/* Pins claimed by another simulator (NeoPixel data line, etc.) — they
skip the Pins panel entirely so the row doesn't flicker on every
neopixel.write(). Cleared between runs. */
this._claimedPins = new Set();
this.localMode = isLocalModeEnabled();
this.localWorkspace = this.localMode ? new LocalWorkspaceClient() : null;
this.init();
}
async getWorkspacePythonSources() {
const now = Date.now();
if (this.workspaceSourcesCache && (now - this.workspaceSourcesCacheAt) < 1200) {
return { ...this.workspaceSourcesCache };
}
const response = await this.apiFetch('/api/workspace/py-sources');
if (!response.ok) {
throw new Error('Failed to load workspace sources');
}
const payload = await response.json().catch(() => ({}));
const files = payload && typeof payload.files === 'object' ? payload.files : {};
this.workspaceSourcesCache = files;
this.workspaceSourcesCacheAt = now;
return { ...files };
}
init() {
try {
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get('api_key');
const workspaceUserId = params.get('workspace_user_id');
if (fromQuery) {
sessionStorage.setItem('python-editor.api_key', fromQuery);
}
if (workspaceUserId && /^\d+$/.test(workspaceUserId)) {
this.workspaceUserId = workspaceUserId;
}
} catch (_error) {
// Ignore query / storage failures.
}
this.loadSessionState();
this.setupEditor();
this.setupEventListeners();
this.setupDevAutoReload();
this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
this.updateWorkspaceBanner();
this.prewarmPyWorker();
this.fetchViewerRole()
.finally(() =>
this.loadInitialDirectoryState().then(() =>
this.restoreSessionTabs().then(() => this.ensureDefaultMainOpen())
)
);
}
async fetchViewerRole() {
if (this.localMode) {
this.isSuperuser = false;
return;
}
try {
const me = await fetch('/api/auth/me', { credentials: 'include' });
if (!me.ok) {
this.isSuperuser = false;
return;
}
const data = await me.json().catch(() => ({}));
this.isSuperuser = Boolean(data && data.user && data.user.is_superuser);
} catch (_error) {
this.isSuperuser = false;
}
}
async updateWorkspaceBanner() {
const badge = document.getElementById('workspace-badge');
if (!badge) return;
if (this.localMode) {
badge.innerHTML = '';
const label = document.createElement('span');
label.className = 'workspace-badge-label';
label.textContent = 'Local · IndexedDB';
badge.appendChild(label);
if (supportsFolderPicker()) {
const pick = document.createElement('button');
pick.type = 'button';
pick.className = 'workspace-badge-action';
pick.textContent = 'Folder…';
pick.title = 'Save files to a folder on this device';
pick.addEventListener('click', () => this.pickLocalFolder());
badge.appendChild(pick);
} else {
const why = document.createElement('span');
why.className = 'workspace-badge-note';
why.textContent = '(no folder picker)';
why.title =
'window.showDirectoryPicker is not exposed in this browser context.\n' +
'• Firefox / Safari: not implemented (use Chromium-based browser).\n' +
'• Brave: enable brave://flags/#file-system-access-api or relax Shields for this site.\n' +
'• HTTPS-only requirement: must be served from localhost or https://.\n' +
'Files keep saving to IndexedDB; use Export to download a ZIP.';
badge.appendChild(why);
}
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.className = 'workspace-badge-action';
exportBtn.textContent = 'Export';
exportBtn.title = 'Download every workspace file as a .zip';
exportBtn.addEventListener('click', () => this.exportWorkspaceZip());
badge.appendChild(exportBtn);
const importBtn = document.createElement('button');
importBtn.type = 'button';
importBtn.className = 'workspace-badge-action';
importBtn.textContent = 'Import';
importBtn.title = 'Upload a .zip — its files land in code/ (overwrites on conflict)';
importBtn.addEventListener('click', () => this.importWorkspaceZip());
badge.appendChild(importBtn);
const resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.className = 'workspace-badge-action';
resetBtn.textContent = 'Reset demos';
resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)';
resetBtn.addEventListener('click', () => this.resetDemoFiles());
badge.appendChild(resetBtn);
const exit = document.createElement('button');
exit.type = 'button';
exit.className = 'workspace-badge-exit';
exit.textContent = 'Exit';
exit.title = 'Leave local mode and return to the home page';
exit.addEventListener('click', () => {
if (!confirm('Leave local mode? Your files stay in this browser; you can come back later.')) return;
exitLocalMode();
window.location.href = '/';
});
badge.appendChild(exit);
badge.classList.remove('hidden');
try {
const info = await this.localWorkspace.describeStorage();
if (info) {
if (info.mode === 'filesystem') {
label.textContent = `Local · ${info.label}`;
const swap = document.createElement('button');
swap.type = 'button';
swap.className = 'workspace-badge-action';
swap.textContent = 'IndexedDB';
swap.title = 'Switch storage back to in-browser IndexedDB';
swap.addEventListener('click', () => this.useIndexedDbStorage());
badge.insertBefore(swap, exit);
} else if (info.pendingReconnect) {
label.textContent = `Local · IndexedDB (folder “${info.pendingFolderName}” needs reconnect)`;
const reconnect = document.createElement('button');
reconnect.type = 'button';
reconnect.className = 'workspace-badge-action';
reconnect.textContent = 'Reconnect';
reconnect.title = 'Re-grant read/write access to the previously picked folder';
reconnect.addEventListener('click', () => this.reconnectLocalFolder());
badge.insertBefore(reconnect, exit);
} else {
label.textContent = 'Local · IndexedDB';
}
}
} catch (_err) {
// Best-effort label refresh.
}
return;
}
badge.innerHTML = '';
const label = document.createElement('span');
label.className = 'workspace-badge-label';
label.textContent = this.workspaceUserId
? `Workspace: user ${this.workspaceUserId}`
: 'Server workspace';
badge.appendChild(label);
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.className = 'workspace-badge-action';
exportBtn.textContent = 'Export';
exportBtn.title = 'Download every workspace file as a .zip';
exportBtn.addEventListener('click', () => this.exportWorkspaceZip());
badge.appendChild(exportBtn);
const importBtn = document.createElement('button');
importBtn.type = 'button';
importBtn.className = 'workspace-badge-action';
importBtn.textContent = 'Import';
importBtn.title = 'Upload a .zip — its files land in code/ (overwrites on conflict)';
importBtn.addEventListener('click', () => this.importWorkspaceZip());
badge.appendChild(importBtn);
const resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.className = 'workspace-badge-action';
resetBtn.textContent = 'Reset demos';
resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)';
resetBtn.addEventListener('click', () => this.resetDemoFiles());
badge.appendChild(resetBtn);
badge.classList.remove('hidden');
}
async resetDemoFiles() {
let manifest;
try {
const r = await fetch('/static/bundled-demos/manifest.json', { cache: 'no-store' });
if (!r.ok) throw new Error(`manifest fetch ${r.status}`);
manifest = await r.json();
} catch (err) {
this.showError(
`Could not load demo manifest: ${err && err.message ? err.message : err}`
);
return;
}
const names = Array.isArray(manifest && manifest.files) ? manifest.files.slice() : [];
if (!names.length) {
this.showError('No demos in bundle');
return;
}
if (!confirm(
`Reset ${names.length} demo file${names.length === 1 ? '' : 's'}?\n\n` +
names.map((n) => ` • code/${n}`).join('\n') +
'\n\nAny edits you made to these files will be overwritten. Other ' +
'files (main.py, your own scripts) are not touched.'
)) return;
let written = 0;
let failed = 0;
for (const name of names) {
try {
const r = await fetch(`/static/bundled-demos/${encodeURIComponent(name)}`, {
cache: 'no-store',
});
if (!r.ok) {
failed += 1;
continue;
}
const content = await r.text();
const w = await this.apiFetch(`/api/file/code/${encodeURIComponent(name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (w && w.ok) {
written += 1;
} else {
failed += 1;
}
} catch (_err) {
failed += 1;
}
}
this.workspaceSourcesCache = null;
this.directoryCache.clear();
/* If a stale demo is currently open in a tab, refresh the editor
contents from disk so the user sees the new version. */
for (const name of names) {
const path = `code/${name}`;
if (this.findTab(path)) {
try {
const fr = await this.apiFetch(`/api/file/${encodeURIComponent(path)}`);
if (fr && fr.ok) {
const fd = await fr.json();
const tab = this.findTab(path);
if (tab) {
tab.content = typeof fd.content === 'string' ? fd.content : '';
tab.savedContent = tab.content;
if (this.activeTabPath === path) {
this.ignoreNextChange = true;
this.editor.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: tab.content,
},
});
}
}
}
} catch (_err) {
// Skip refresh failure; user can re-open manually.
}
}
}
await this.loadInitialDirectoryState();
if (failed) {
this.showError(`Reset ${written} demo${written === 1 ? '' : 's'} (${failed} failed)`);
} else {
this.showSuccess(`Reset ${written} demo${written === 1 ? '' : 's'}`);
}
}
async pickLocalFolder() {
if (!this.localWorkspace) return;
try {
await this.localWorkspace.pickDirectory();
} catch (err) {
const msg = err && err.message ? err.message : String(err);
if (!/aborted|cancel/i.test(msg)) {
this.showError(`Could not switch to folder: ${msg}`);
}
return;
}
this.workspaceSourcesCache = null;
this.directoryCache.clear();
this.expandedDirs = new Set(['', 'code']);
await this.loadInitialDirectoryState();
await this.updateWorkspaceBanner();
this.showSuccess('Now saving to the picked folder');
}
async useIndexedDbStorage() {
if (!this.localWorkspace) return;
if (!confirm('Switch back to in-browser storage? Your folder files stay on disk untouched.')) return;
try {
await this.localWorkspace.useIndexedDb();
} catch (err) {
this.showError(`Could not switch storage: ${err && err.message ? err.message : err}`);
return;
}
this.workspaceSourcesCache = null;
this.directoryCache.clear();
this.expandedDirs = new Set(['', 'code']);
await this.loadInitialDirectoryState();
await this.updateWorkspaceBanner();
this.showSuccess('Storage: IndexedDB');
}
/**
* Recursively read every file under `code/` via apiFetch — works for both
* local-mode (LocalWorkspaceClient dispatches it) and server-mode (FastAPI
* serves it). `lib/` is excluded since it ships with the app and is
* read-only anyway.
*/
async _walkUserFiles() {
const out = {};
const queue = ['code'];
while (queue.length) {
const path = queue.shift();
let resp;
try {
resp = await this.apiFetch(`/api/files?path=${encodeURIComponent(path)}`);
} catch (_err) {
continue;
}
if (!resp || !resp.ok) continue;
let data;
try {
data = await resp.json();
} catch (_err) {
continue;
}
for (const entry of data.files || []) {
const child = path ? `${path}/${entry.name}` : entry.name;
if (entry.is_directory) {
queue.push(child);
} else {
try {
const fr = await this.apiFetch(`/api/file/${encodeURIComponent(child)}`);
if (fr && fr.ok) {
const fd = await fr.json();
out[child] = typeof fd.content === 'string' ? fd.content : '';
}
} catch (_err) {
// Skip unreadable files.
}
}
}
}
return out;
}
async exportWorkspaceZip() {
let files;
try {
files = await this._walkUserFiles();
} catch (err) {
this.showError(`Could not export: ${err && err.message ? err.message : err}`);
return;
}
const entries = Object.entries(files).map(([name, content]) => ({ name, content }));
if (!entries.length) {
entries.push({
name: 'README.txt',
content: 'Your LED Editor workspace was empty when this archive was created.\n',
});
}
const blob = createZip(entries);
const stamp = new Date().toISOString().slice(0, 10);
const filename = `led-editor-workspace-${stamp}.zip`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 30000);
let bytes = 0;
for (const e of entries) bytes += e.content.length;
const sizeKb = (bytes / 1024).toFixed(1);
const fileCount = Object.keys(files).length;
this.showSuccess(
`Exported ${fileCount} file${fileCount === 1 ? '' : 's'} (${sizeKb} KB)`
);
}
/**
* Open a hidden file input, read the chosen `.zip`, and write each entry
* back into the workspace through apiFetch. Same path-routing as export:
* local-mode keeps it in-browser, server-mode uploads it.
*
* Conflict policy = overwrite. The user gets a confirm prompt that lists
* the file count first, and a final summary toast.
*/
async importWorkspaceZip() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip,application/zip';
input.style.display = 'none';
document.body.appendChild(input);
const file = await new Promise((resolve) => {
input.addEventListener('change', () => resolve(input.files && input.files[0]), { once: true });
input.click();
});
document.body.removeChild(input);
if (!file) return;
let entries;
try {
const buf = await file.arrayBuffer();
entries = await readZip(buf);
} catch (err) {
this.showError(`Could not read ZIP: ${err && err.message ? err.message : err}`);
return;
}
/* Sanitize paths and force every entry under `code/`. Anything that
points at `lib/`, an absolute path, or escapes via `..` gets remapped
so the import can never silently overwrite library stubs or wander
outside the workspace. */
const safeEntries = [];
for (const entry of entries) {
let raw = String(entry.name || '').replace(/\\/g, '/').replace(/^\/+/, '');
const parts = raw.split('/').filter((seg) => seg && seg !== '.' && seg !== '..');
if (!parts.length) continue;
if (parts[0] === 'lib') continue;
if (parts[0] !== 'code') parts.unshift('code');
const path = parts.join('/');
safeEntries.push({ path, content: entry.content });
}
if (!safeEntries.length) {
this.showError('ZIP contained no importable files');
return;
}
const fileCount = safeEntries.length;
if (!confirm(
`Import ${fileCount} file${fileCount === 1 ? '' : 's'} from "${file.name}"?\n\n` +
`Existing files with the same path will be overwritten. ` +
`Files in lib/ are skipped.`
)) return;
let written = 0;
let failed = 0;
for (const entry of safeEntries) {
try {
const r = await this.apiFetch(`/api/file/${encodeURIComponent(entry.path)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: entry.content }),
});
if (r && r.ok) {
written += 1;
} else {
failed += 1;
}
} catch (_err) {
failed += 1;
}
}
this.workspaceSourcesCache = null;
this.directoryCache.clear();
this.expandedDirs = new Set(['', 'code']);
await this.loadInitialDirectoryState();
if (failed) {
this.showError(`Imported ${written} file${written === 1 ? '' : 's'} (${failed} failed)`);
} else {
this.showSuccess(`Imported ${written} file${written === 1 ? '' : 's'}`);
}
}
async reconnectLocalFolder() {
if (!this.localWorkspace) return;
let ok = false;
try {
ok = await this.localWorkspace.reconnectSavedFolder();
} catch (err) {
this.showError(`Could not reconnect folder: ${err && err.message ? err.message : err}`);
return;
}
if (!ok) {
this.showError('Permission denied. Pick the folder again to continue.');
return;
}
this.workspaceSourcesCache = null;
this.directoryCache.clear();
this.expandedDirs = new Set(['', 'code']);
await this.loadInitialDirectoryState();
await this.updateWorkspaceBanner();
this.showSuccess('Folder reconnected');
}
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();
}
async apiFetch(url, init = {}) {
if (this.localWorkspace && typeof url === 'string') {
const local = await this.localWorkspace.dispatch(url, init);
if (local) return local;
}
const next = { ...init };
const headers = new Headers(init.headers || {});
const key = sessionStorage.getItem('python-editor.api_key');
if (key) {
headers.set('Authorization', `Bearer ${key}`);
}
next.headers = headers;
next.credentials = 'include';
let finalUrl = url;
if (this.workspaceUserId && typeof url === 'string' && url.startsWith('/api/')) {
try {
const parsed = new URL(url, window.location.origin);
if (!parsed.searchParams.has('workspace_user_id')) {
parsed.searchParams.set('workspace_user_id', this.workspaceUserId);
}
finalUrl = parsed.pathname + parsed.search;
} catch (_error) {
// ignore URL parse failure and use original
}
}
return fetch(finalUrl, next);
}
disposePyWorker() {
for (const [, pending] of this.pyWorkerHandlers) {
pending.reject(new Error('Worker terminated'));
}
this.pyWorkerHandlers.clear();
if (this.pyWorker) {
this.pyWorker.terminate();
this.pyWorker = null;
}
this.pyodideInited = false;
}
ensurePyWorker() {
if (!this.pyWorker) {
const worker = new Worker('/static/pyodide-worker.js?v=8');
this.pyWorker = worker;
worker.onmessage = (event) => this.handlePyWorkerMessage(event);
}
return this.pyWorker;
}
handlePyWorkerMessage(event) {
const data = event.data;
if (!data || typeof data !== 'object') {
return;
}
if (data.type === 'io') {
if (this.isPythonRunning) {
const prefix = data.stream === 'stderr' ? '[stderr] ' : '';
this.appendConsoleOutput([`${prefix}${data.text || ''}`]);
}
return;
}
const { id } = data;
if (id == null || !this.pyWorkerHandlers.has(id)) {
return;
}
const pending = this.pyWorkerHandlers.get(id);
this.pyWorkerHandlers.delete(id);
if (data.ok) {
pending.resolve(data);
} else {
pending.reject(new Error(data.error || 'Pyodide worker error'));
}
}
callPyWorker(type, payload) {
return new Promise((resolve, reject) => {
const worker = this.ensurePyWorker();
const id = ++this.pyWorkerMsgId;
this.pyWorkerHandlers.set(id, { resolve, reject });
worker.postMessage({ id, type, payload });
});
}
async ensurePyodideReady() {
if (this.pyodideInited) {
return;
}
await this.callPyWorker('init', {
adcSab: this.getAdcSab(),
serialSab: this.getSerialSab(),
pinOutSab: this.getPinOutSab(),
pinInSab: this.getPinInSab(),
});
this.pyodideInited = true;
}
getAdcSab() {
if (this.adcSab) {
return this.adcSab;
}
if (typeof SharedArrayBuffer === 'undefined') {
return null;
}
try {
this.adcSab = new SharedArrayBuffer(this.adcPinCount * 4);
this.adcView = new Int32Array(this.adcSab);
} catch (_err) {
this.adcSab = null;
this.adcView = null;
}
return this.adcSab;
}
getSerialSab() {
if (this.serialSab) {
return this.serialSab;
}
if (typeof SharedArrayBuffer === 'undefined') {
return null;
}
try {
this.serialSab = new SharedArrayBuffer(8 + this.serialCapacity);
this.serialIndices = new Int32Array(this.serialSab, 0, 2);
this.serialData = new Uint8Array(this.serialSab, 8, this.serialCapacity);
} catch (_err) {
this.serialSab = null;
this.serialIndices = null;
this.serialData = null;
}
return this.serialSab;
}
getPinOutSab() {
if (this.pinOutSab) return this.pinOutSab;
if (typeof SharedArrayBuffer === 'undefined') return null;
try {
this.pinOutSab = new SharedArrayBuffer(this.pinCount * 4);
this.pinOutView = new Int32Array(this.pinOutSab);
} catch (_err) {
this.pinOutSab = null;
this.pinOutView = null;
}
return this.pinOutSab;
}
getPinInSab() {
if (this.pinInSab) return this.pinInSab;
if (typeof SharedArrayBuffer === 'undefined') return null;
try {
this.pinInSab = new SharedArrayBuffer(this.pinCount * 4);
this.pinInView = new Int32Array(this.pinInSab);
} catch (_err) {
this.pinInSab = null;
this.pinInView = null;
}
return this.pinInSab;
}
pushSerialBytes(bytes) {
if (!this.serialIndices || !this.serialData) return 0;
const cap = this.serialCapacity;
let written = 0;
for (const byte of bytes) {
const w = Atomics.load(this.serialIndices, 1);
const r = Atomics.load(this.serialIndices, 0);
const next = (w + 1) % cap;
if (next === r) {
break;
}
this.serialData[w] = byte & 0xff;
Atomics.store(this.serialIndices, 1, next);
written += 1;
}
return written;
}
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);
if (!raw) return;
const session = JSON.parse(raw);
this.savedSession = session;
} catch (_error) {
this.savedSession = null;
}
}
saveSessionState() {
try {
const runMainCheckbox = document.getElementById('run-main-checkbox');
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
const sidebar = document.getElementById('sidebar');
const consoleContainer = document.getElementById('console-container');
const session = {
openTabPaths: this.openTabs.map((tab) => tab.path),
activeTabPath: this.activeTabPath,
runMainChecked: Boolean(runMainCheckbox && runMainCheckbox.checked),
panel16x16Checked: Boolean(panelModeCheckbox && panelModeCheckbox.checked),
expandedDirs: Array.from(this.expandedDirs || []),
selectedPath: this.selectedPath || '',
selectedIsDirectory: Boolean(this.selectedIsDirectory),
sidebarCollapsed: Boolean(sidebar && sidebar.classList.contains('is-collapsed')),
consoleCollapsed: Boolean(consoleContainer && consoleContainer.classList.contains('is-collapsed')),
};
localStorage.setItem(this.sessionStorageKey, JSON.stringify(session));
} catch (_error) {
// Ignore storage failures.
}
}
applyPersistedPanelState() {
const session = this.savedSession;
if (!session) return;
if (session.consoleCollapsed) {
this.setConsoleCollapsed(true);
}
if (session.sidebarCollapsed && !this.isMobileViewport()) {
this.setDesktopSidebarCollapsed(true);
}
}
async restoreSessionTabs() {
const session = this.savedSession;
this.savedSession = null;
if (!session) {
return;
}
await this.restoreExplorerState(session);
if (!Array.isArray(session.openTabPaths) || session.openTabPaths.length === 0) {
return;
}
for (const path of session.openTabPaths) {
await this.openFile(path);
}
if (session.activeTabPath && this.findTab(session.activeTabPath)) {
this.switchToTab(session.activeTabPath);
}
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();
}
async ensureDefaultMainOpen() {
if (this.openTabs.length > 0) {
return;
}
await this.openFile('code/main.py');
this.selectedPath = 'code/main.py';
this.selectedIsDirectory = false;
this.renderFileTree();
}
async restoreExplorerState(session) {
const expanded = Array.isArray(session.expandedDirs)
? session.expandedDirs.filter((path) => typeof path === 'string')
: [];
this.expandedDirs = new Set(expanded.length ? expanded : ['']);
if (!this.expandedDirs.has('')) {
this.expandedDirs.add('');
}
this.selectedPath = typeof session.selectedPath === 'string' ? session.selectedPath : '';
this.selectedIsDirectory = Boolean(session.selectedIsDirectory);
for (const path of this.expandedDirs) {
if (!path || this.directoryCache.has(path)) continue;
await this.loadDirectory(path, { suppressError: true });
}
this.renderFileTree();
}
getVisibleTopLevelNames() {
/* lib is shared read-only for everyone; browsing is allowed, saves are blocked in API/UI. */
return new Set(['code', 'lib']);
}
getDefaultEditableRoot() {
return 'code';
}
async loadInitialDirectoryState() {
await this.loadDirectory('');
await this.ensureFolderExists('code');
this.selectedPath = 'code';
this.selectedIsDirectory = true;
this.expandedDirs.add('code');
await this.loadDirectory('code', { suppressError: true });
await this.loadDirectory('lib', { suppressError: true });
this.renderFileTree();
}
async ensureFolderExists(folderPath) {
try {
const existing = await this.apiFetch(`/api/files?path=${encodeURIComponent(folderPath)}`);
if (existing.ok) {
return true;
}
if (existing.status && existing.status !== 404) {
return false;
}
const response = await this.apiFetch(`/api/folder/new/${encodeURIComponent(folderPath)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: folderPath })
});
return response.ok;
} catch (_error) {
return false;
}
}
getTreeRootPath() {
return '';
}
normalizeRelativePathInput(pathValue) {
let normalized = (pathValue || '').trim().replace(/^\/+/, '');
const parts = normalized.split('/').filter(Boolean);
while (parts.length >= 2 && parts[0] === 'code' && parts[1] === 'code') {
parts.shift();
}
return parts.join('/');
}
resetDirectoryState() {
this.directoryCache.clear();
this.expandedDirs = new Set(['']);
}
setupEditor() {
this.editor = new EditorView({
doc: '',
extensions: [basicSetup, this.languageCompartment.of([])],
parent: document.getElementById('editor')
});
this.editor.dom.addEventListener('keydown', (event) => {
if (this.currentFileReadOnly) {
const blocksEditKey = event.key.length === 1 || ['Backspace', 'Delete', 'Enter', 'Tab'].includes(event.key);
if (blocksEditKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
return;
}
}
if (this.completionOpen) {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.moveCompletionSelection(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.moveCompletionSelection(-1);
return;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
this.applySelectedCompletion();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.hideCompletionDropdown();
return;
}
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
this.saveFile();
return;
}
if ((event.ctrlKey || event.metaKey) && event.code === 'Space') {
event.preventDefault();
this.showCompletionDropdown();
return;
}
if (event.key === 'Tab') {
event.preventDefault();
const selection = this.editor.state.selection.main;
const from = Math.min(selection.from, selection.to);
const to = Math.max(selection.from, selection.to);
const indentText = ' ';
this.editor.dispatch({
changes: { from, to, insert: indentText },
selection: { anchor: from + indentText.length }
});
}
});
this.editor.dispatch = ((dispatch) => (transaction) => {
dispatch.call(this.editor, transaction);
if (!this.ignoreNextChange && transaction.docChanged) {
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 = await this.getWorkspacePythonSources();
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);
return {
line: lineInfo.number,
column: cursor - lineInfo.from
};
}
getCurrentPrefix(cursor) {
const content = this.editor.state.doc.toString();
let start = cursor;
while (start > 0 && /[A-Za-z0-9_]/.test(content[start - 1])) {
start -= 1;
}
return {
prefixStart: start,
prefix: content.slice(start, cursor)
};
}
async fetchCompletions() {
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
return [];
}
const { line, column } = this.getCursorLineAndColumn();
const requestId = ++this.completionRequestId;
try {
await this.ensurePyodideReady();
const extraFiles = await this.getWorkspacePythonSources();
for (const tab of this.openTabs) {
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
extraFiles[tab.path] = tab.content;
}
}
const data = await this.callPyWorker('complete', {
path: this.currentFilePath,
content: this.editor.state.doc.toString(),
line,
column,
max_results: 20,
extra_files: extraFiles
});
if (requestId !== this.completionRequestId) {
return [];
}
return Array.isArray(data.completions) ? data.completions : [];
} catch (_error) {
return [];
}
}
renderCompletionDropdown() {
const dropdown = document.getElementById('completion-dropdown');
if (!dropdown) return;
dropdown.innerHTML = this.completionItems.map((item, index) => {
const activeClass = index === this.completionIndex ? 'active' : '';
const itemType = item.type || 'value';
return `
${item.name}
${itemType}
`;
}).join('');
dropdown.querySelectorAll('.completion-item').forEach((node) => {
node.addEventListener('mousedown', (event) => {
event.preventDefault();
this.completionIndex = Number(node.dataset.index || 0);
this.applySelectedCompletion();
});
});
}
positionCompletionDropdown() {
const dropdown = document.getElementById('completion-dropdown');
if (!dropdown) return;
const cursor = this.editor.state.selection.main.head;
const coords = this.editor.coordsAtPos(cursor);
const editorRect = this.editor.dom.getBoundingClientRect();
if (!coords || !editorRect) return;
dropdown.style.top = `${coords.bottom - editorRect.top + 4}px`;
dropdown.style.left = `${coords.left - editorRect.left}px`;
}
moveCompletionSelection(direction) {
if (!this.completionOpen || this.completionItems.length === 0) return;
const total = this.completionItems.length;
this.completionIndex = (this.completionIndex + direction + total) % total;
this.renderCompletionDropdown();
}
hideCompletionDropdown() {
const dropdown = document.getElementById('completion-dropdown');
if (dropdown) {
dropdown.style.display = 'none';
dropdown.innerHTML = '';
}
this.completionOpen = false;
this.completionItems = [];
this.completionIndex = 0;
}
async showCompletionDropdown() {
const dropdown = document.getElementById('completion-dropdown');
if (!dropdown) return;
const completions = await this.fetchCompletions();
if (completions.length === 0) {
this.hideCompletionDropdown();
return;
}
const cursor = this.editor.state.selection.main.head;
const { prefix } = this.getCurrentPrefix(cursor);
const ranked = completions
.filter((item) => item && typeof item.name === 'string')
.sort((a, b) => {
const aStarts = prefix && a.name.startsWith(prefix) ? 1 : 0;
const bStarts = prefix && b.name.startsWith(prefix) ? 1 : 0;
return bStarts - aStarts;
});
if (ranked.length === 0) {
this.hideCompletionDropdown();
return;
}
this.completionItems = ranked;
this.completionIndex = 0;
this.completionOpen = true;
this.renderCompletionDropdown();
this.positionCompletionDropdown();
dropdown.style.display = 'block';
}
applySelectedCompletion() {
if (!this.completionOpen || this.completionItems.length === 0) return;
const selected = this.completionItems[this.completionIndex];
if (!selected || typeof selected.name !== 'string') return;
const cursor = this.editor.state.selection.main.head;
const { prefixStart } = this.getCurrentPrefix(cursor);
const isCallable = selected.type === 'function' || selected.type === 'method';
const insertText = isCallable ? `${selected.name}()` : selected.name;
const cursorOffset = isCallable ? selected.name.length + 1 : insertText.length;
this.editor.dispatch({
changes: {
from: prefixStart,
to: cursor,
insert: insertText
},
selection: { anchor: prefixStart + cursorOffset }
});
this.hideCompletionDropdown();
}
isMobileViewport() {
return typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
}
setMobileSidebarOpen(open) {
const sidebar = document.getElementById('sidebar');
const backdrop = document.getElementById('sidebar-backdrop');
const toggle = document.getElementById('sidebar-toggle');
if (!sidebar || !backdrop || !toggle) return;
sidebar.classList.toggle('is-open', open);
backdrop.classList.toggle('is-open', open);
backdrop.hidden = !open;
toggle.setAttribute('aria-expanded', String(open));
}
setDesktopSidebarCollapsed(collapsed) {
const sidebar = document.getElementById('sidebar');
const toggle = document.getElementById('sidebar-toggle');
if (!sidebar || !toggle) return;
sidebar.classList.toggle('is-collapsed', collapsed);
toggle.setAttribute('aria-expanded', String(!collapsed));
this.saveSessionState();
}
setupSidebarToggle() {
const toggle = document.getElementById('sidebar-toggle');
const backdrop = document.getElementById('sidebar-backdrop');
if (!toggle) return;
/* Initial state: mobile drawer starts closed; desktop sidebar starts open. */
if (this.isMobileViewport()) {
toggle.setAttribute('aria-expanded', 'false');
} else {
const sidebar = document.getElementById('sidebar');
const collapsed = Boolean(sidebar && sidebar.classList.contains('is-collapsed'));
toggle.setAttribute('aria-expanded', String(!collapsed));
}
toggle.addEventListener('click', () => {
const sidebar = document.getElementById('sidebar');
if (!sidebar) return;
if (this.isMobileViewport()) {
const isOpen = sidebar.classList.contains('is-open');
this.setMobileSidebarOpen(!isOpen);
} else {
const collapsed = sidebar.classList.contains('is-collapsed');
this.setDesktopSidebarCollapsed(!collapsed);
}
});
if (backdrop) {
backdrop.addEventListener('click', () => this.setMobileSidebarOpen(false));
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
const sidebar = document.getElementById('sidebar');
if (sidebar?.classList.contains('is-open')) {
this.setMobileSidebarOpen(false);
}
}
});
}
setConsoleCollapsed(collapsed) {
const container = document.getElementById('console-container');
const button = document.getElementById('console-toggle');
if (!container || !button) return;
container.classList.toggle('is-collapsed', collapsed);
button.setAttribute('aria-expanded', String(!collapsed));
this.saveSessionState();
}
setupConsoleToggle() {
const button = document.getElementById('console-toggle');
if (!button) return;
button.addEventListener('click', () => {
const container = document.getElementById('console-container');
const collapsed = container?.classList.contains('is-collapsed');
this.setConsoleCollapsed(!collapsed);
});
}
setupHeaderMenu() {
const menu = document.getElementById('header-menu');
if (!menu) return;
document.addEventListener('click', (event) => {
if (!menu.open) return;
if (!menu.contains(event.target)) {
menu.open = false;
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && menu.open) {
menu.open = false;
}
});
}
setupEventListeners() {
this.setupSidebarToggle();
this.setupConsoleToggle();
this.applyPersistedPanelState();
const persistSession = () => {
this.saveSessionState();
};
window.addEventListener('beforeunload', persistSession);
window.addEventListener('pagehide', persistSession);
/* Stop button inside the 16x16 popup posts back so mobile users (where the popup
takes over the screen) can halt without returning to the editor. */
window.addEventListener('message', (event) => {
const data = event && event.data;
if (data && data.type === 'neopixel-popup-stop' && this.isPythonRunning) {
this.stopPython();
}
});
window.addEventListener('keydown', (event) => {
if (!this.completionOpen) return;
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
event.stopPropagation();
this.applySelectedCompletion();
}
}, true);
document.addEventListener('keydown', (event) => {
if (this.completionOpen) {
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
this.applySelectedCompletion();
return;
}
}
if (event.key === 'F5') {
event.preventDefault();
this.runPython();
return;
}
const isMod = event.ctrlKey || event.metaKey;
if (!isMod) return;
const key = event.key.toLowerCase();
if (event.code === 'Space') {
event.preventDefault();
this.showCompletionDropdown();
return;
}
if (key === 's') {
event.preventDefault();
this.saveFile();
return;
}
if (key === 'z') {
event.preventDefault();
this.performUndo();
return;
}
if (key === 'y') {
event.preventDefault();
this.performRedo();
}
});
document.addEventListener('mousedown', (event) => {
if (!this.completionOpen) return;
const dropdown = document.getElementById('completion-dropdown');
if (!dropdown) return;
if (!dropdown.contains(event.target)) {
this.hideCompletionDropdown();
}
});
document.getElementById('refresh-btn').addEventListener('click', () => {
this.resetDirectoryState();
this.loadDirectory(this.getTreeRootPath());
});
document.getElementById('new-file-btn').addEventListener('click', () => {
this.showNewFileModal();
});
document.getElementById('new-folder-btn').addEventListener('click', () => {
this.createNewFolder();
});
document.getElementById('delete-selected-btn').addEventListener('click', () => {
this.deleteSelected();
});
document.getElementById('run-btn').addEventListener('click', () => {
this.runPython();
});
document.getElementById('stop-btn').addEventListener('click', () => {
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();
});
document.getElementById('cancel-create-btn').addEventListener('click', () => {
this.hideNewFileModal();
});
document.getElementById('new-filename').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.createNewFile();
}
});
document.getElementById('new-file-modal').addEventListener('click', (e) => {
if (e.target.id === 'new-file-modal') {
this.hideNewFileModal();
}
});
this.setupHeaderMenu();
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);
}
});
}
}
performUndo() {
document.execCommand('undo');
}
performRedo() {
document.execCommand('redo');
}
renderTabs() {
const tabsContainer = document.getElementById('tabs');
if (this.openTabs.length === 0) {
tabsContainer.innerHTML = '';
return;
}
tabsContainer.innerHTML = this.openTabs.map((tab) => {
const activeClass = tab.path === this.activeTabPath ? 'active' : '';
const modifiedMark = tab.isModified ? '*' : '';
const displayPath = tab.path.startsWith('code/') ? tab.path.slice('code/'.length) : tab.path;
return `
${displayPath}${modifiedMark}
`;
}).join('');
tabsContainer.querySelectorAll('.tab').forEach((tabElement) => {
tabElement.addEventListener('click', (event) => {
if (event.target.classList.contains('tab-close')) {
return;
}
this.switchToTab(tabElement.dataset.path);
});
});
tabsContainer.querySelectorAll('.tab-close').forEach((closeButton) => {
closeButton.addEventListener('click', (event) => {
event.stopPropagation();
this.closeTab(closeButton.dataset.path);
});
});
this.saveSessionState();
}
findTab(path) {
return this.openTabs.find((tab) => tab.path === path);
}
isReadOnlyPath(path) {
return typeof path === 'string' && path.startsWith('lib/');
}
setEditorReadOnly(isReadOnly) {
this.currentFileReadOnly = Boolean(isReadOnly);
if (!this.editor || !this.editor.contentDOM) return;
this.editor.contentDOM.setAttribute('contenteditable', this.currentFileReadOnly ? 'false' : 'true');
}
updateActiveTabContent() {
if (!this.activeTabPath) return;
const tab = this.findTab(this.activeTabPath);
if (!tab) return;
tab.content = this.editor.state.doc.toString();
tab.isModified = true;
this.renderTabs();
}
switchToTab(path) {
this.hideCompletionDropdown();
const tab = this.findTab(path);
if (!tab) return;
this.activeTabPath = path;
this.currentFilePath = path;
this.ignoreNextChange = true;
this.editor.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: tab.content
}
});
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 {
this.markAsSaved();
}
this.renderTabs();
this.selectedPath = path;
this.selectedIsDirectory = false;
this.renderFileTree();
}
closeTab(path) {
this.hideCompletionDropdown();
const index = this.openTabs.findIndex((tab) => tab.path === path);
if (index === -1) return;
const tab = this.openTabs[index];
if (tab.isModified && !confirm(`Close "${path}" with unsaved changes?`)) {
return;
}
this.openTabs.splice(index, 1);
if (this.activeTabPath === path) {
const nextTab = this.openTabs[index] || this.openTabs[index - 1];
if (nextTab) {
this.switchToTab(nextTab.path);
} else {
this.activeTabPath = null;
this.currentFilePath = null;
this.setEditorReadOnly(false);
this.setLanguageForPath('');
this.ignoreNextChange = true;
this.editor.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: ''
}
});
this.ignoreNextChange = false;
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();
this.selectedPath = null;
this.selectedIsDirectory = false;
this.renderFileTree();
}
}
this.renderTabs();
this.saveSessionState();
}
async loadDirectory(path = "", options = {}) {
const { suppressError = false } = options;
try {
const response = await this.apiFetch(`/api/files?path=${encodeURIComponent(path)}`);
if (!response.ok) {
throw new Error(`Failed to load directory: ${response.statusText}`);
}
const data = await response.json();
this.directoryCache.set(path, data.files || []);
this.renderFileTree();
return true;
} catch (error) {
if (!suppressError) {
console.error('Error loading file tree:', error);
this.showError('Failed to load files');
}
return false;
}
}
renderFileTree() {
const fileTreeElement = document.getElementById('file-tree');
const treeRootPath = this.getTreeRootPath();
let rootFiles = this.directoryCache.get(treeRootPath) || [];
if (!treeRootPath) {
const visibleNames = this.getVisibleTopLevelNames();
if (visibleNames) {
rootFiles = rootFiles.filter((file) => visibleNames.has(file.name));
}
}
if (rootFiles.length === 0) {
fileTreeElement.innerHTML = `No files found
`;
} else {
const initialDepth = treeRootPath ? 1 : 0;
fileTreeElement.innerHTML = this.renderDirectoryHtml(treeRootPath, initialDepth, rootFiles);
}
fileTreeElement.querySelectorAll('.file-item').forEach((item) => {
item.addEventListener('click', async () => {
const path = item.dataset.path;
const isDirectory = item.dataset.isDirectory === 'true';
this.selectedPath = path;
this.selectedIsDirectory = isDirectory;
if (isDirectory && path) {
await this.toggleDirectory(path);
} else if (!isDirectory) {
await this.openFile(path);
this.renderFileTree();
if (this.isMobileViewport()) {
this.setMobileSidebarOpen(false);
}
} else {
this.renderFileTree();
}
});
});
fileTreeElement.querySelectorAll('.file-item').forEach((item) => {
item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', () => {
this.draggedItemPath = item.dataset.path || null;
this.draggedItemIsDirectory = item.dataset.isDirectory === 'true';
item.classList.add('dragging-file');
});
item.addEventListener('dragend', () => {
this.clearDragHoverExpand();
this.draggedItemPath = null;
this.draggedItemIsDirectory = false;
item.classList.remove('dragging-file');
fileTreeElement.querySelectorAll('.file-item.drag-target').forEach((node) => {
node.classList.remove('drag-target');
});
});
});
fileTreeElement.querySelectorAll('.file-item[data-is-directory="true"]').forEach((item) => {
item.addEventListener('dragover', (event) => {
if (!this.draggedItemPath) return;
event.preventDefault();
item.classList.add('drag-target');
const targetPath = item.dataset.path || "";
this.scheduleDragHoverExpand(targetPath);
});
item.addEventListener('dragleave', () => {
item.classList.remove('drag-target');
this.clearDragHoverExpand();
});
item.addEventListener('drop', async (event) => {
event.preventDefault();
item.classList.remove('drag-target');
this.clearDragHoverExpand();
const destination = item.dataset.path || "";
await this.movePathToFolder(this.draggedItemPath, this.draggedItemIsDirectory, destination);
});
});
fileTreeElement.addEventListener('dragover', (event) => {
if (!this.draggedItemPath) return;
const targetItem = event.target.closest('.file-item');
if (!targetItem) {
event.preventDefault();
}
});
fileTreeElement.addEventListener('drop', async (event) => {
const targetItem = event.target.closest('.file-item');
if (targetItem) return;
if (!this.draggedItemPath) return;
event.preventDefault();
this.clearDragHoverExpand();
const dropRoot = treeRootPath || this.getDefaultEditableRoot();
await this.movePathToFolder(this.draggedItemPath, this.draggedItemIsDirectory, dropRoot);
});
}
clearDragHoverExpand() {
if (this.dragHoverExpandTimer) {
clearTimeout(this.dragHoverExpandTimer);
this.dragHoverExpandTimer = null;
}
this.dragHoverTargetPath = null;
}
scheduleDragHoverExpand(targetPath) {
if (!targetPath || this.expandedDirs.has(targetPath)) {
this.clearDragHoverExpand();
return;
}
if (this.dragHoverTargetPath === targetPath && this.dragHoverExpandTimer) {
return;
}
this.clearDragHoverExpand();
this.dragHoverTargetPath = targetPath;
this.dragHoverExpandTimer = setTimeout(async () => {
if (!this.draggedItemPath || this.dragHoverTargetPath !== targetPath) return;
this.expandedDirs.add(targetPath);
if (!this.directoryCache.has(targetPath)) {
await this.loadDirectory(targetPath);
} else {
this.renderFileTree();
}
this.clearDragHoverExpand();
}, 500);
}
renderDirectoryHtml(basePath, depth, overrideFiles = null) {
const files = overrideFiles || this.directoryCache.get(basePath) || [];
let html = "";
for (const file of files) {
const fullPath = basePath ? `${basePath}/${file.name}` : file.name;
const isDir = file.is_directory;
const selectedClass = this.selectedPath === fullPath ? "selected" : "";
const indent = depth * 16;
/* The top-level "code" folder is the user's editable root; show it as "/" while keeping the underlying path "code" for the API. */
const displayName = depth === 0 && !basePath && file.is_directory && file.name === 'code'
? '/'
: file.name;
if (isDir) {
const expanded = this.expandedDirs.has(fullPath);
html += `
${expanded ? "📂" : "📁"}
${displayName}
`;
if (expanded) {
html += this.renderDirectoryHtml(fullPath, depth + 1);
}
} else {
const size = file.size ? ` (${this.formatFileSize(file.size)})` : '';
html += `
${this.getFileIcon(file.name)}
${displayName}
${size}
`;
}
}
return html;
}
async toggleDirectory(path) {
if (this.expandedDirs.has(path)) {
this.expandedDirs.delete(path);
this.renderFileTree();
return;
}
this.expandedDirs.add(path);
if (!this.directoryCache.has(path)) {
await this.loadDirectory(path);
return;
}
this.renderFileTree();
}
getFileIcon(filename) {
const ext = filename.split('.').pop().toLowerCase();
const icons = {
'txt': '📄',
'md': '📝',
'py': '🐍',
'js': '📜',
'html': '🌐',
'css': '🎨',
'json': '📋',
'yaml': '⚙️',
'yml': '⚙️',
'xml': '📄',
'csv': '📊'
};
return icons[ext] || '📄';
}
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
async openFile(filePath) {
this.hideCompletionDropdown();
try {
const existingTab = this.findTab(filePath);
if (existingTab) {
this.switchToTab(filePath);
return;
}
const response = await this.apiFetch(`/api/file/${encodeURIComponent(filePath)}`);
if (!response.ok) {
throw new Error(`Failed to open file: ${response.statusText}`);
}
const data = await response.json();
this.openTabs.push({
path: filePath,
content: data.content,
isModified: false
});
this.activeTabPath = filePath;
this.currentFilePath = filePath;
this.ignoreNextChange = true;
this.editor.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: data.content
}
});
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) {
console.error('Error opening file:', error);
this.showError('Failed to open file');
}
}
async saveFile() {
if (!this.currentFilePath) return;
if (this.isReadOnlyPath(this.currentFilePath)) {
this.showError('Files in lib are read-only');
return;
}
try {
const content = this.editor.state.doc.toString();
const response = await this.apiFetch(`/api/file/${encodeURIComponent(this.currentFilePath)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ content })
});
if (!response.ok) {
throw new Error(`Failed to save file: ${response.statusText}`);
}
const tab = this.findTab(this.currentFilePath);
if (tab) {
tab.content = content;
tab.isModified = false;
}
this.markAsSaved();
this.renderTabs();
this.showSuccess('File saved successfully');
} catch (error) {
console.error('Error saving file:', error);
this.showError('Failed to save file');
}
}
async deleteFile() {
if (!this.currentFilePath) return;
if (!confirm(`Are you sure you want to delete "${this.currentFilePath}"?`)) {
return;
}
try {
const deletedPath = this.currentFilePath;
const response = await this.apiFetch(`/api/file/${encodeURIComponent(this.currentFilePath)}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to delete file: ${response.statusText}`);
}
this.ignoreNextChange = true;
this.editor.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: ''
}
});
this.ignoreNextChange = false;
this.openTabs = this.openTabs.filter((tab) => tab.path !== deletedPath);
if (this.openTabs.length > 0) {
this.switchToTab(this.openTabs[this.openTabs.length - 1].path);
} else {
this.currentFilePath = null;
this.activeTabPath = null;
this.markAsSaved();
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();
this.loadDirectory("");
this.renderTabs();
this.showSuccess('File deleted successfully');
} catch (error) {
console.error('Error deleting file:', error);
this.showError('Failed to delete file');
}
}
closeTabWithoutPrompt(path) {
this.hideCompletionDropdown();
this.openTabs = this.openTabs.filter((tab) => tab.path !== path);
if (this.activeTabPath === path) {
const nextTab = this.openTabs[this.openTabs.length - 1];
if (nextTab) {
this.switchToTab(nextTab.path);
} else {
this.activeTabPath = null;
this.currentFilePath = null;
this.setLanguageForPath('');
this.ignoreNextChange = true;
this.editor.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: ''
}
});
this.ignoreNextChange = false;
this.markAsSaved();
const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = 'No file selected';
this.updateRunButtonState();
}
}
this.renderTabs();
}
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'));
const canRun = runMainSelected || hasPythonFile;
runButton.disabled = !canRun;
stopButton.disabled = !this.isPythonRunning;
this.updateLedWindowControls();
}
clearConsole() {
const consoleOutput = document.getElementById('console-output');
consoleOutput.textContent = '';
this.consolePendingText = '';
if (this.consoleFlushTimer) {
clearTimeout(this.consoleFlushTimer);
this.consoleFlushTimer = null;
}
}
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.documentElement.style.height = '100%';
win.document.body.innerHTML = `
`;
if (!this.isMobileViewport()) {
win.document.body.classList.add('led-panel-popup-desktop');
}
win.document.getElementById('stop-fab')?.addEventListener('click', () => {
try {
if (win.opener && !win.opener.closed) {
win.opener.postMessage({ type: 'neopixel-popup-stop' }, '*');
}
} catch (_e) {
// ignore - parent will handle stop on next interaction
}
});
this.ledPanelWindow = win;
return win;
}
closeLedPanelWindow() {
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
this.ledPanelWindow.close();
}
this.ledPanelWindow = null;
}
hideInlineLedPanel() {
const panel = document.getElementById('led-sim-panel');
if (panel) panel.classList.add('hidden');
const grid = document.getElementById('led-grid');
if (grid) grid.innerHTML = '';
this.lastLedFrame = null;
}
closeLedSimulator() {
this.closeLedPanelWindow();
this.hideInlineLedPanel();
}
ensureAdcSlider(pin) {
if (!Number.isFinite(pin) || pin < 0 || pin >= this.adcPinCount) {
return;
}
const panel = document.getElementById('adc-panel');
const list = document.getElementById('adc-sliders');
if (!panel || !list) return;
panel.classList.remove('hidden');
if (this.adcSliders.has(pin)) {
return;
}
this.getAdcSab();
if (this.adcView) {
try {
Atomics.store(this.adcView, pin, this.adcDefaultValue);
} catch (_err) {
this.adcView[pin] = this.adcDefaultValue;
}
}
const row = document.createElement('div');
row.className = 'adc-row';
row.dataset.pin = String(pin);
const label = document.createElement('label');
label.className = 'adc-row-label';
const labelId = `adc-slider-${pin}`;
label.setAttribute('for', labelId);
label.textContent = `ADC pin ${pin}`;
const slider = document.createElement('input');
slider.type = 'range';
slider.id = labelId;
slider.className = 'adc-slider';
slider.min = '0';
slider.max = '65535';
slider.step = '1';
slider.value = String(this.adcDefaultValue);
const readout = document.createElement('span');
readout.className = 'adc-readout';
const updateReadout = (raw) => {
const u16 = Number(raw) | 0;
const volts = (u16 / 65535) * 3.3;
readout.textContent = `${u16} (${volts.toFixed(2)} V)`;
};
updateReadout(this.adcDefaultValue);
slider.addEventListener('input', () => {
const value = Number(slider.value) | 0;
if (this.adcView) {
try {
Atomics.store(this.adcView, pin, value);
} catch (_err) {
this.adcView[pin] = value;
}
}
/* SAB-less fallback: deliver the new slider value directly to the
Pyodide worker, which keeps its own Int32Array when isolation is
off. Same idea as the Pin IN button. */
try {
if (this.pyWorker) {
this.pyWorker.postMessage({ type: 'adcSet', payload: { pin, value } });
}
} catch (_err) {
// ignore
}
updateReadout(value);
});
row.appendChild(label);
row.appendChild(slider);
row.appendChild(readout);
list.appendChild(row);
this.adcSliders.set(pin, { row, slider, readout });
}
closeAdcPanel() {
const panel = document.getElementById('adc-panel');
const list = document.getElementById('adc-sliders');
if (panel) panel.classList.add('hidden');
if (list) list.innerHTML = '';
this.adcSliders.clear();
if (this.adcView) {
for (let i = 0; i < this.adcPinCount; i += 1) {
try {
Atomics.store(this.adcView, i, 0);
} catch (_err) {
this.adcView[i] = 0;
}
}
}
}
openSerialMonitor(meta) {
const panel = document.getElementById('serial-panel');
if (!panel) return;
panel.classList.remove('hidden');
const metaEl = document.getElementById('serial-meta');
if (metaEl && meta && Number.isFinite(meta.baudrate)) {
metaEl.textContent = `UART ${meta.id ?? 0} @ ${meta.baudrate} baud`;
}
if (!this.serialPanelOpen) {
this.serialPanelOpen = true;
this.bindSerialPanel();
}
}
closeSerialPanel() {
const panel = document.getElementById('serial-panel');
if (panel) panel.classList.add('hidden');
const out = document.getElementById('serial-output');
if (out) out.textContent = '';
if (this.serialIndices) {
try {
Atomics.store(this.serialIndices, 0, 0);
Atomics.store(this.serialIndices, 1, 0);
} catch (_err) {
this.serialIndices[0] = 0;
this.serialIndices[1] = 0;
}
}
this.serialDecoder = null;
}
bindSerialPanel() {
const form = document.getElementById('serial-form');
const input = document.getElementById('serial-input');
const clearBtn = document.getElementById('serial-clear');
if (form && input && !form.dataset.bound) {
form.dataset.bound = '1';
form.addEventListener('submit', (event) => {
event.preventDefault();
if (!this.isPythonRunning) return;
const newlineCheckbox = document.getElementById('serial-newline-checkbox');
let text = input.value;
if (newlineCheckbox && newlineCheckbox.checked) text += '\n';
if (!text) return;
const bytes = new TextEncoder().encode(text);
this.pushSerialBytes(bytes);
this.appendSerialMonitorText(text, 'tx');
input.value = '';
input.focus();
});
}
if (clearBtn && !clearBtn.dataset.bound) {
clearBtn.dataset.bound = '1';
clearBtn.addEventListener('click', () => {
const out = document.getElementById('serial-output');
if (out) out.textContent = '';
});
}
}
appendSerialMonitorText(text, direction) {
const out = document.getElementById('serial-output');
if (!out) return;
const span = document.createElement('span');
span.className = direction === 'tx' ? 'serial-tx' : 'serial-rx';
span.textContent = text;
out.appendChild(span);
const maxChars = 50000;
if (out.textContent.length > maxChars) {
out.textContent = out.textContent.slice(-maxChars);
}
out.scrollTop = out.scrollHeight;
}
appendSerialOutputBase64(b64) {
if (typeof b64 !== 'string' || !b64) return;
let bytes;
try {
const bin = atob(b64);
bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) {
bytes[i] = bin.charCodeAt(i);
}
} catch (_err) {
return;
}
if (!this.serialDecoder) {
this.serialDecoder = new TextDecoder('utf-8', { fatal: false });
}
const text = this.serialDecoder.decode(bytes, { stream: true });
if (text) this.appendSerialMonitorText(text, 'rx');
}
ensurePinRow(pin, mode, extra) {
if (!Number.isFinite(pin) || pin < 0 || pin >= this.pinCount) return;
if (this._claimedPins && this._claimedPins.has(pin)) return;
const panel = document.getElementById('pin-panel');
const list = document.getElementById('pin-rows');
if (!panel || !list) return;
panel.classList.remove('hidden');
this.getPinOutSab();
this.getPinInSab();
let entry = this.pinRows.get(pin);
if (!entry) {
const row = document.createElement('div');
row.className = 'pin-row';
row.dataset.pin = String(pin);
const label = document.createElement('span');
label.className = 'pin-row-label';
const indicator = document.createElement('span');
indicator.className = 'pin-led';
const button = document.createElement('button');
button.type = 'button';
button.className = 'pin-toggle';
button.textContent = '0';
button.addEventListener('click', () => {
const cur = this.pinInView ? (this.pinInView[pin] | 0) : (this._pinInLocal[pin] || 0);
const next = cur ? 0 : 1;
this._pinInLocal[pin] = next;
if (this.pinInView) {
try {
Atomics.store(this.pinInView, pin, next);
} catch (_err) {
try {
this.pinInView[pin] = next;
} catch (_e2) {
// ignore
}
}
}
/* postMessage the change too — when SAB is unavailable (e.g. mobile
hitting a LAN IP over plain HTTP) the worker keeps its own local
Int32Array and only learns about clicks through messages. */
try {
if (this.pyWorker) {
this.pyWorker.postMessage({ type: 'pinIn', payload: { pin, value: next } });
}
} catch (_err) {
// ignore
}
button.textContent = next ? '1' : '0';
button.classList.toggle('on', Boolean(next));
});
const bar = document.createElement('div');
bar.className = 'pin-pwm-bar';
const fill = document.createElement('div');
fill.className = 'pin-pwm-fill';
bar.appendChild(fill);
const detail = document.createElement('span');
detail.className = 'pin-row-detail';
row.appendChild(label);
row.appendChild(indicator);
row.appendChild(button);
row.appendChild(bar);
row.appendChild(detail);
list.appendChild(row);
entry = { row, label, indicator, button, bar, fill, detail, mode: 0 };
this.pinRows.set(pin, entry);
}
entry.mode = mode;
entry.label.textContent = `Pin ${pin}`;
const isOut = mode === 1;
const isIn = mode === 2;
const isPwm = mode === 4;
entry.indicator.style.display = isOut ? '' : 'none';
entry.button.style.display = isIn ? '' : 'none';
entry.bar.style.display = isPwm ? '' : 'none';
if (isPwm && extra && Number.isFinite(extra.freq)) {
entry.detail.textContent = `PWM ${extra.freq} Hz`;
} else if (isOut) {
entry.detail.textContent = 'OUT';
} else if (isIn) {
entry.detail.textContent = 'IN';
} else {
entry.detail.textContent = '';
}
this.startPinRafLoop();
}
startPinRafLoop() {
if (this.pinRafId != null) return;
const tick = () => {
this.pinRafId = null;
if (!this.pinRows.size) return;
this.refreshPinRows();
if (this.pinRows.size) {
this.pinRafId = requestAnimationFrame(tick);
}
};
this.pinRafId = requestAnimationFrame(tick);
}
refreshPinRows() {
const view = this.pinOutView;
if (!view) return;
for (const [pin, entry] of this.pinRows) {
if (entry.mode === 1) {
const packed = view[pin] | 0;
const value = packed & 0x00ffffff;
entry.indicator.classList.toggle('on', value !== 0);
} else if (entry.mode === 4) {
const packed = view[pin] | 0;
const duty = packed & 0x00ffffff;
const pct = Math.max(0, Math.min(100, (duty / 65535) * 100));
entry.fill.style.width = `${pct.toFixed(1)}%`;
entry.detail.textContent = `${(pct).toFixed(0)}% duty`;
}
}
}
closePinPanel() {
const panel = document.getElementById('pin-panel');
const list = document.getElementById('pin-rows');
if (panel) panel.classList.add('hidden');
if (list) list.innerHTML = '';
this.pinRows.clear();
if (this._claimedPins) this._claimedPins.clear();
if (this.pinRafId != null) {
cancelAnimationFrame(this.pinRafId);
this.pinRafId = null;
}
if (this.pinOutView) {
for (let i = 0; i < this.pinCount; i += 1) {
try {
Atomics.store(this.pinOutView, i, 0);
} catch (_err) {
this.pinOutView[i] = 0;
}
}
}
if (this.pinInView) {
for (let i = 0; i < this.pinCount; i += 1) {
try {
Atomics.store(this.pinInView, i, 0);
} catch (_err) {
this.pinInView[i] = 0;
}
}
}
}
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 grid = panelWindow.document.getElementById('grid');
if (!grid) return;
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 (!grid) return;
if (meta) {
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 npxIdx = line.indexOf('[neopixel-json]');
if (npxIdx !== -1) {
const jsonPart = line.slice(npxIdx + '[neopixel-json]'.length).trim();
if (jsonPart) {
try {
const payload = JSON.parse(jsonPart);
if (payload && payload.type === 'neopixel') {
this.renderLedSimulation(payload);
}
} catch (_err) {
// Ignore malformed simulation payloads.
}
}
}
const adcIdx = line.indexOf('[adc-register]');
if (adcIdx !== -1) {
const jsonPart = line.slice(adcIdx + '[adc-register]'.length).trim();
if (jsonPart) {
try {
const payload = JSON.parse(jsonPart);
if (payload && Number.isFinite(payload.pin)) {
this.ensureAdcSlider(Number(payload.pin));
}
} catch (_err) {
// Ignore malformed ADC registrations.
}
}
}
const serialRegIdx = line.indexOf('[serial-register]');
if (serialRegIdx !== -1) {
const jsonPart = line.slice(serialRegIdx + '[serial-register]'.length).trim();
let payload = null;
try {
payload = jsonPart ? JSON.parse(jsonPart) : {};
} catch (_err) {
payload = {};
}
this.openSerialMonitor(payload || {});
}
const serialOutIdx = line.indexOf('[serial-out]');
if (serialOutIdx !== -1) {
const b64 = line.slice(serialOutIdx + '[serial-out]'.length).trim();
if (b64) this.appendSerialOutputBase64(b64);
}
const pinClaimIdx = line.indexOf('[pin-claim]');
if (pinClaimIdx !== -1) {
const jsonPart = line.slice(pinClaimIdx + '[pin-claim]'.length).trim();
if (jsonPart) {
try {
const payload = JSON.parse(jsonPart);
this.claimPin(payload);
} catch (_err) {
// Ignore malformed pin claims.
}
}
}
const pinRegIdx = line.indexOf('[pin-register]');
if (pinRegIdx !== -1) {
const jsonPart = line.slice(pinRegIdx + '[pin-register]'.length).trim();
if (jsonPart) {
try {
const payload = JSON.parse(jsonPart);
if (payload && Number.isFinite(payload.pin) && Number.isFinite(payload.mode)) {
this.ensurePinRow(Number(payload.pin), Number(payload.mode), payload);
}
} catch (_err) {
// Ignore malformed pin registrations.
}
}
}
/* SAB-less fallback: machine.py prints `[pin-out]` whenever the
worker has no SharedArrayBuffer for pin output. The main thread
can't read the worker's local array, so we drive indicators/PWM
bars off these console lines instead. */
const pinOutIdx = line.indexOf('[pin-out]');
if (pinOutIdx !== -1) {
const jsonPart = line.slice(pinOutIdx + '[pin-out]'.length).trim();
if (jsonPart) {
try {
const payload = JSON.parse(jsonPart);
this.applyPinOutSnapshot(payload);
} catch (_err) {
// Ignore malformed pin-out lines.
}
}
}
}
}
claimPin(payload) {
if (!payload || !Number.isFinite(payload.pin)) return;
const pin = Number(payload.pin);
if (!this._claimedPins) this._claimedPins = new Set();
this._claimedPins.add(pin);
/* If a Pin(...) constructor already published a row for this pin
(NeoPixel internally uses Pin(...)), drop it now. The remaining rows
collapse and the panel hides itself when empty. */
const entry = this.pinRows.get(pin);
if (entry) {
entry.row.remove();
this.pinRows.delete(pin);
}
if (!this.pinRows.size) {
const panel = document.getElementById('pin-panel');
if (panel) panel.classList.add('hidden');
}
}
applyPinOutSnapshot(payload) {
if (!payload || !Number.isFinite(payload.pin)) return;
const pin = Number(payload.pin);
const mode = Number(payload.mode) | 0;
const entry = this.pinRows.get(pin);
if (!entry) return;
if (mode === 1) {
const on = Boolean(payload.value);
entry.indicator.classList.toggle('on', on);
} else if (mode === 4) {
const u16 = Math.max(0, Math.min(65535, Number(payload.value) | 0));
entry.fill.style.width = `${(u16 / 65535) * 100}%`;
if (Number.isFinite(payload.freq)) {
entry.detail.textContent = `${u16} / 65535 @ ${payload.freq} Hz`;
} else {
entry.detail.textContent = `${u16} / 65535`;
}
}
}
appendConsoleOutput(lines) {
if (!Array.isArray(lines) || lines.length === 0) return;
this.processSimulationLines(lines);
const visibleLines = lines.filter((line) => {
if (typeof line !== 'string') return false;
return (
!line.includes('[neopixel-json]') &&
!line.includes('[adc-register]') &&
!line.includes('[serial-register]') &&
!line.includes('[serial-out]') &&
!line.includes('[pin-register]') &&
!line.includes('[pin-out]') &&
!line.includes('[pin-claim]')
);
});
if (visibleLines.length === 0) return;
this.consolePendingText += visibleLines.join('');
if (this.consoleFlushTimer) return;
this.consoleFlushTimer = setTimeout(() => {
const consoleOutput = document.getElementById('console-output');
if (this.consolePendingText) {
consoleOutput.textContent += this.consolePendingText;
this.consolePendingText = '';
}
const maxChars = 200000;
if (consoleOutput.textContent.length > maxChars) {
consoleOutput.textContent = consoleOutput.textContent.slice(-maxChars);
}
consoleOutput.scrollTop = consoleOutput.scrollHeight;
this.consoleFlushTimer = null;
}, 50);
}
async runPython() {
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);
}
if (!selectedRunFile || !selectedRunFile.toLowerCase().endsWith('.py')) {
this.showError('Select a Python (.py) file first');
return;
}
try {
if (this.isPythonRunning) {
await this.stopPython();
}
if (this.isModified) {
await this.saveFile();
}
const bundleResp = await this.apiFetch('/api/workspace/py-sources');
if (!bundleResp.ok) {
const errorBody = await bundleResp.json().catch(() => ({}));
throw new Error(errorBody.detail || bundleResp.statusText);
}
const bundle = await bundleResp.json();
const files = { ...(bundle.files || {}) };
for (const tab of this.openTabs) {
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
files[tab.path] = tab.content;
}
}
this.maybePrepareLedWindow(files);
this.clearConsole();
const args = [];
this.appendConsoleOutput([`$ pyodide ${selectedRunFile}\n`]);
this.isPythonRunning = true;
this.updateRunButtonState();
const generation = ++this.pyRunGeneration;
await this.ensurePyodideReady();
await this.callPyWorker('run', {
files,
mainPath: selectedRunFile,
args
});
if (generation === this.pyRunGeneration) {
this.appendConsoleOutput(['\n[Finished]\n']);
this.closeLedSimulator();
this.closeAdcPanel();
this.closeSerialPanel();
this.closePinPanel();
}
} catch (error) {
this.appendConsoleOutput([`\n${error.message}\n`]);
} finally {
this.isPythonRunning = false;
this.updateRunButtonState();
}
}
async stopPython() {
if (!this.isPythonRunning) {
return;
}
this.pyRunGeneration += 1;
this.disposePyWorker();
this.isPythonRunning = false;
this.closeLedSimulator();
this.closeAdcPanel();
this.closeSerialPanel();
this.closePinPanel();
this.appendConsoleOutput(['\n[Execution stopped — Pyodide worker was reset]\n']);
this.updateRunButtonState();
this.prewarmPyWorker();
}
async deleteSelected() {
if (!this.selectedPath) {
this.showError('Select a file or folder first');
return;
}
const targetType = this.selectedIsDirectory ? "folder" : "file";
if (!confirm(`Delete ${targetType} "${this.selectedPath}"?`)) {
return;
}
try {
const endpoint = this.selectedIsDirectory
? `/api/folder/${encodeURIComponent(this.selectedPath)}`
: `/api/file/${encodeURIComponent(this.selectedPath)}`;
const response = await this.apiFetch(endpoint, { method: 'DELETE' });
if (!response.ok) {
throw new Error(`Failed to delete ${targetType}: ${response.statusText}`);
}
if (this.selectedIsDirectory) {
const prefix = `${this.selectedPath}/`;
const pathsToClose = this.openTabs
.map((tab) => tab.path)
.filter((path) => path === this.selectedPath || path.startsWith(prefix));
pathsToClose.forEach((path) => this.closeTabWithoutPrompt(path));
} else {
this.closeTabWithoutPrompt(this.selectedPath);
}
this.selectedPath = null;
this.selectedIsDirectory = false;
this.resetDirectoryState();
this.loadDirectory(this.getTreeRootPath());
this.showSuccess(`${targetType} deleted successfully`);
} catch (error) {
console.error(`Error deleting ${targetType}:`, error);
this.showError(`Failed to delete ${targetType}`);
}
}
getParentDirectory(filePath) {
if (!filePath || !filePath.includes('/')) return "";
return filePath.slice(0, filePath.lastIndexOf('/'));
}
getNewFileBasePath() {
const fallbackRoot = this.getDefaultEditableRoot();
if (!this.selectedPath) {
return fallbackRoot;
}
return this.selectedIsDirectory ? this.selectedPath : this.getParentDirectory(this.selectedPath);
}
showNewFileModal() {
const basePath = this.getNewFileBasePath();
const input = document.getElementById('new-filename');
input.value = basePath ? `${basePath}/` : '';
document.getElementById('new-file-modal').style.display = 'block';
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
hideNewFileModal() {
document.getElementById('new-file-modal').style.display = 'none';
document.getElementById('new-filename').value = '';
}
async createNewFile() {
const filename = document.getElementById('new-filename').value.trim();
if (!filename) {
this.showError('Please enter a filename');
return;
}
try {
let cleanName = this.normalizeRelativePathInput(filename);
if (cleanName.startsWith('new/')) {
cleanName = cleanName.slice(4);
}
if (!cleanName) {
this.showError('Please enter a filename');
return;
}
const targetRoot = this.getDefaultEditableRoot();
const targetPath = `${targetRoot}/${cleanName}`;
const response = await this.apiFetch(`/api/file/${encodeURIComponent(targetPath)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ filename: targetPath, content: '' })
});
if (!response.ok) {
throw new Error(`Failed to create file: ${response.statusText}`);
}
this.hideNewFileModal();
this.resetDirectoryState();
this.loadDirectory(this.getTreeRootPath());
this.showSuccess('File created successfully');
} catch (error) {
console.error('Error creating file:', error);
this.showError('Failed to create file');
}
}
remapPath(path, oldPrefix, newPrefix) {
if (!path) return path;
if (path === oldPrefix) return newPrefix;
if (path.startsWith(`${oldPrefix}/`)) {
return `${newPrefix}${path.slice(oldPrefix.length)}`;
}
return path;
}
async movePathToFolder(sourcePath, sourceIsDirectory, destinationFolder) {
if (!sourcePath) {
return;
}
if (sourcePath === destinationFolder) {
return;
}
if (sourceIsDirectory && destinationFolder && destinationFolder.startsWith(`${sourcePath}/`)) {
return;
}
try {
const response = await this.apiFetch('/api/file-move', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
source_path: sourcePath,
destination_folder: destinationFolder
})
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(errorBody.detail || response.statusText);
}
const body = await response.json();
const newPath = body.new_path;
if (typeof newPath === 'string' && newPath) {
this.openTabs.forEach((tab) => {
tab.path = this.remapPath(tab.path, sourcePath, newPath);
});
this.currentFilePath = this.remapPath(this.currentFilePath, sourcePath, newPath);
this.activeTabPath = this.remapPath(this.activeTabPath, sourcePath, newPath);
if (this.currentFilePath) {
const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = this.currentFilePath;
}
this.selectedPath = this.remapPath(this.selectedPath, sourcePath, newPath);
this.expandedDirs = new Set(
Array.from(this.expandedDirs).map((path) => this.remapPath(path, sourcePath, newPath))
);
if (destinationFolder) {
this.expandedDirs.add(destinationFolder);
}
}
this.directoryCache.clear();
this.expandedDirs.add('');
this.renderTabs();
this.loadDirectory(this.getTreeRootPath());
this.showSuccess('Path moved successfully');
} catch (error) {
this.showError(`Failed to move path: ${error.message}`);
}
}
async createNewFolder() {
const folderPath = prompt('Enter folder path (e.g., src/components)');
if (!folderPath) return;
let cleanPath = this.normalizeRelativePathInput(folderPath);
if (!cleanPath) {
this.showError('Please enter a folder path');
return;
}
const targetRoot = this.getDefaultEditableRoot();
const targetPath = `${targetRoot}/${cleanPath}`;
try {
const response = await this.apiFetch(`/api/folder/new/${encodeURIComponent(targetPath)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: targetPath })
});
if (!response.ok) {
throw new Error(`Failed to create folder: ${response.statusText}`);
}
this.resetDirectoryState();
this.loadDirectory(this.getTreeRootPath());
this.showSuccess('Folder created successfully');
} catch (error) {
console.error('Error creating folder:', error);
this.showError('Failed to create folder');
}
}
markAsModified() {
this.isModified = true;
const saveStatus = document.getElementById('save-status');
saveStatus.textContent = 'Unsaved changes';
saveStatus.className = 'save-status unsaved';
}
markAsSaved() {
this.isModified = false;
const saveStatus = document.getElementById('save-status');
saveStatus.textContent = 'Saved';
saveStatus.className = 'save-status saved';
}
showError(message) {
alert(`Error: ${message}`);
}
showSuccess(message) {
console.log(`Success: ${message}`);
}
}
async function bootEditorApp() {
/* Decide between local and server mode. Order of precedence:
1. `?local=1` in the URL → local mode (always wins, even when signed in,
so a logged-in user can still test the local-mode workflow).
2. Signed-in session → server mode (clear any stale local-mode
flag from a previous "Use locally" visit).
3. No session, local flag in localStorage → local mode.
4. Auth required, no session → redirect to /login.
5. Auth disabled → server mode.
*/
const params = new URLSearchParams(window.location.search);
const explicitLocal = params.get('local') === '1';
if (explicitLocal) {
new TextEditor();
return;
}
let authEnabled = false;
let signedIn = false;
try {
const st = await fetch('/api/auth/status');
if (st.ok) {
const status = await st.json();
authEnabled = Boolean(status.auth_enabled);
}
} catch (_e) {
// No backend reachable — fall through to local-flag handling.
}
if (authEnabled) {
try {
const me = await fetch('/api/auth/me', { credentials: 'include' });
signedIn = me.ok;
} catch (_e) {
signedIn = false;
}
}
if (signedIn) {
/* Signed-in users always get the server workspace. Wipe the stale
local-mode flag so refreshing this tab doesn't bounce back to
IndexedDB without warning. */
try {
window.localStorage.removeItem('python-editor.local-mode');
} catch (_e) {
// ignore
}
new TextEditor();
return;
}
if (isLocalModeEnabled()) {
new TextEditor();
return;
}
if (authEnabled) {
const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
window.location.replace(`/login?next=${next}`);
return;
}
new TextEditor();
}
document.addEventListener('DOMContentLoaded', () => {
bootEditorApp();
});