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, '&') .replace(//g, '>') .replace(/"/g, '"'); } renderPackagesSavedList() { const ul = document.getElementById('packages-saved-list'); if (!ul) { return; } const specs = this.getPersistedMicropipPackages(); if (!specs.length) { ul.innerHTML = '
  • None yet
  • '; return; } ul.innerHTML = specs .map( (s) => `
  • ${this.escapeHtml(s)}` + `
  • `, ) .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 `
    ${item.name} ${itemType}
    `; }).join(''); dropdown.querySelectorAll('.completion-item').forEach((node) => { node.addEventListener('mousedown', (event) => { event.preventDefault(); this.completionIndex = Number(node.dataset.index || 0); this.applySelectedCompletion(); }); }); } positionCompletionDropdown() { const dropdown = document.getElementById('completion-dropdown'); if (!dropdown) return; const cursor = this.editor.state.selection.main.head; const coords = this.editor.coordsAtPos(cursor); const editorRect = this.editor.dom.getBoundingClientRect(); if (!coords || !editorRect) return; dropdown.style.top = `${coords.bottom - editorRect.top + 4}px`; dropdown.style.left = `${coords.left - editorRect.left}px`; } moveCompletionSelection(direction) { if (!this.completionOpen || this.completionItems.length === 0) return; const total = this.completionItems.length; this.completionIndex = (this.completionIndex + direction + total) % total; this.renderCompletionDropdown(); } hideCompletionDropdown() { const dropdown = document.getElementById('completion-dropdown'); if (dropdown) { dropdown.style.display = 'none'; dropdown.innerHTML = ''; } this.completionOpen = false; this.completionItems = []; this.completionIndex = 0; } async showCompletionDropdown() { const dropdown = document.getElementById('completion-dropdown'); if (!dropdown) return; const completions = await this.fetchCompletions(); if (completions.length === 0) { this.hideCompletionDropdown(); return; } const cursor = this.editor.state.selection.main.head; const { prefix } = this.getCurrentPrefix(cursor); const ranked = completions .filter((item) => item && typeof item.name === 'string') .sort((a, b) => { const aStarts = prefix && a.name.startsWith(prefix) ? 1 : 0; const bStarts = prefix && b.name.startsWith(prefix) ? 1 : 0; return bStarts - aStarts; }); if (ranked.length === 0) { this.hideCompletionDropdown(); return; } this.completionItems = ranked; this.completionIndex = 0; this.completionOpen = true; this.renderCompletionDropdown(); this.positionCompletionDropdown(); dropdown.style.display = 'block'; } applySelectedCompletion() { if (!this.completionOpen || this.completionItems.length === 0) return; const selected = this.completionItems[this.completionIndex]; if (!selected || typeof selected.name !== 'string') return; const cursor = this.editor.state.selection.main.head; const { prefixStart } = this.getCurrentPrefix(cursor); const isCallable = selected.type === 'function' || selected.type === 'method'; const insertText = isCallable ? `${selected.name}()` : selected.name; const cursorOffset = isCallable ? selected.name.length + 1 : insertText.length; this.editor.dispatch({ changes: { from: prefixStart, to: cursor, insert: insertText }, selection: { anchor: prefixStart + cursorOffset } }); this.hideCompletionDropdown(); } isMobileViewport() { return typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 768px)').matches; } setMobileSidebarOpen(open) { const sidebar = document.getElementById('sidebar'); const backdrop = document.getElementById('sidebar-backdrop'); const toggle = document.getElementById('sidebar-toggle'); if (!sidebar || !backdrop || !toggle) return; sidebar.classList.toggle('is-open', open); backdrop.classList.toggle('is-open', open); backdrop.hidden = !open; toggle.setAttribute('aria-expanded', String(open)); } setDesktopSidebarCollapsed(collapsed) { const sidebar = document.getElementById('sidebar'); const toggle = document.getElementById('sidebar-toggle'); if (!sidebar || !toggle) return; sidebar.classList.toggle('is-collapsed', collapsed); toggle.setAttribute('aria-expanded', String(!collapsed)); this.saveSessionState(); } setupSidebarToggle() { const toggle = document.getElementById('sidebar-toggle'); const backdrop = document.getElementById('sidebar-backdrop'); if (!toggle) return; /* Initial state: mobile drawer starts closed; desktop sidebar starts open. */ if (this.isMobileViewport()) { toggle.setAttribute('aria-expanded', 'false'); } else { const sidebar = document.getElementById('sidebar'); const collapsed = Boolean(sidebar && sidebar.classList.contains('is-collapsed')); toggle.setAttribute('aria-expanded', String(!collapsed)); } toggle.addEventListener('click', () => { const sidebar = document.getElementById('sidebar'); if (!sidebar) return; if (this.isMobileViewport()) { const isOpen = sidebar.classList.contains('is-open'); this.setMobileSidebarOpen(!isOpen); } else { const collapsed = sidebar.classList.contains('is-collapsed'); this.setDesktopSidebarCollapsed(!collapsed); } }); if (backdrop) { backdrop.addEventListener('click', () => this.setMobileSidebarOpen(false)); } document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { const sidebar = document.getElementById('sidebar'); if (sidebar?.classList.contains('is-open')) { this.setMobileSidebarOpen(false); } } }); } setConsoleCollapsed(collapsed) { const container = document.getElementById('console-container'); const button = document.getElementById('console-toggle'); if (!container || !button) return; container.classList.toggle('is-collapsed', collapsed); button.setAttribute('aria-expanded', String(!collapsed)); this.saveSessionState(); } setupConsoleToggle() { const button = document.getElementById('console-toggle'); if (!button) return; button.addEventListener('click', () => { const container = document.getElementById('console-container'); const collapsed = container?.classList.contains('is-collapsed'); this.setConsoleCollapsed(!collapsed); }); } setupHeaderMenu() { const menu = document.getElementById('header-menu'); if (!menu) return; /* 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
    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 `
    ${displayPath}${modifiedMark}
    `; }).join(''); tabsContainer.querySelectorAll('.tab').forEach((tabElement) => { tabElement.addEventListener('click', (event) => { if (event.target.classList.contains('tab-close')) { return; } this.switchToTab(tabElement.dataset.path); }); }); tabsContainer.querySelectorAll('.tab-close').forEach((closeButton) => { closeButton.addEventListener('click', (event) => { event.stopPropagation(); this.closeTab(closeButton.dataset.path); }); }); this.saveSessionState(); } findTab(path) { return this.openTabs.find((tab) => tab.path === path); } isReadOnlyPath(path) { 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 = `
    No files found
    `; } else { const initialDepth = treeRootPath ? 1 : 0; fileTreeElement.innerHTML = this.renderDirectoryHtml(treeRootPath, initialDepth, rootFiles); } fileTreeElement.querySelectorAll('.file-item').forEach((item) => { item.addEventListener('click', async () => { const path = item.dataset.path; const isDirectory = item.dataset.isDirectory === 'true'; this.selectedPath = path; this.selectedIsDirectory = isDirectory; if (isDirectory && path) { await this.toggleDirectory(path); } else if (!isDirectory) { 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 += `
    ${expanded ? "📂" : "📁"} ${displayName}
    `; if (expanded) { html += this.renderDirectoryHtml(fullPath, depth + 1); } } else { const size = file.size ? ` (${this.formatFileSize(file.size)})` : ''; html += `
    ${this.getFileIcon(file.name)} ${displayName} ${size}
    `; } } return html; } async toggleDirectory(path) { if (this.expandedDirs.has(path)) { this.expandedDirs.delete(path); this.renderFileTree(); return; } this.expandedDirs.add(path); if (!this.directoryCache.has(path)) { await this.loadDirectory(path); return; } this.renderFileTree(); } getFileIcon(filename) { const ext = filename.split('.').pop().toLowerCase(); const icons = { 'txt': '📄', 'md': '📝', 'py': '🐍', 'js': '📜', 'html': '🌐', 'css': '🎨', 'json': '📋', 'yaml': '⚙️', 'yml': '⚙️', 'xml': '📄', 'csv': '📊' }; return icons[ext] || '📄'; } formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } async openFile(filePath) { this.hideCompletionDropdown(); try { const existingTab = this.findTab(filePath); if (existingTab) { this.switchToTab(filePath); return; } 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 = `
    `; 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]` 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(); });