Files
python-editor/src/static/script.js
Jimmy 551c3d1efc Add PyPI package installer, file-tree context menu, and demos UI
Expose micropip installs from the menu, right-click actions on the file
tree, local folder/file open shortcuts, and browse demos/ alongside code/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:55 +12:00

4503 lines
148 KiB
JavaScript

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=7";
import { createZip, readZip } from "/static/zip-utils.js?v=2";
/** Legacy browser-local paths (older sessions); new mounts use *-local at workspace root. */
const BROWSER_LOCAL_LEGACY_ROOT = '__local__';
const BROWSER_LOCAL_LEGACY_PREFIX = '__local__/';
const BROWSER_LOCAL_SEG_CHANGE = '__change_folder__';
const BROWSER_LOCAL_LEGACY_VIRTUAL = /(__change_folder__|__choose_folder__|__open_file__|__menu_hint__|__info_unsupported__)$/;
const BROWSER_LOCAL_FOLDER_IDB_NAME = 'python-editor.browser-local-folder';
const BROWSER_LOCAL_FOLDER_IDB_VER = 1;
const BROWSER_LOCAL_FOLDER_IDB_STORE = 'folder';
class TextEditor {
constructor() {
this.sessionStorageKey = 'python-editor.editor.session.v1';
/** PEP 508 specs installed via micropip; persisted in localStorage (see getPersistedMicropipPackages). */
this.micropipPackagesStorageKey = 'python-editor.micropip.packages.v1';
/** When true, worker stdout/stderr is appended to #packages-install-log during micropip installs. */
this.micropipInstallCaptureIo = false;
/** Briefly true after a worker run so late batched stdout still reaches the console. */
this.workerConsoleIoGrace = false;
this._workerConsoleIoGraceTimer = null;
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._browserLocalRootHandle = null;
/** Top-level directory name for the picked folder (e.g. `Photos-local`). */
this._browserLocalFolderMount = null;
this._browserLocalHandles = new Map();
this._onFileTreeContextMenuDismiss = 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.restoreBrowserLocalFolderFromPersistence())
.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');
const menuActions = document.getElementById('workspace-menu-actions');
const menuLabel = document.getElementById('workspace-menu-label');
if (!badge) return;
// Helper: a button styled as a menu item.
const makeMenuButton = (text, title, onClick, opts = {}) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'menu-item menu-action' + (opts.danger ? ' menu-action-danger' : '');
b.setAttribute('role', 'menuitem');
b.textContent = text;
if (title) b.title = title;
b.addEventListener('click', (event) => {
// Close the dropdown after a click — feels more like a normal menu.
const details = document.getElementById('header-menu');
if (details) details.open = false;
onClick(event);
});
return b;
};
// Helper: a passive note line inside the menu (e.g. for "no folder picker").
const makeMenuNote = (text, title) => {
const n = document.createElement('div');
n.className = 'menu-note';
n.textContent = text;
if (title) n.title = title;
return n;
};
badge.innerHTML = '';
if (menuActions) menuActions.innerHTML = '';
const label = document.createElement('span');
label.className = 'workspace-badge-label';
badge.appendChild(label);
badge.classList.remove('hidden');
if (this.localMode) {
label.textContent = 'Local · IndexedDB';
if (menuLabel) menuLabel.textContent = 'Workspace · Local';
if (menuActions) {
if (supportsFolderPicker()) {
menuActions.appendChild(
makeMenuButton('📁 Save to folder…', 'Save files to a folder on this device', () =>
this.pickLocalFolder(),
),
);
} else {
menuActions.appendChild(
makeMenuNote(
'(folder picker unavailable)',
'window.showDirectoryPicker is not exposed in this browser context.\n' +
'• Firefox / Safari: not implemented (use a 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.',
),
);
}
menuActions.appendChild(
makeMenuButton('⬇️ Export workspace…', 'Download every workspace file as a .zip', () =>
this.exportWorkspaceZip(),
),
);
menuActions.appendChild(
makeMenuButton('⬆️ Import .zip…', 'Upload a .zip — its files land in code/ (overwrites on conflict)', () =>
this.importWorkspaceZip(),
),
);
menuActions.appendChild(
makeMenuButton(
'↻ Reset demos',
'Re-copy bundled demos into demos/ (overwrites your edits to those files)',
() => this.resetDemoFiles(),
),
);
menuActions.appendChild(
makeMenuButton(
'🚪 Exit local mode',
'Leave local mode and return to the home page',
() => {
if (!confirm('Leave local mode? Your files stay in this browser; you can come back later.')) return;
exitLocalMode();
window.location.href = '/';
},
{ danger: true },
),
);
}
try {
const info = await this.localWorkspace.describeStorage();
if (info) {
if (info.mode === 'filesystem') {
label.textContent = `Local · ${info.label}`;
if (menuActions) {
const swap = makeMenuButton(
'↺ Switch to IndexedDB',
'Switch storage back to in-browser IndexedDB',
() => this.useIndexedDbStorage(),
);
// Insert near the top, just under the picker button (or first child).
menuActions.insertBefore(swap, menuActions.firstChild);
}
} else if (info.pendingReconnect) {
label.textContent = `Local · folder “${info.pendingFolderName}” (reconnect)`;
if (menuActions) {
const reconnect = makeMenuButton(
`🔌 Reconnect “${info.pendingFolderName}`,
'Re-grant read/write access to the previously picked folder',
() => this.reconnectLocalFolder(),
);
menuActions.insertBefore(reconnect, menuActions.firstChild);
}
} else {
label.textContent = 'Local · IndexedDB';
}
}
} catch (_err) {
// Best-effort label refresh.
}
return;
}
label.textContent = this.workspaceUserId
? `Workspace: user ${this.workspaceUserId}`
: 'Server workspace';
if (menuLabel) menuLabel.textContent = 'Workspace';
if (menuActions) {
menuActions.appendChild(
makeMenuButton('⬇️ Export workspace…', 'Download every workspace file as a .zip', () =>
this.exportWorkspaceZip(),
),
);
menuActions.appendChild(
makeMenuButton('⬆️ Import .zip…', 'Upload a .zip — its files land in code/ (overwrites on conflict)', () =>
this.importWorkspaceZip(),
),
);
menuActions.appendChild(
makeMenuButton(
'↻ Reset demos',
'Re-copy bundled demos into demos/ (overwrites your edits to those files)',
() => this.resetDemoFiles(),
),
);
}
}
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;
}
const demoBasename = (n) => (n.startsWith('demo/') ? n.slice(5) : n);
const editorDemoPath = (base) => `demos/${base}`;
if (!confirm(
`Reset ${names.length} demo file${names.length === 1 ? '' : 's'}?\n\n` +
names.map((n) => `${editorDemoPath(demoBasename(n))}`).join('\n') +
'\n\nAny edits you made to these files will be overwritten. ' +
'main.py, code/, and lib/ are not touched.'
)) return;
let written = 0;
let failed = 0;
for (const name of names) {
try {
const urlPath = name.split('/').map(encodeURIComponent).join('/');
const r = await fetch(`/static/bundled-demos/${urlPath}`, {
cache: 'no-store',
});
if (!r.ok) {
failed += 1;
continue;
}
const content = await r.text();
const codeName = demoBasename(name);
const filePath = editorDemoPath(codeName);
const w = await this.apiFetch(`/api/file/${this.encodeApiFilePath(filePath)}`, {
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 = editorDemoPath(demoBasename(name));
if (this.findTab(path)) {
try {
const fr = await this.apiFetch(`/api/file/${this.encodeApiFilePath(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', 'demos']);
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', 'demos']);
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', 'demos'];
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/${this.encodeApiFilePath(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[0] !== 'demos') 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. Paths default to code/ unless they start with demos/.`
)) return;
let written = 0;
let failed = 0;
for (const entry of safeEntries) {
try {
const r = await this.apiFetch(`/api/file/${this.encodeApiFilePath(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', 'demos']);
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', 'demos']);
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();
}
/** Build `/api/file/...` or `/api/folder/...` paths: encode each segment so `/` is not `%2F` (many servers reject that). */
encodeApiFilePath(relPath) {
const s = String(relPath || '').replace(/^\/+/, '');
if (!s) return '';
return s.split('/').map(encodeURIComponent).join('/');
}
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() {
if (this._workerConsoleIoGraceTimer) {
clearTimeout(this._workerConsoleIoGraceTimer);
this._workerConsoleIoGraceTimer = null;
}
this.workerConsoleIoGrace = false;
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=24');
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.micropipInstallCaptureIo) {
const logEl = document.getElementById('packages-install-log');
const chunk = data.text || '';
if (logEl && chunk) {
const next = logEl.textContent + chunk;
logEl.textContent =
next.length > 64000 ? `…(truncated)\n${next.slice(next.length - 63000)}` : next;
logEl.scrollTop = logEl.scrollHeight;
}
}
if (
!this.micropipInstallCaptureIo &&
(this.isPythonRunning || this.workerConsoleIoGrace)
) {
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;
}
const initData = await this.callPyWorker('init', {
adcSab: this.getAdcSab(),
serialSab: this.getSerialSab(),
pinOutSab: this.getPinOutSab(),
pinInSab: this.getPinInSab(),
persistedMicropipPackages: this.getPersistedMicropipPackages(),
});
if (initData && initData.micropipRestoreError) {
this.showError(
`Could not restore saved PyPI packages (editor still works): ${initData.micropipRestoreError}`,
);
}
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;
});
}
getPersistedMicropipPackages() {
try {
const raw = localStorage.getItem(this.micropipPackagesStorageKey);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? this.normalizePackageSpecList(parsed) : [];
} catch (_error) {
return [];
}
}
normalizePackageSpecList(items) {
const seen = new Set();
const out = [];
for (const item of items) {
const t = typeof item === 'string' ? item.trim() : '';
if (!t) {
continue;
}
const key = t.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
out.push(t);
}
return out;
}
/** Split user input on commas or newlines (one spec per segment). */
parsePackageSpecsInput(text) {
const raw = String(text || '')
.split(/[,\n]+/)
.map((s) => s.trim())
.filter(Boolean);
return this.normalizePackageSpecList(raw);
}
savePersistedMicropipPackages(specs) {
try {
const list = this.normalizePackageSpecList(specs);
localStorage.setItem(this.micropipPackagesStorageKey, JSON.stringify(list));
} catch (_error) {
// Ignore storage failures (private mode, quota).
}
}
pypiProjectNameFromInput(text) {
const first = String(text || '')
.split(/[,\n]+/)[0]
.trim();
if (!first) {
return '';
}
const base = first.split(/[\s\[<>=!]/)[0];
return (base || first).trim();
}
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
renderPackagesSavedList() {
const ul = document.getElementById('packages-saved-list');
if (!ul) {
return;
}
const specs = this.getPersistedMicropipPackages();
if (!specs.length) {
ul.innerHTML = '<li class="packages-saved-empty">None yet</li>';
return;
}
ul.innerHTML = specs
.map(
(s) =>
`<li class="packages-saved-item"><span>${this.escapeHtml(s)}</span>` +
`<button type="button" class="packages-remove-btn" data-spec="${encodeURIComponent(s)}">Remove</button></li>`,
)
.join('');
}
showPackagesModal() {
const modal = document.getElementById('packages-modal');
const status = document.getElementById('packages-modal-status');
const info = document.getElementById('packages-pypi-info');
if (!modal) {
return;
}
if (status) {
status.textContent = '';
}
if (info) {
info.textContent = '';
}
const installLog = document.getElementById('packages-install-log');
if (installLog) {
installLog.textContent = '';
installLog.classList.add('hidden');
}
this.renderPackagesSavedList();
modal.style.display = 'block';
const input = document.getElementById('packages-install-input');
if (input) {
input.focus();
}
}
hidePackagesModal() {
const modal = document.getElementById('packages-modal');
if (modal) {
modal.style.display = 'none';
}
}
async removePersistedPackage(encodedSpec) {
let spec = encodedSpec;
try {
spec = decodeURIComponent(encodedSpec);
} catch (_e) {
// use raw
}
const next = this.getPersistedMicropipPackages().filter((s) => s !== spec);
this.savePersistedMicropipPackages(next);
this.renderPackagesSavedList();
this.disposePyWorker();
this.pyodideInited = false;
this.prewarmPyWorker();
this.showSuccess('Removed from saved list. Pyodide reloads in the background.');
}
async lookupPackageOnPyPI() {
const input = document.getElementById('packages-install-input');
const infoEl = document.getElementById('packages-pypi-info');
const name = this.pypiProjectNameFromInput((input && input.value) || '');
if (!name) {
this.showError('Enter a package name to look up');
return;
}
if (infoEl) {
infoEl.textContent = 'Loading…';
}
try {
const r = await fetch(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`);
if (!r.ok) {
throw new Error('No such project on PyPI');
}
const j = await r.json();
const ver = j.info && j.info.version;
const sum = j.info && j.info.summary;
const bits = [name, ver ? `(latest: ${ver})` : '', sum ? `${sum}` : ''].filter(Boolean);
if (infoEl) {
infoEl.textContent = bits.join(' ');
}
} catch (err) {
if (infoEl) {
infoEl.textContent = '';
}
const msg = err && err.message ? err.message : String(err);
this.showError(`PyPI lookup failed: ${msg}`);
}
}
async installPackagesFromModal() {
const input = document.getElementById('packages-install-input');
const status = document.getElementById('packages-modal-status');
const btn = document.getElementById('packages-install-btn');
const text = (input && input.value) || '';
const newSpecs = this.parsePackageSpecsInput(text);
if (!newSpecs.length) {
this.showError('Enter at least one package name or version spec.');
return;
}
if (btn) {
btn.disabled = true;
}
const logEl = document.getElementById('packages-install-log');
if (logEl) {
logEl.textContent = '';
logEl.classList.remove('hidden');
}
if (status) {
status.textContent = 'Installing (may download from PyPI)…';
}
const t0 = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
this.micropipInstallCaptureIo = true;
try {
await this.ensurePyodideReady();
await this.callPyWorker('micropipInstall', { specs: newSpecs });
const merged = this.normalizePackageSpecList([...this.getPersistedMicropipPackages(), ...newSpecs]);
this.savePersistedMicropipPackages(merged);
if (input) {
input.value = '';
}
const elapsedSec =
((typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) - t0) /
1000;
const elapsed = elapsedSec < 10 ? elapsedSec.toFixed(2) : elapsedSec.toFixed(1);
if (status) {
const hasLog = logEl && logEl.textContent.trim();
status.textContent = hasLog
? `Installed: ${newSpecs.join(', ')} (${elapsed}s). Installer output below.`
: `Installed: ${newSpecs.join(', ')} (${elapsed}s). No installer console output (often normal for tiny pure-Python wheels).`;
}
this.renderPackagesSavedList();
this.showSuccess('Packages installed');
} catch (err) {
const msg = err && err.message ? err.message : String(err);
const elapsedSec =
((typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()) - t0) /
1000;
const elapsed = elapsedSec < 10 ? elapsedSec.toFixed(2) : elapsedSec.toFixed(1);
if (status) {
status.textContent = `Install failed after ${elapsed}s.`;
}
this.showError(`Install failed: ${msg}`);
} finally {
this.micropipInstallCaptureIo = false;
if (btn) {
btn.disabled = false;
}
}
}
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) {
if (this.isBrowserLocalPath(path) && !this.isBrowserLocalFilePath(path)) {
continue;
}
if (this.isBrowserLocalFilePath(path) && !this._browserLocalHandles.has(path)) {
continue;
}
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' &&
path !== BROWSER_LOCAL_LEGACY_ROOT &&
!path.startsWith(BROWSER_LOCAL_LEGACY_PREFIX),
)
: [];
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;
const ok = await this.loadDirectory(path, { suppressError: true });
if (!ok && this.isBrowserLocalPath(path)) {
this.expandedDirs.delete(path);
}
}
this.renderFileTree();
}
getVisibleTopLevelNames() {
/* lib is shared read-only for everyone; browsing is allowed, saves are blocked in API/UI. */
return new Set(['code', 'demos', 'lib']);
}
getDefaultEditableRoot() {
return 'code';
}
async loadInitialDirectoryState() {
await this.loadDirectory('');
await this.ensureFolderExists('code');
await this.ensureFolderExists('demos');
this.selectedPath = 'code';
this.selectedIsDirectory = true;
this.expandedDirs.add('code');
this.expandedDirs.add('demos');
await this.loadDirectory('code', { suppressError: true });
await this.loadDirectory('demos', { 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/${this.encodeApiFilePath(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();
}
while (parts.length >= 2 && parts[0] === 'demos' && parts[1] === 'demos') {
parts.shift();
}
return parts.join('/');
}
resetDirectoryState() {
this.directoryCache.clear();
this.expandedDirs = new Set(['']);
}
ensureExpandedAncestorsForPath(relativePath, { isDirectory = false } = {}) {
if (!relativePath || typeof relativePath !== 'string') return;
this.expandedDirs.add('');
const parts = relativePath.split('/').filter(Boolean);
if (!parts.length) return;
const depth = isDirectory ? parts.length : Math.max(0, parts.length - 1);
let acc = '';
for (let i = 0; i < depth; i++) {
acc = acc ? `${acc}/${parts[i]}` : parts[i];
this.expandedDirs.add(acc);
}
}
pruneExpandedDirsAtAndUnder(prefix) {
if (!prefix || typeof prefix !== 'string') return;
const norm = prefix.replace(/\/+$/g, '');
for (const p of [...this.expandedDirs]) {
if (!p) continue;
if (p === norm || p.startsWith(`${norm}/`)) {
this.expandedDirs.delete(p);
}
}
this.expandedDirs.add('');
}
pathDepthForSort(p) {
if (!p) return 0;
return p.split('/').length;
}
async refreshFileTreePreservingExpansion(options = {}) {
const { pruneDeletedPrefix = null } = options;
if (pruneDeletedPrefix) {
this.pruneExpandedDirsAtAndUnder(pruneDeletedPrefix);
}
this.directoryCache.clear();
const sorted = Array.from(this.expandedDirs).sort((a, b) => {
const da = this.pathDepthForSort(a);
const db = this.pathDepthForSort(b);
if (da !== db) return da - db;
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
for (const p of sorted) {
await this.loadDirectory(p, { suppressError: true, suppressRender: true });
}
this.renderFileTree();
}
browserLocalChangePath() {
return this._browserLocalFolderMount
? `${this._browserLocalFolderMount}/${BROWSER_LOCAL_SEG_CHANGE}`
: null;
}
isBrowserLocalVirtualPath(path) {
if (!path || typeof path !== 'string') return false;
const ch = this.browserLocalChangePath();
if (ch && path === ch) return true;
if (BROWSER_LOCAL_LEGACY_VIRTUAL.test(path)) return true;
return false;
}
isBrowserLocalPath(path) {
if (!path || typeof path !== 'string') return false;
if (path === BROWSER_LOCAL_LEGACY_ROOT || path.startsWith(BROWSER_LOCAL_LEGACY_PREFIX)) return true;
if (this._browserLocalFolderMount) {
if (path === this._browserLocalFolderMount) return true;
if (path.startsWith(`${this._browserLocalFolderMount}/`)) return true;
}
if (!path.includes('/') && path.endsWith('-local')) {
return true;
}
return false;
}
isBrowserLocalFilePath(path) {
if (!path || this.isBrowserLocalVirtualPath(path)) return false;
const h = this._browserLocalHandles.get(path);
return Boolean(h && h.kind === 'file');
}
slugifyLocalMountBase(raw) {
let s = String(raw || '').trim() || 'folder';
s = s.replace(/[/\\:*?"<>|]+/g, '-').replace(/\s+/g, '-').replace(/-+/g, '-');
s = s.replace(/^-|-$/g, '') || 'folder';
return s.slice(0, 96);
}
rootTopLevelNameTaken(name) {
const rootFiles = this.directoryCache.get('') || [];
return rootFiles.some((f) => f && f.name === name);
}
allocateBrowserLocalFolderMount(rawName) {
const base = this.slugifyLocalMountBase(rawName);
let candidate = `${base}-local`;
let n = 2;
while (
candidate === 'code' ||
candidate === 'demos' ||
candidate === 'lib' ||
this.rootTopLevelNameTaken(candidate) ||
this._browserLocalHandles.has(candidate)
) {
candidate = `${base}-${n}-local`;
n += 1;
}
return candidate;
}
allocateBrowserLocalFileMount(baseName) {
const base = this.slugifyLocalMountBase(baseName);
let candidate = `${base}-local`;
let n = 2;
const dot = baseName.lastIndexOf('.');
const stem = dot === -1 ? baseName : baseName.slice(0, dot);
const ext = dot === -1 ? '' : baseName.slice(dot);
while (
this._browserLocalHandles.has(candidate) ||
this.findTab(candidate) ||
candidate === this._browserLocalFolderMount ||
this.rootTopLevelNameTaken(candidate)
) {
candidate = `${this.slugifyLocalMountBase(`${stem} (${n})${ext}`)}-local`;
n += 1;
}
return candidate;
}
formatBrowserLocalError(err) {
const msg = err && err.message ? err.message : String(err);
if (/aborted|cancel/i.test(msg)) return '';
if (/permission|not allowed|denied/i.test(msg)) {
return 'Permission was denied for local folder access. Choose the folder again if you need access.';
}
return `Local folder: ${msg}`;
}
showBrowserLocalUnsupportedHelp() {
this.showError(
'Opening local folders needs the File System Access API (Chromium: Chrome, Edge, Brave, Opera). ' +
'Firefox and Safari are not supported. The page must be served over HTTPS or from localhost. ' +
'In Brave, you may need to enable the File System Access API flag or relax Shields for this site.',
);
}
async browserLocalFolderIdbOpen() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(BROWSER_LOCAL_FOLDER_IDB_NAME, BROWSER_LOCAL_FOLDER_IDB_VER);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(BROWSER_LOCAL_FOLDER_IDB_STORE)) {
db.createObjectStore(BROWSER_LOCAL_FOLDER_IDB_STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async saveBrowserLocalFolderPersistence() {
if (!this._browserLocalFolderMount || !this._browserLocalRootHandle) return;
try {
const db = await this.browserLocalFolderIdbOpen();
await new Promise((resolve, reject) => {
const tx = db.transaction(BROWSER_LOCAL_FOLDER_IDB_STORE, 'readwrite');
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.objectStore(BROWSER_LOCAL_FOLDER_IDB_STORE).put(
{ mount: this._browserLocalFolderMount, handle: this._browserLocalRootHandle },
'saved',
);
});
db.close();
} catch (_e) {
// Ignore IDB failures (private mode, quota, etc.).
}
}
async clearBrowserLocalFolderPersistence() {
try {
const db = await this.browserLocalFolderIdbOpen();
await new Promise((resolve, reject) => {
const tx = db.transaction(BROWSER_LOCAL_FOLDER_IDB_STORE, 'readwrite');
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.objectStore(BROWSER_LOCAL_FOLDER_IDB_STORE).delete('saved');
});
db.close();
} catch (_e) {
// Ignore.
}
}
async loadBrowserLocalFolderPersistenceRecord() {
try {
const db = await this.browserLocalFolderIdbOpen();
const rec = await new Promise((resolve, reject) => {
const tx = db.transaction(BROWSER_LOCAL_FOLDER_IDB_STORE, 'readonly');
const r = tx.objectStore(BROWSER_LOCAL_FOLDER_IDB_STORE).get('saved');
r.onsuccess = () => resolve(r.result || null);
r.onerror = () => reject(r.error);
});
db.close();
return rec;
} catch (_e) {
return null;
}
}
async restoreBrowserLocalFolderFromPersistence() {
if (!supportsFolderPicker() || typeof indexedDB === 'undefined') return;
const rec = await this.loadBrowserLocalFolderPersistenceRecord();
if (!rec || !rec.mount || !rec.handle || rec.handle.kind !== 'directory') {
await this.clearBrowserLocalFolderPersistence();
return;
}
if (this.rootTopLevelNameTaken(rec.mount)) {
await this.clearBrowserLocalFolderPersistence();
return;
}
let perm = 'granted';
try {
perm = rec.handle.queryPermission ? await rec.handle.queryPermission({ mode: 'read' }) : 'granted';
} catch (_e) {
perm = 'prompt';
}
if (perm === 'denied') {
await this.clearBrowserLocalFolderPersistence();
return;
}
if (perm === 'prompt') {
try {
perm = rec.handle.requestPermission ? await rec.handle.requestPermission({ mode: 'read' }) : 'denied';
} catch (_e) {
perm = 'denied';
}
}
if (perm !== 'granted') {
return;
}
this._browserLocalFolderMount = rec.mount;
this._browserLocalRootHandle = rec.handle;
this._browserLocalHandles.clear();
this._browserLocalHandles.set(rec.mount, rec.handle);
this.expandedDirs.add(rec.mount);
await this.loadBrowserLocalDirectory(rec.mount, { suppressError: true });
}
async pickBrowserLocalRootDirectory() {
if (!supportsFolderPicker()) {
this.showBrowserLocalUnsupportedHelp();
return;
}
try {
const handle = await window.showDirectoryPicker({
id: 'python-editor-local-browse',
mode: 'read',
});
const prevMount = this._browserLocalFolderMount;
for (const key of [...this.directoryCache.keys()]) {
if (
key.startsWith(BROWSER_LOCAL_LEGACY_PREFIX) ||
key === BROWSER_LOCAL_LEGACY_ROOT ||
(prevMount && (key === prevMount || key.startsWith(`${prevMount}/`))) ||
(!key.includes('/') && key.endsWith('-local'))
) {
this.directoryCache.delete(key);
}
}
this._browserLocalHandles.clear();
const mount = this.allocateBrowserLocalFolderMount(handle.name);
this._browserLocalFolderMount = mount;
this._browserLocalRootHandle = handle;
this._browserLocalHandles.set(mount, handle);
this.expandedDirs.add(mount);
await this.loadBrowserLocalDirectory(mount, { suppressError: true });
await this.saveBrowserLocalFolderPersistence();
this.showSuccess(`Opened local folder as “${mount}” (read-only in the editor).`);
} catch (err) {
const msg = this.formatBrowserLocalError(err);
if (msg) this.showError(msg);
}
}
async changeBrowserLocalFolder() {
if (!supportsFolderPicker()) {
this.showBrowserLocalUnsupportedHelp();
return;
}
this.openTabs
.filter((tab) => this.isBrowserLocalFilePath(tab.path))
.forEach((tab) => this.closeTabWithoutPrompt(tab.path));
await this.clearBrowserLocalFolderPersistence();
const prevMount = this._browserLocalFolderMount;
this._browserLocalRootHandle = null;
this._browserLocalFolderMount = null;
this._browserLocalHandles.clear();
for (const key of [...this.directoryCache.keys()]) {
if (
key.startsWith(BROWSER_LOCAL_LEGACY_PREFIX) ||
key === BROWSER_LOCAL_LEGACY_ROOT ||
(prevMount && (key === prevMount || key.startsWith(`${prevMount}/`))) ||
(!key.includes('/') && key.endsWith('-local'))
) {
this.directoryCache.delete(key);
}
}
if (prevMount) {
this.expandedDirs.delete(prevMount);
}
await this.pickBrowserLocalRootDirectory();
}
async removeBrowserLocalFolderConnection() {
if (!this._browserLocalFolderMount) {
return;
}
const label = this._browserLocalFolderMount;
if (
!confirm(
`Remove “${label}” from the file browser? Open tabs from that folder will close. Nothing is deleted on disk.`,
)
) {
return;
}
await this.clearBrowserLocalFolderPersistence();
const prevMount = this._browserLocalFolderMount;
this.openTabs
.filter((tab) => tab.path === prevMount || tab.path.startsWith(`${prevMount}/`))
.forEach((tab) => this.closeTabWithoutPrompt(tab.path));
this._browserLocalRootHandle = null;
this._browserLocalFolderMount = null;
for (const k of [...this._browserLocalHandles.keys()]) {
if (k === prevMount || k.startsWith(`${prevMount}/`)) {
this._browserLocalHandles.delete(k);
}
}
for (const key of [...this.directoryCache.keys()]) {
if (key.startsWith(BROWSER_LOCAL_LEGACY_PREFIX) || key === BROWSER_LOCAL_LEGACY_ROOT) {
this.directoryCache.delete(key);
} else if (prevMount && (key === prevMount || key.startsWith(`${prevMount}/`))) {
this.directoryCache.delete(key);
}
}
this.expandedDirs.delete(prevMount);
this.selectedPath = null;
this.selectedIsDirectory = false;
this.renderFileTree();
this.showSuccess('Removed local folder from the editor.');
}
async removeBrowserLocalRootFileEntry(filePath) {
if (!filePath || filePath.includes('/') || !filePath.endsWith('-local')) {
return;
}
if (!this.isBrowserLocalFilePath(filePath)) {
return;
}
if (!confirm(`Remove “${filePath}” from the editor? The file on disk is not deleted.`)) {
return;
}
this._browserLocalHandles.delete(filePath);
if (this.findTab(filePath)) {
this.closeTabWithoutPrompt(filePath);
}
this.renderFileTree();
this.showSuccess('Removed local file from the editor.');
}
async closeLocalBrowserFileUnderFolder(filePath) {
if (!this.isBrowserLocalFilePath(filePath)) {
return;
}
const mount = this._browserLocalFolderMount;
if (!mount || !filePath.startsWith(`${mount}/`)) {
return;
}
if (!confirm(`Close “${filePath}” in the editor? The file on disk is not deleted.`)) {
return;
}
this._browserLocalHandles.delete(filePath);
if (this.findTab(filePath)) {
this.closeTabWithoutPrompt(filePath);
}
const parent = this.getParentDirectory(filePath);
if (parent) {
this.directoryCache.delete(parent);
await this.loadBrowserLocalDirectory(parent, { suppressError: true });
} else {
this.renderFileTree();
}
this.showSuccess('Removed from editor.');
}
hideFileTreeContextMenu() {
const menu = document.getElementById('file-tree-context-menu');
if (menu) {
menu.hidden = true;
menu.setAttribute('aria-hidden', 'true');
menu.innerHTML = '';
menu.style.left = '';
menu.style.top = '';
}
if (this._onFileTreeContextMenuDismiss) {
document.removeEventListener('pointerdown', this._onFileTreeContextMenuDismiss, true);
document.removeEventListener('keydown', this._onFileTreeContextMenuDismiss, true);
this._onFileTreeContextMenuDismiss = null;
}
}
setupFileTreeContextMenu() {
const tree = document.getElementById('file-tree');
if (!tree || tree.dataset.contextMenuBound === '1') {
return;
}
tree.dataset.contextMenuBound = '1';
tree.addEventListener('contextmenu', (e) => {
if (!tree.contains(e.target)) {
return;
}
e.preventDefault();
this.openFileTreeContextMenu(e);
});
}
openFileTreeContextMenu(e) {
const tree = document.getElementById('file-tree');
const menu = document.getElementById('file-tree-context-menu');
if (!tree || !menu) {
return;
}
this.hideFileTreeContextMenu();
const item = e.target.closest('.file-item');
let path = '';
let isDirectory = false;
if (item) {
path = item.dataset.path || '';
isDirectory = item.dataset.isDirectory === 'true';
this.selectedPath = path;
this.selectedIsDirectory = isDirectory;
} else {
this.selectedPath = 'code';
this.selectedIsDirectory = true;
}
const ch = this.browserLocalChangePath();
const isEmpty = !item;
const isLib = Boolean(path && (path === 'lib' || path.startsWith('lib/')));
const isProjectWritable = Boolean(
path &&
(path === 'code' ||
path.startsWith('code/') ||
path === 'demos' ||
path.startsWith('demos/')),
);
const isLocalChange = Boolean(ch && path === ch);
const isLocalMount = Boolean(this._browserLocalFolderMount && path === this._browserLocalFolderMount);
const isUnderLocal = Boolean(
this._browserLocalFolderMount &&
path &&
path.startsWith(`${this._browserLocalFolderMount}/`) &&
path !== ch,
);
const isLocalFile = this.isBrowserLocalFilePath(path);
const isRootLocalFile =
Boolean(path && !path.includes('/') && path.endsWith('-local')) && this.isBrowserLocalFilePath(path);
const frag = document.createDocumentFragment();
const addBtn = (label, onClick, opts = {}) => {
const b = document.createElement('button');
b.type = 'button';
b.setAttribute('role', 'menuitem');
b.textContent = label;
if (opts.danger) {
b.className = 'file-tree-cm-danger';
}
if (opts.disabled) {
b.disabled = true;
} else {
b.addEventListener('click', () => {
this.hideFileTreeContextMenu();
onClick();
});
}
frag.appendChild(b);
};
const addSep = () => {
const hr = document.createElement('hr');
hr.className = 'file-tree-cm-sep';
frag.appendChild(hr);
};
const addNote = (text) => {
const n = document.createElement('div');
n.className = 'file-tree-cm-note';
n.textContent = text;
frag.appendChild(n);
};
const refreshTree = () => {
this.refreshFileTreePreservingExpansion();
};
if (isLib) {
addNote('Shared library is read-only.');
addBtn('Refresh file tree', refreshTree);
} else if (isLocalChange || isLocalMount) {
if (typeof window.showOpenFilePicker === 'function') {
addBtn('Open local file…', async () => {
await this.pickBrowserLocalSingleFile();
this.renderFileTree();
});
}
addBtn('Change local folder…', async () => {
await this.changeBrowserLocalFolder();
});
addBtn('Remove local folder from editor…', () => this.removeBrowserLocalFolderConnection(), {
danger: true,
});
} else if (isUnderLocal) {
if (isLocalFile) {
addBtn('Remove from editor…', () => this.closeLocalBrowserFileUnderFolder(path), {
danger: true,
});
} else {
addNote('Local folders are read-only here.');
addBtn('Refresh this folder', async () => {
this.directoryCache.delete(path);
await this.loadBrowserLocalDirectory(path, { suppressError: true });
});
}
} else if (isRootLocalFile) {
addBtn('Remove local file from editor…', () => this.removeBrowserLocalRootFileEntry(path), {
danger: true,
});
} else if (isEmpty) {
addBtn('New file…', () => this.showNewFileModal());
addBtn('New folder…', () => this.createNewFolder());
addSep();
if (supportsFolderPicker()) {
addBtn('Open local folder…', async () => {
await this.pickBrowserLocalRootDirectory();
this.renderFileTree();
});
}
if (typeof window.showOpenFilePicker === 'function') {
addBtn('Open local file…', async () => {
await this.pickBrowserLocalSingleFile();
this.renderFileTree();
});
}
addSep();
addBtn('Refresh file tree', refreshTree);
} else if (isProjectWritable) {
addBtn('New file…', () => this.showNewFileModal());
addBtn('New folder…', () => this.createNewFolder(this.getNewFolderPromptDefault()));
addSep();
const canDel =
!((path === 'code' || path === 'demos') && isDirectory) && !this.isBrowserLocalVirtualPath(path);
addBtn('Delete…', () => this.deleteSelected(), { danger: true, disabled: !canDel });
addSep();
if (supportsFolderPicker() && !this._browserLocalFolderMount) {
addBtn('Open local folder…', async () => {
await this.pickBrowserLocalRootDirectory();
this.renderFileTree();
});
}
if (typeof window.showOpenFilePicker === 'function') {
addBtn('Open local file…', async () => {
await this.pickBrowserLocalSingleFile();
this.renderFileTree();
});
}
addBtn('Refresh file tree', refreshTree);
} else {
addBtn('Refresh file tree', refreshTree);
}
menu.appendChild(frag);
menu.hidden = false;
menu.removeAttribute('aria-hidden');
let x = e.clientX;
let y = e.clientY;
const pad = 6;
requestAnimationFrame(() => {
const rr = menu.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
if (x + rr.width + pad > vw) {
x = Math.max(pad, vw - rr.width - pad);
}
if (y + rr.height + pad > vh) {
y = Math.max(pad, vh - rr.height - pad);
}
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
});
this._onFileTreeContextMenuDismiss = (ev) => {
const m = document.getElementById('file-tree-context-menu');
if (!m || m.hidden) {
return;
}
if (ev.type === 'keydown') {
if (ev.key === 'Escape') {
this.hideFileTreeContextMenu();
}
return;
}
if (ev.type === 'pointerdown' && ev.target instanceof Node && m.contains(ev.target)) {
return;
}
if (ev.type === 'pointerdown') {
this.hideFileTreeContextMenu();
}
};
setTimeout(() => {
document.addEventListener('pointerdown', this._onFileTreeContextMenuDismiss, true);
document.addEventListener('keydown', this._onFileTreeContextMenuDismiss, true);
}, 10);
}
async pickBrowserLocalSingleFile() {
if (typeof window.showOpenFilePicker !== 'function') {
this.showError('Single-file picker is not available in this browser.');
return;
}
try {
const picked = await window.showOpenFilePicker({ multiple: false });
const handle = picked && picked[0];
if (!handle || handle.kind !== 'file') return;
const baseName = handle.name || 'file';
const path = this.allocateBrowserLocalFileMount(baseName);
this._browserLocalHandles.set(path, handle);
await this.openFile(path);
if (this.isMobileViewport()) {
this.setMobileSidebarOpen(false);
}
} catch (err) {
const msg = this.formatBrowserLocalError(err);
if (msg) this.showError(msg);
}
}
async listBrowserLocalDirectory(dirPath) {
const handle = this._browserLocalHandles.get(dirPath);
if (!handle || handle.kind !== 'directory') {
return [];
}
const out = [];
for await (const [name, h] of handle.entries()) {
const fullPath = `${dirPath}/${name}`;
this._browserLocalHandles.set(fullPath, h);
if (h.kind === 'directory') {
out.push({ name, is_directory: true, size: null });
} else {
let size = null;
try {
const f = await h.getFile();
size = typeof f.size === 'number' ? f.size : null;
} catch (_e) {
size = null;
}
out.push({ name, is_directory: false, size });
}
}
out.sort((a, b) => {
if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return out;
}
async loadBrowserLocalDirectory(path, options = {}) {
const { suppressError = false, suppressRender = false } = options;
try {
if (path === this._browserLocalFolderMount) {
if (!this._browserLocalRootHandle || !this._browserLocalFolderMount) {
return false;
}
const ch = this.browserLocalChangePath();
const files = [
{ name: '↻ Change folder…', is_directory: false, virtualPath: ch },
...(await this.listBrowserLocalDirectory(path)),
];
this.directoryCache.set(path, files);
if (!suppressRender) this.renderFileTree();
return true;
}
const inner = await this.listBrowserLocalDirectory(path);
this.directoryCache.set(path, inner);
if (!suppressRender) this.renderFileTree();
return true;
} catch (err) {
if (!suppressError) {
console.error('Local folder listing failed:', err);
const msg = this.formatBrowserLocalError(err);
if (msg) this.showError(msg);
}
return false;
}
}
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') &&
!this.isBrowserLocalPath(tab.path)
) {
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') &&
!this.isBrowserLocalPath(tab.path)
) {
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 `
<div class="completion-item ${activeClass}" data-index="${index}">
<span class="completion-name">${item.name}</span>
<span class="completion-type">${itemType}</span>
</div>
`;
}).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;
/* Close the dropdown when the user taps anywhere outside it. We listen
* on both `pointerdown` (fires reliably for touch) and `click` (mouse
* users + keyboard activations) so the menu can't end up floating over
* the Pin / ADC / Serial panels on mobile. */
const closeIfOutside = (event) => {
if (!menu.open) return;
const target = event.target;
if (target instanceof Node && menu.contains(target)) return;
menu.open = false;
};
document.addEventListener('pointerdown', closeIfOutside);
document.addEventListener('click', closeIfOutside);
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.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.refreshFileTreePreservingExpansion();
});
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();
}
});
const packagesMenuBtn = document.getElementById('packages-menu-btn');
if (packagesMenuBtn) {
packagesMenuBtn.addEventListener('click', () => {
const headerMenu = document.getElementById('header-menu');
if (headerMenu) {
headerMenu.open = false;
}
this.showPackagesModal();
});
}
const menuOpenLocalFolderBtn = document.getElementById('menu-open-local-folder-btn');
if (menuOpenLocalFolderBtn) {
menuOpenLocalFolderBtn.addEventListener('click', async (e) => {
/* Keep the user activation for showDirectoryPicker: do not close <details> first. */
e.preventDefault();
e.stopPropagation();
try {
await this.pickBrowserLocalRootDirectory();
} finally {
const headerMenu = document.getElementById('header-menu');
if (headerMenu) {
headerMenu.open = false;
}
this.renderFileTree();
}
});
}
const menuOpenLocalFileBtn = document.getElementById('menu-open-local-file-btn');
if (menuOpenLocalFileBtn) {
menuOpenLocalFileBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
try {
await this.pickBrowserLocalSingleFile();
} finally {
const headerMenu = document.getElementById('header-menu');
if (headerMenu) {
headerMenu.open = false;
}
this.renderFileTree();
}
});
}
const packagesCloseBtn = document.getElementById('packages-close-btn');
if (packagesCloseBtn) {
packagesCloseBtn.addEventListener('click', () => {
this.hidePackagesModal();
});
}
const packagesInstallBtn = document.getElementById('packages-install-btn');
if (packagesInstallBtn) {
packagesInstallBtn.addEventListener('click', () => {
this.installPackagesFromModal();
});
}
const packagesLookupBtn = document.getElementById('packages-lookup-btn');
if (packagesLookupBtn) {
packagesLookupBtn.addEventListener('click', () => {
this.lookupPackageOnPyPI();
});
}
const packagesInstallInput = document.getElementById('packages-install-input');
if (packagesInstallInput) {
packagesInstallInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.installPackagesFromModal();
}
});
}
const packagesModal = document.getElementById('packages-modal');
if (packagesModal) {
packagesModal.addEventListener('click', (e) => {
if (e.target.id === 'packages-modal') {
this.hidePackagesModal();
}
});
}
const packagesSavedList = document.getElementById('packages-saved-list');
if (packagesSavedList) {
packagesSavedList.addEventListener('click', (e) => {
const t = e.target.closest('.packages-remove-btn');
if (!t || !packagesSavedList.contains(t)) {
return;
}
const specEnc = t.getAttribute('data-spec');
if (specEnc) {
this.removePersistedPackage(specEnc);
}
});
}
this.setupHeaderMenu();
this.setupFileTreeContextMenu();
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 ? '*' : '';
let displayPath = tab.path;
if (tab.path.startsWith('code/')) {
displayPath = tab.path.slice('code/'.length);
} else if (tab.path.startsWith('demos/')) {
displayPath = tab.path;
} else if (this._browserLocalFolderMount && tab.path.startsWith(`${this._browserLocalFolderMount}/`)) {
displayPath = tab.path.slice(this._browserLocalFolderMount.length + 1);
} else if (tab.path.startsWith(BROWSER_LOCAL_LEGACY_PREFIX)) {
displayPath = tab.path.slice(BROWSER_LOCAL_LEGACY_PREFIX.length);
}
return `
<div class="tab ${activeClass}" data-path="${tab.path}">
<span class="tab-title">${displayPath}${modifiedMark}</span>
<button class="tab-close" data-path="${tab.path}" title="Close tab">x</button>
</div>
`;
}).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) {
if (typeof path !== 'string') return false;
if (path.startsWith('lib/')) return true;
return this.isBrowserLocalFilePath(path);
}
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 = {}) {
if (this.isBrowserLocalPath(path)) {
return this.loadBrowserLocalDirectory(path, options);
}
const { suppressError = false, suppressRender = 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 || []);
if (!suppressRender) 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 (this._browserLocalFolderMount && this._browserLocalRootHandle) {
rootFiles = [...rootFiles, { name: this._browserLocalFolderMount, is_directory: true }];
}
}
if (rootFiles.length === 0) {
fileTreeElement.innerHTML = `<div class="loading">No files found</div>`;
} 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) {
const changePath = this.browserLocalChangePath();
if (changePath && path === changePath) {
await this.changeBrowserLocalFolder();
this.renderFileTree();
return;
}
await this.openFile(path);
this.renderFileTree();
if (this.isMobileViewport()) {
this.setMobileSidebarOpen(false);
}
} else {
this.renderFileTree();
}
});
});
fileTreeElement.querySelectorAll('.file-item').forEach((item) => {
const p = item.dataset.path || '';
const noDrag = item.dataset.noDrag === 'true' || this.isBrowserLocalPath(p);
item.setAttribute('draggable', noDrag ? 'false' : 'true');
item.addEventListener('dragstart', () => {
if (noDrag) return;
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 = file.virtualPath || (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. */
let displayName = file.name;
if (depth === 0 && !basePath && file.is_directory && file.name === 'code') {
displayName = '/';
}
if (isDir) {
const expanded = this.expandedDirs.has(fullPath);
html += `
<div class="file-item directory ${selectedClass}" data-path="${fullPath}" data-is-directory="true" style="padding-left: ${8 + indent}px">
<span class="file-icon">${expanded ? "📂" : "📁"}</span>
<span class="file-name">${displayName}</span>
</div>
`;
if (expanded) {
html += this.renderDirectoryHtml(fullPath, depth + 1);
}
} else {
const size = file.size ? ` (${this.formatFileSize(file.size)})` : '';
html += `
<div class="file-item ${selectedClass}" data-path="${fullPath}" data-is-directory="false" style="padding-left: ${8 + indent}px">
<span class="file-icon">${this.getFileIcon(file.name)}</span>
<span class="file-name">${displayName}</span>
<span class="file-size">${size}</span>
</div>
`;
}
}
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;
}
if (this.isBrowserLocalFilePath(filePath)) {
const handle = this._browserLocalHandles.get(filePath);
if (!handle || handle.kind !== 'file') {
this.showError(
'That local file is no longer available. Use ⋮ → Open local folder… again, or re-open the file.',
);
return;
}
const file = await handle.getFile();
const maxBytes = 4 * 1024 * 1024;
if (typeof file.size === 'number' && file.size > maxBytes) {
this.showError('That file is larger than 4MB; open a smaller file in the browser editor.');
return;
}
let content = '';
try {
content = await file.text();
} catch (readErr) {
const hint = this.formatBrowserLocalError(readErr);
this.showError(hint || 'Could not read that file (it may be binary or no longer permitted).');
return;
}
this.openTabs.push({
path: filePath,
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: 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();
return;
}
const response = await this.apiFetch(`/api/file/${this.encodeApiFilePath(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(
this.isBrowserLocalFilePath(this.currentFilePath)
? 'Local folder and picked files are read-only in this editor.'
: 'Files in lib are read-only',
);
return;
}
try {
const content = this.editor.state.doc.toString();
const response = await this.apiFetch(`/api/file/${this.encodeApiFilePath(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 (this.isBrowserLocalPath(this.currentFilePath)) {
this.showError('Deleting local disk files from the tree is not supported. Use your file manager.');
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/${this.encodeApiFilePath(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');
}
await this.refreshFileTreePreservingExpansion();
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 runnablePython =
hasPythonFile && !this.isBrowserLocalPath(this.currentFilePath);
const canRun = runMainSelected || runnablePython;
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 = `
<style>
html, body {
margin: 0;
height: 100%;
width: 100%;
background: #111827;
color: #e5e7eb;
font-family: system-ui, sans-serif;
overflow: hidden;
}
body {
display: grid;
place-items: center;
}
.grid {
display: grid;
grid-template-columns: repeat(16, 1fr);
grid-template-rows: repeat(16, 1fr);
gap: clamp(2px, 0.5vmin, 8px);
padding: clamp(4px, 1vmin, 14px);
box-sizing: border-box;
width: 100vmin;
height: 100vmin;
}
.led {
width: 100%;
height: 100%;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.45);
}
/* Match main editor #stop-btn.icon-btn (see styles.css .editor-actions) */
#led-popup-stop-btn.icon-btn {
position: fixed;
top: max(12px, env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right));
z-index: 10;
font-size: 1rem;
line-height: 1;
padding: 0.5rem 0.75rem;
min-width: 40px;
border: 1px solid #e2e8f0;
background-color: white;
color: #c53030;
border-radius: 6px;
cursor: pointer;
font-family: system-ui, sans-serif;
transition: all 0.2s;
}
#led-popup-stop-btn.icon-btn:hover:not(:disabled) {
background-color: #f7fafc;
border-color: #cbd5e0;
}
#led-popup-stop-btn.icon-btn:active:not(:disabled) {
background-color: #edf2f7;
}
#led-popup-stop-btn.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<div id="grid" class="grid"></div>
<button id="led-popup-stop-btn" class="icon-btn" type="button" disabled aria-label="Stop" title="Stop">\u25A0</button>
`;
this.updateLedWindowControls();
win.document.getElementById('led-popup-stop-btn')?.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';
const togglePin = (event) => {
if (event && typeof event.preventDefault === 'function') event.preventDefault();
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));
};
/* On iOS Safari, `click` on a small button inside a scrollable parent
* sometimes fails to fire (the tap gets reclassified as a scroll
* gesture). `pointerup` fires reliably for finger taps; we guard with
* a 300ms suppression flag so we don't double-toggle on browsers that
* deliver both events. */
let lastTriggerAt = 0;
const trigger = (event) => {
const now = (typeof performance !== 'undefined' && performance.now)
? performance.now()
: Date.now();
if (now - lastTriggerAt < 300) return;
lastTriggerAt = now;
togglePin(event);
};
button.addEventListener('pointerup', (event) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
trigger(event);
});
button.addEventListener('click', trigger);
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) {
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;
}
const popup = this.ledPanelWindow;
if (popup && !popup.closed) {
const popupStop = popup.document.getElementById('led-popup-stop-btn');
if (popupStop) {
popupStop.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]<json>` 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 (this.isBrowserLocalPath(selectedRunFile)) {
this.showError(
'The workspace runner cannot execute files from a local folder or *-local file tab. Open a file under code/ or demos/, or enable “Run main.py”.',
);
return;
}
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;
}
let workerRunIssued = false;
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') &&
!this.isBrowserLocalPath(tab.path)
) {
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();
workerRunIssued = true;
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;
if (workerRunIssued) {
this.workerConsoleIoGrace = true;
if (this._workerConsoleIoGraceTimer) {
clearTimeout(this._workerConsoleIoGraceTimer);
}
this._workerConsoleIoGraceTimer = setTimeout(() => {
this.workerConsoleIoGrace = false;
this._workerConsoleIoGraceTimer = null;
}, 800);
}
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;
}
if (this.selectedPath === 'code' && this.selectedIsDirectory) {
this.showError('The workspace root folder “code” cannot be deleted.');
return;
}
if (this.selectedPath === 'demos' && this.selectedIsDirectory) {
this.showError('The demos folder cannot be deleted.');
return;
}
if (this.isBrowserLocalPath(this.selectedPath)) {
this.showError('Local folder files are read-only. Delete or move them in your file manager instead.');
return;
}
const targetType = this.selectedIsDirectory ? "folder" : "file";
if (!confirm(`Delete ${targetType} "${this.selectedPath}"?`)) {
return;
}
try {
const endpoint = this.selectedIsDirectory
? `/api/folder/${this.encodeApiFilePath(this.selectedPath)}`
: `/api/file/${this.encodeApiFilePath(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);
}
const deletedPath = this.selectedPath;
const wasDirectory = this.selectedIsDirectory;
this.selectedPath = null;
this.selectedIsDirectory = false;
if (wasDirectory) {
await this.refreshFileTreePreservingExpansion({ pruneDeletedPrefix: deletedPath });
} else {
await this.refreshFileTreePreservingExpansion();
}
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;
}
if (this.isBrowserLocalPath(this.selectedPath)) {
return fallbackRoot;
}
if (this.selectedPath === 'lib' || this.selectedPath.startsWith('lib/')) {
return fallbackRoot;
}
return this.selectedIsDirectory ? this.selectedPath : this.getParentDirectory(this.selectedPath);
}
getNewFolderPromptDefault() {
const base = this.getNewFileBasePath();
if (!base || base === 'code') {
return 'src/';
}
if (base.startsWith('code/')) {
return `${base.slice('code/'.length)}/`;
}
if (base === 'demos') {
return '';
}
if (base.startsWith('demos/')) {
return `${base.slice('demos/'.length)}/`;
}
return 'src/';
}
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 basePath = this.getNewFileBasePath() || this.getDefaultEditableRoot();
let targetPath;
if (
cleanName.startsWith('code/') ||
cleanName.startsWith('demos/') ||
cleanName.startsWith('lib/')
) {
targetPath = cleanName;
} else {
targetPath = `${basePath}/${cleanName}`.replace(/\/+/g, '/');
}
const response = await this.apiFetch(`/api/file/${this.encodeApiFilePath(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.ensureExpandedAncestorsForPath(targetPath, { isDirectory: false });
await this.refreshFileTreePreservingExpansion();
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 (this.isBrowserLocalPath(sourcePath) || this.isBrowserLocalPath(destinationFolder)) {
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.renderTabs();
await this.refreshFileTreePreservingExpansion();
this.showSuccess('Path moved successfully');
} catch (error) {
this.showError(`Failed to move path: ${error.message}`);
}
}
async createNewFolder(overrideSuggestedPath) {
const suggestion =
typeof overrideSuggestedPath === 'string' && overrideSuggestedPath.length
? overrideSuggestedPath
: this.getNewFolderPromptDefault();
const folderPath = prompt('Enter folder path (under code/ or demos/)', suggestion);
if (!folderPath) return;
let cleanPath = this.normalizeRelativePathInput(folderPath);
if (!cleanPath) {
this.showError('Please enter a folder path');
return;
}
const basePath = this.getNewFileBasePath() || this.getDefaultEditableRoot();
let targetPath;
if (cleanPath.startsWith('code/') || cleanPath.startsWith('demos/') || cleanPath.startsWith('lib/')) {
targetPath = cleanPath;
} else {
targetPath = `${basePath}/${cleanPath}`.replace(/\/+/g, '/');
}
try {
const response = await this.apiFetch(`/api/folder/new/${this.encodeApiFilePath(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.ensureExpandedAncestorsForPath(targetPath, { isDirectory: true });
await this.refreshFileTreePreservingExpansion();
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();
});