/** * Browser-local workspace, backed by IndexedDB or a user-picked folder * (File System Access API, Chromium browsers). * * Used when the editor is opened in "no-login" mode (`?local=1` or the * `python-editor.local-mode` localStorage flag). Mirrors the shape of the * server file API so `apiFetch()` can dispatch through this client without * the rest of the editor caring whether the workspace is remote or local. * * Backends: * - IndexedDBBackend (default, supported everywhere): stores files in * `led-editor-local-v1.files`, keyed by path. * - FileSystemBackend: stores files inside a `FileSystemDirectoryHandle` * the user picked via `window.showDirectoryPicker()`. The handle is * persisted in `led-editor-local-v1.meta` so it survives reloads; * permission is re-verified on each session. * * The bundled MicroPython stubs (`lib/`) are loaded once from * `/static/bundled-lib/*.py` (or `/api/public/lib-bundle` as fallback) and * served entirely from memory — they never touch the user's storage backend. */ import { createZip } from '/static/zip-utils.js?v=1'; const DB_NAME = 'led-editor-local-v1'; const DB_VERSION = 1; const META_FOLDER_HANDLE = 'folder-handle'; const META_STORAGE_MODE = 'storage-mode'; const DEFAULT_MAIN_PY = 'print("Hello, World!")\n'; function openDb() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains('files')) { db.createObjectStore('files', { keyPath: 'path' }); } if (!db.objectStoreNames.contains('meta')) { db.createObjectStore('meta', { keyPath: 'key' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function txPromise(tx) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error); tx.onerror = () => reject(tx.error); }); } function reqPromise(req) { return new Promise((resolve, reject) => { req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function jsonResponse(status, body) { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }); } function normalizePath(input) { let cleaned = String(input || '').trim().replace(/^\/+/, ''); if (!cleaned) return ''; const parts = cleaned.split('/').filter(Boolean); while (parts.length >= 2 && parts[0] === 'code' && parts[1] === 'code') { parts.splice(1, 1); } return parts.join('/'); } function basename(path) { if (!path) return ''; const idx = path.lastIndexOf('/'); return idx === -1 ? path : path.slice(idx + 1); } function isLibPath(path) { return path === 'lib' || path === 'lib/' || path.startsWith('lib/'); } function isWritablePath(path) { if (!path) return false; const root = path.split('/')[0]; return root === 'code' || root === 'demos'; } function splitParts(path) { return path.split('/').filter(Boolean); } /* ------------------------------------------------------------------ */ /* Storage backends */ /* ------------------------------------------------------------------ */ /** * IndexedDB backend — schema: * { path: "code/foo/bar.py", is_directory: false, content: "...", updated_at: 0 } * Folders get their own rows so empty directories survive. */ class IndexedDbBackend { constructor(dbPromise) { this.dbPromise = dbPromise; } static get id() { return 'indexeddb'; } describe() { return 'Browser storage (IndexedDB)'; } async _all() { const db = await this.dbPromise; const tx = db.transaction('files', 'readonly'); const req = tx.objectStore('files').getAll(); return reqPromise(req); } async _get(path) { const db = await this.dbPromise; const tx = db.transaction('files', 'readonly'); const req = tx.objectStore('files').get(path); return reqPromise(req); } async _put(entry) { const db = await this.dbPromise; const tx = db.transaction('files', 'readwrite'); tx.objectStore('files').put(entry); return txPromise(tx); } async _ensureAncestorDirs(path) { const segs = splitParts(path).slice(0, -1); if (!segs.length) return; const db = await this.dbPromise; const tx = db.transaction('files', 'readwrite'); const store = tx.objectStore('files'); let acc = ''; for (const seg of segs) { acc = acc ? `${acc}/${seg}` : seg; store.put({ path: acc, is_directory: true, content: null, updated_at: Date.now() }); } return txPromise(tx); } async ensureSeed() { const existing = await this._get('code/main.py'); if (existing) return; await this._ensureAncestorDirs('code/main.py'); await this._put({ path: 'code/main.py', is_directory: false, content: DEFAULT_MAIN_PY, updated_at: Date.now(), }); } async listDir(path) { const all = await this._all(); const byPath = new Map(all.map((row) => [row.path, row])); if (!path) { const seen = new Set(); const result = []; for (const row of all) { if (!row.path || row.path.startsWith('users/') || row.path === 'users') continue; if (row.path.startsWith('lib/') || row.path === 'lib') continue; const top = row.path.split('/')[0]; if (seen.has(top)) continue; seen.add(top); const entry = byPath.get(top); const isDir = entry ? Boolean(entry.is_directory) : true; result.push({ name: top, is_directory: isDir, size: isDir ? null : (entry && entry.content ? entry.content.length : 0), }); } return result; } const target = byPath.get(path); if (!target || !target.is_directory) return null; const prefix = `${path}/`; const seen = new Set(); const result = []; for (const row of all) { if (!row.path.startsWith(prefix)) continue; const rest = row.path.slice(prefix.length); if (!rest) continue; const nextSeg = rest.split('/')[0]; if (seen.has(nextSeg)) continue; seen.add(nextSeg); const childPath = `${prefix}${nextSeg}`; const child = byPath.get(childPath); const isDir = child ? Boolean(child.is_directory) : true; result.push({ name: nextSeg, is_directory: isDir, size: isDir ? null : (child && child.content ? child.content.length : 0), }); } return result; } async readFile(path) { const entry = await this._get(path); if (!entry) return { kind: 'missing' }; if (entry.is_directory) return { kind: 'directory' }; return { kind: 'file', content: entry.content == null ? '' : String(entry.content) }; } async writeFile(path, content) { await this._ensureAncestorDirs(path); await this._put({ path, is_directory: false, content, updated_at: Date.now(), }); } async deleteFile(path) { const entry = await this._get(path); if (!entry) return { ok: false, reason: 'missing' }; if (entry.is_directory) return { ok: false, reason: 'is_directory' }; const db = await this.dbPromise; const tx = db.transaction('files', 'readwrite'); tx.objectStore('files').delete(path); await txPromise(tx); return { ok: true }; } async createFolder(path) { const existing = await this._get(path); if (existing) return { ok: false, reason: 'exists' }; await this._ensureAncestorDirs(path); await this._put({ path, is_directory: true, content: null, updated_at: Date.now() }); return { ok: true }; } async deleteFolder(path) { const entry = await this._get(path); if (!entry) return { ok: false, reason: 'missing' }; if (!entry.is_directory) return { ok: false, reason: 'not_directory' }; const all = await this._all(); const prefix = `${path}/`; const targets = [path]; for (const row of all) if (row.path.startsWith(prefix)) targets.push(row.path); const db = await this.dbPromise; const tx = db.transaction('files', 'readwrite'); const store = tx.objectStore('files'); for (const p of targets) store.delete(p); await txPromise(tx); return { ok: true }; } async movePath(source, dest) { const entry = await this._get(source); if (!entry) return { ok: false, reason: 'missing' }; const newPath = dest ? `${dest}/${basename(source)}` : basename(source); if (newPath === source) return { ok: false, reason: 'same' }; if (entry.is_directory && newPath.startsWith(`${source}/`)) { return { ok: false, reason: 'into_self' }; } const conflict = await this._get(newPath); if (conflict) return { ok: false, reason: 'conflict' }; if (dest) { const destEntry = await this._get(dest); if (!destEntry || !destEntry.is_directory) return { ok: false, reason: 'dest_missing' }; } await this._ensureAncestorDirs(newPath); const db = await this.dbPromise; const all = await this._all(); const tx = db.transaction('files', 'readwrite'); const store = tx.objectStore('files'); if (entry.is_directory) { const prefix = `${source}/`; store.delete(source); store.put({ ...entry, path: newPath, updated_at: Date.now() }); for (const row of all) { if (!row.path.startsWith(prefix)) continue; const remapped = `${newPath}/${row.path.slice(prefix.length)}`; store.delete(row.path); store.put({ ...row, path: remapped, updated_at: Date.now() }); } } else { store.delete(source); store.put({ ...entry, path: newPath, updated_at: Date.now() }); } await txPromise(tx); return { ok: true, newPath, type: entry.is_directory ? 'folder' : 'file' }; } async listAllPyFiles() { const all = await this._all(); const out = {}; for (const row of all) { if (row.is_directory) continue; if (!row.path.toLowerCase().endsWith('.py')) continue; if (row.path.startsWith('users/')) continue; if (row.path.startsWith('lib/')) continue; out[row.path] = row.content == null ? '' : String(row.content); } return out; } async listAllUserFiles() { const all = await this._all(); const out = {}; for (const row of all) { if (row.is_directory) continue; if (row.path.startsWith('users/')) continue; if (row.path.startsWith('lib/')) continue; out[row.path] = row.content == null ? '' : String(row.content); } return out; } } /** * File System Access API backend — files live inside a single * `FileSystemDirectoryHandle` chosen by the user. The picked folder is * treated as the root **of the editable workspace**: a `code/` subfolder * is created lazily so paths like `code/main.py` map to * `/code/main.py` on disk. Drag a folder of demos in there once, * and the editor reads/writes them straight from disk. */ class FileSystemBackend { constructor(rootHandle) { this.root = rootHandle; } static get id() { return 'filesystem'; } describe() { return `Folder · ${this.root.name}`; } async _resolveDir(parts, create = false) { let dir = this.root; for (const part of parts) { if (!part) continue; dir = await dir.getDirectoryHandle(part, { create }); } return dir; } async _maybeResolveDir(parts) { try { return await this._resolveDir(parts); } catch (_e) { return null; } } async ensureSeed() { await this._resolveDir(['demos'], true); const codeDir = await this._resolveDir(['code'], true); let mainExists = false; for await (const [name] of codeDir.entries()) { if (name === 'main.py') { mainExists = true; break; } } if (mainExists) return; const handle = await codeDir.getFileHandle('main.py', { create: true }); const writable = await handle.createWritable(); await writable.write(DEFAULT_MAIN_PY); await writable.close(); } async listDir(path) { const parts = splitParts(path); const dir = await this._maybeResolveDir(parts); if (!dir) return null; const result = []; for await (const [name, handle] of dir.entries()) { if (name.startsWith('.')) continue; const isDir = handle.kind === 'directory'; let size = null; if (!isDir) { try { const file = await handle.getFile(); size = file.size; } catch (_e) { // Some browsers throw on getFile() for permission-pending handles. } } result.push({ name, is_directory: isDir, size }); } return result; } async readFile(path) { const parts = splitParts(path); if (!parts.length) return { kind: 'missing' }; const fileName = parts.pop(); const dir = await this._maybeResolveDir(parts); if (!dir) return { kind: 'missing' }; let handle; try { handle = await dir.getFileHandle(fileName); } catch (_e) { return { kind: 'missing' }; } if (handle.kind !== 'file') return { kind: 'directory' }; const file = await handle.getFile(); return { kind: 'file', content: await file.text() }; } async writeFile(path, content) { const parts = splitParts(path); if (!parts.length) throw new Error('empty path'); const fileName = parts.pop(); const dir = await this._resolveDir(parts, true); const handle = await dir.getFileHandle(fileName, { create: true }); const writable = await handle.createWritable(); await writable.write(content); await writable.close(); } async deleteFile(path) { const parts = splitParts(path); if (!parts.length) return { ok: false, reason: 'missing' }; const fileName = parts.pop(); const dir = await this._maybeResolveDir(parts); if (!dir) return { ok: false, reason: 'missing' }; try { const handle = await dir.getFileHandle(fileName); if (handle.kind !== 'file') return { ok: false, reason: 'is_directory' }; await dir.removeEntry(fileName); return { ok: true }; } catch (_e) { return { ok: false, reason: 'missing' }; } } async createFolder(path) { const parts = splitParts(path); if (!parts.length) return { ok: false, reason: 'empty' }; const exists = await this._maybeResolveDir(parts); if (exists) return { ok: false, reason: 'exists' }; await this._resolveDir(parts, true); return { ok: true }; } async deleteFolder(path) { const parts = splitParts(path); if (!parts.length) return { ok: false, reason: 'empty' }; const folderName = parts.pop(); const parent = await this._maybeResolveDir(parts); if (!parent) return { ok: false, reason: 'missing' }; try { await parent.getDirectoryHandle(folderName); } catch (_e) { return { ok: false, reason: 'missing' }; } await parent.removeEntry(folderName, { recursive: true }); return { ok: true }; } async _readAllRecursive(parts, dir, acc) { for await (const [name, handle] of dir.entries()) { if (name.startsWith('.')) continue; const childParts = parts.concat([name]); if (handle.kind === 'directory') { await this._readAllRecursive(childParts, handle, acc); } else { acc.push({ path: childParts.join('/'), handle }); } } } async movePath(source, dest) { /* The File System Access API has no atomic rename, so we copy + delete. */ const srcParts = splitParts(source); if (!srcParts.length) return { ok: false, reason: 'missing' }; const srcLeaf = srcParts.pop(); const srcParent = await this._maybeResolveDir(srcParts); if (!srcParent) return { ok: false, reason: 'missing' }; let srcHandle; try { srcHandle = await srcParent.getDirectoryHandle(srcLeaf); } catch (_e) { try { srcHandle = await srcParent.getFileHandle(srcLeaf); } catch (_e2) { return { ok: false, reason: 'missing' }; } } const newPath = dest ? `${dest}/${srcLeaf}` : srcLeaf; if (newPath === source) return { ok: false, reason: 'same' }; if (srcHandle.kind === 'directory' && newPath.startsWith(`${source}/`)) { return { ok: false, reason: 'into_self' }; } const conflictParts = splitParts(newPath); const conflictLeaf = conflictParts.pop(); const conflictParent = await this._maybeResolveDir(conflictParts); if (conflictParent) { let conflict = null; try { conflict = await conflictParent.getFileHandle(conflictLeaf); } catch (_e) { try { conflict = await conflictParent.getDirectoryHandle(conflictLeaf); } catch (_e2) { conflict = null; } } if (conflict) return { ok: false, reason: 'conflict' }; } if (srcHandle.kind === 'file') { const text = await (await srcHandle.getFile()).text(); await this.writeFile(newPath, text); await srcParent.removeEntry(srcLeaf); } else { await this._resolveDir(splitParts(newPath), true); const collected = []; await this._readAllRecursive([], srcHandle, collected); for (const { path, handle } of collected) { const text = await (await handle.getFile()).text(); await this.writeFile(`${newPath}/${path}`, text); } await srcParent.removeEntry(srcLeaf, { recursive: true }); } return { ok: true, newPath, type: srcHandle.kind === 'directory' ? 'folder' : 'file' }; } async listAllPyFiles() { const out = {}; for (const rootSeg of ['code', 'demos']) { let dir; try { dir = await this._resolveDir([rootSeg]); } catch (_e) { continue; } const collected = []; await this._readAllRecursive([rootSeg], dir, collected); for (const { path, handle } of collected) { if (!path.toLowerCase().endsWith('.py')) continue; try { out[path] = await (await handle.getFile()).text(); } catch (_e) { // Skip unreadable files. } } } return out; } async listAllUserFiles() { const out = {}; for (const rootSeg of ['code', 'demos']) { let dir; try { dir = await this._resolveDir([rootSeg]); } catch (_e) { continue; } const collected = []; await this._readAllRecursive([rootSeg], dir, collected); for (const { path, handle } of collected) { try { out[path] = await (await handle.getFile()).text(); } catch (_e) { // Skip unreadable files (e.g. binaries the editor can't open). } } } return out; } } /* ------------------------------------------------------------------ */ /* LocalWorkspaceClient */ /* ------------------------------------------------------------------ */ export class LocalWorkspaceClient { constructor() { this.dbPromise = null; this.libBundlePromise = null; this.libFiles = null; this.backend = null; this.backendReady = null; } db() { if (!this.dbPromise) this.dbPromise = openDb(); return this.dbPromise; } async ready() { await this._loadLibBundle(); if (!this.backendReady) this.backendReady = this._initBackend(); await this.backendReady; } async getMeta(key) { const db = await this.db(); const tx = db.transaction('meta', 'readonly'); const req = tx.objectStore('meta').get(key); const row = await reqPromise(req); return row ? row.value : null; } async putMeta(key, value) { const db = await this.db(); const tx = db.transaction('meta', 'readwrite'); tx.objectStore('meta').put({ key, value }); return txPromise(tx); } async _initBackend() { const desiredMode = (await this.getMeta(META_STORAGE_MODE)) || IndexedDbBackend.id; if (desiredMode === FileSystemBackend.id && this._supportsFileSystem()) { const handle = await this.getMeta(META_FOLDER_HANDLE); if (handle) { // Silent permission probe only — `requestPermission()` must be called // from a user gesture (button click). If the permission has lapsed, // we surface the saved handle so the UI can offer a "Reconnect" button. const granted = await queryHandlePermission(handle); if (granted) { this.backend = new FileSystemBackend(handle); await this.backend.ensureSeed(); return; } this.savedFolderHandle = handle; } } this.backend = new IndexedDbBackend(this.db()); await this.backend.ensureSeed(); } async reconnectSavedFolder() { if (!this.savedFolderHandle || !this._supportsFileSystem()) return false; const ok = await verifyHandlePermission(this.savedFolderHandle, true); if (!ok) return false; this.backend = new FileSystemBackend(this.savedFolderHandle); this.backendReady = Promise.resolve(); await this.backend.ensureSeed(); await this.putMeta(META_STORAGE_MODE, FileSystemBackend.id); this.savedFolderHandle = null; return true; } _supportsFileSystem() { return typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function'; } async pickDirectory() { if (!this._supportsFileSystem()) { throw new Error('Folder picker not supported in this browser'); } const handle = await window.showDirectoryPicker({ id: 'led-editor-workspace', mode: 'readwrite', startIn: 'documents', }); const ok = await verifyHandlePermission(handle, true); if (!ok) throw new Error('Read/write permission was denied'); await this.putMeta(META_FOLDER_HANDLE, handle); await this.putMeta(META_STORAGE_MODE, FileSystemBackend.id); this.backend = new FileSystemBackend(handle); this.backendReady = Promise.resolve(); await this.backend.ensureSeed(); return handle; } async useIndexedDb() { await this.putMeta(META_STORAGE_MODE, IndexedDbBackend.id); this.backend = new IndexedDbBackend(this.db()); this.backendReady = Promise.resolve(); await this.backend.ensureSeed(); } async describeStorage() { await this.ready(); const isFs = this.backend instanceof FileSystemBackend; return { mode: isFs ? FileSystemBackend.id : IndexedDbBackend.id, label: this.backend.describe(), supportsFolder: this._supportsFileSystem(), pendingReconnect: !isFs && Boolean(this.savedFolderHandle), pendingFolderName: this.savedFolderHandle ? this.savedFolderHandle.name : null, }; } /* --- Lib bundle ------------------------------------------------- */ async _tryLoadStaticBundledLib() { const names = ['machine.py', 'neopixel.py', 'browser_fetch.py']; const map = {}; for (const name of names) { const r = await fetch(`/static/bundled-lib/${encodeURIComponent(name)}`, { cache: 'force-cache', }); if (!r.ok) return null; map[name] = await r.text(); } return map; } async _loadLibBundle() { if (this.libBundlePromise) return this.libBundlePromise; this.libBundlePromise = (async () => { let map = null; try { map = await this._tryLoadStaticBundledLib(); } catch (_e) { map = null; } if (!map) { try { const resp = await fetch('/api/public/lib-bundle'); if (resp.ok) { const body = await resp.json(); map = body && body.files ? body.files : null; } } catch (_e) { map = null; } } this.libFiles = map || {}; })(); return this.libBundlePromise; } _libListing(path) { if (!this.libFiles || !Object.keys(this.libFiles).length) return null; if (path === '' || path === 'lib') { if (path === '') { return { __root: true }; } return Object.entries(this.libFiles).map(([name, content]) => ({ name, is_directory: false, size: content.length, })); } if (path.startsWith('lib/')) { const rel = path.slice(4); // Flat lib for now (machine.py, neopixel.py at top level). const child = this.libFiles[rel]; return child ? [] : null; } return null; } /* --- Endpoint dispatchers --------------------------------------- */ async listFiles(rawPath) { await this.ready(); const path = normalizePath(rawPath); if (!path) { const result = (await this.backend.listDir('')) || []; const filtered = result.filter((row) => row.name !== 'lib' && row.name !== 'users'); // Always advertise a top-level `code` directory even if empty. if (!filtered.some((row) => row.name === 'code')) { filtered.push({ name: 'code', is_directory: true, size: null }); } if (!filtered.some((row) => row.name === 'demos')) { filtered.push({ name: 'demos', is_directory: true, size: null }); } if (this.libFiles && Object.keys(this.libFiles).length) { filtered.push({ name: 'lib', is_directory: true, size: null }); } filtered.sort((a, b) => a.name.localeCompare(b.name)); return jsonResponse(200, { files: filtered }); } if (path === 'lib' || path.startsWith('lib/')) { const lib = this._libListing(path); if (!lib) return jsonResponse(404, { detail: 'Directory not found' }); const arr = Array.isArray(lib) ? lib : []; arr.sort((a, b) => a.name.localeCompare(b.name)); return jsonResponse(200, { files: arr }); } const list = await this.backend.listDir(path); if (list === null) return jsonResponse(404, { detail: 'Directory not found' }); list.sort((a, b) => a.name.localeCompare(b.name)); return jsonResponse(200, { files: list }); } async readFile(rawPath) { await this.ready(); const path = normalizePath(rawPath); if (!path) return jsonResponse(400, { detail: 'Empty path' }); if (isLibPath(path)) { const rel = path.slice(4); const content = this.libFiles && this.libFiles[rel]; if (typeof content !== 'string') return jsonResponse(404, { detail: 'File not found' }); return jsonResponse(200, { content, filename: basename(path) }); } const result = await this.backend.readFile(path); if (result.kind === 'missing') return jsonResponse(404, { detail: 'File not found' }); if (result.kind === 'directory') return jsonResponse(400, { detail: 'Path is a directory' }); return jsonResponse(200, { content: result.content, filename: basename(path) }); } async writeFile(rawPath, body) { await this.ready(); const path = normalizePath(rawPath); if (!path) return jsonResponse(400, { detail: 'Empty path' }); if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' }); if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable (lib is read-only)' }); const content = body && typeof body.content === 'string' ? body.content : ''; await this.backend.writeFile(path, content); return jsonResponse(200, { filename: basename(path) }); } async deleteFile(rawPath) { await this.ready(); const path = normalizePath(rawPath); if (!path) return jsonResponse(400, { detail: 'Empty path' }); if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' }); if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' }); const r = await this.backend.deleteFile(path); if (!r.ok) { if (r.reason === 'missing') return jsonResponse(404, { detail: 'File not found' }); if (r.reason === 'is_directory') return jsonResponse(400, { detail: 'Cannot delete directories' }); return jsonResponse(500, { detail: 'Delete failed' }); } return jsonResponse(200, { ok: true }); } async createFolder(rawPath) { await this.ready(); const path = normalizePath(rawPath); if (!path) return jsonResponse(400, { detail: 'Empty path' }); if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' }); if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' }); const r = await this.backend.createFolder(path); if (!r.ok) { if (r.reason === 'exists') return jsonResponse(400, { detail: 'Folder already exists' }); return jsonResponse(500, { detail: 'Create failed' }); } return jsonResponse(200, { name: basename(path) }); } async deleteFolder(rawPath) { await this.ready(); const path = normalizePath(rawPath); if (!path) return jsonResponse(400, { detail: 'Empty path' }); if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' }); if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' }); const r = await this.backend.deleteFolder(path); if (!r.ok) { if (r.reason === 'missing') return jsonResponse(404, { detail: 'Folder not found' }); if (r.reason === 'not_directory') return jsonResponse(400, { detail: 'Path is not a directory' }); return jsonResponse(500, { detail: 'Delete failed' }); } return jsonResponse(200, { ok: true }); } async movePath(body) { await this.ready(); const source = normalizePath(body && body.source_path); const dest = normalizePath(body && body.destination_folder); if (!source) return jsonResponse(400, { detail: 'Missing source_path' }); if (isLibPath(source) || isLibPath(dest)) return jsonResponse(403, { detail: 'lib is read-only' }); if (!isWritablePath(source) || (dest && !isWritablePath(dest))) { return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' }); } const r = await this.backend.movePath(source, dest); if (!r.ok) { if (r.reason === 'missing') return jsonResponse(404, { detail: 'Source path not found' }); if (r.reason === 'dest_missing') return jsonResponse(404, { detail: 'Destination folder not found' }); if (r.reason === 'same') return jsonResponse(400, { detail: 'Path is already in that folder' }); if (r.reason === 'into_self') return jsonResponse(400, { detail: 'Cannot move a folder into itself or its child' }); if (r.reason === 'conflict') return jsonResponse(409, { detail: 'A path with that name already exists in destination' }); return jsonResponse(500, { detail: 'Move failed' }); } return jsonResponse(200, { new_path: r.newPath, moved: r.type }); } async pySources() { await this.ready(); const codeFiles = await this.backend.listAllPyFiles(); const out = { ...codeFiles }; if (this.libFiles) { for (const [name, content] of Object.entries(this.libFiles)) { out[`lib/${name}`] = content; } } return jsonResponse(200, { files: out }); } /** * Build a ZIP of the entire user workspace (every file, not just `.py`). * `lib/` stubs are not included — they're bundled with the app, so users * always have a fresh copy. * * Returns `{ blob, fileCount, byteCount }`. UI is responsible for * triggering the download. */ async exportWorkspace() { await this.ready(); const files = await this.backend.listAllUserFiles(); const entries = []; let byteCount = 0; for (const [path, content] of Object.entries(files)) { entries.push({ name: path, content }); byteCount += content.length; } if (!entries.length) { // ZIP spec allows empty archives but some tools choke; ship a README. entries.push({ name: 'README.txt', content: 'Your LED Editor workspace was empty when this archive was created.\n', }); } return { blob: createZip(entries), fileCount: Object.keys(files).length, byteCount, }; } /* --- URL router ------------------------------------------------- */ async dispatch(url, init) { let pathname = url; let search = ''; try { const parsed = new URL(url, window.location.origin); pathname = parsed.pathname; search = parsed.search; } catch (_e) { // URL is already a path. } const method = (init && init.method ? init.method : 'GET').toUpperCase(); const params = new URLSearchParams(search); if (pathname === '/api/files') { return this.listFiles(params.get('path') || ''); } if (pathname === '/api/workspace/py-sources') { return this.pySources(); } if (pathname === '/api/file-move' && method === 'POST') { const body = init && init.body ? safeJson(init.body) : {}; return this.movePath(body || {}); } if (pathname.startsWith('/api/folder/new/') && method === 'POST') { const path = decodeURIComponent(pathname.slice('/api/folder/new/'.length)); return this.createFolder(path); } if (pathname.startsWith('/api/folder/') && method === 'DELETE') { const path = decodeURIComponent(pathname.slice('/api/folder/'.length)); return this.deleteFolder(path); } if (pathname.startsWith('/api/file/')) { const path = decodeURIComponent(pathname.slice('/api/file/'.length)); if (method === 'GET') return this.readFile(path); if (method === 'POST') { const body = init && init.body ? safeJson(init.body) : {}; return this.writeFile(path, body || {}); } if (method === 'DELETE') return this.deleteFile(path); } return null; } } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ async function queryHandlePermission(handle) { if (!handle || typeof handle.queryPermission !== 'function') return false; try { const state = await handle.queryPermission({ mode: 'readwrite' }); return state === 'granted'; } catch (_e) { return false; } } async function verifyHandlePermission(handle, requestIfNeeded) { if (!handle || typeof handle.queryPermission !== 'function') return false; const opts = { mode: 'readwrite' }; let state = await handle.queryPermission(opts); if (state === 'granted') return true; if (state === 'denied') return false; if (!requestIfNeeded) return false; state = await handle.requestPermission(opts); return state === 'granted'; } function safeJson(body) { if (typeof body !== 'string') return null; try { return JSON.parse(body); } catch (_e) { return null; } } export function isLocalModeEnabled() { try { const params = new URLSearchParams(window.location.search); if (params.get('local') === '1') { window.localStorage.setItem('python-editor.local-mode', '1'); } if (params.get('local') === '0') { window.localStorage.removeItem('python-editor.local-mode'); } return window.localStorage.getItem('python-editor.local-mode') === '1'; } catch (_e) { return false; } } export function exitLocalMode() { try { window.localStorage.removeItem('python-editor.local-mode'); } catch (_e) { // ignore } } export function supportsFolderPicker() { return typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function'; }