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>
1041 lines
35 KiB
JavaScript
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';
|
|
}
|