Files
python-editor/src/static/local-workspace.js
Jimmy 8c45097ec5 Support demos/ in the local-mode workspace client
Treat demos as a writable top-level folder alongside code/, seed it on
first open, and ship browser_fetch.py with bundled lib stubs.

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

1041 lines
35 KiB
JavaScript

/**
* 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
* `<picked>/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';
}