Add local-mode workspace, ZIP import/export, and richer pin/ADC/serial sims

Boot:
- Editor now picks local vs server mode based on URL flag, sign-in
  state, and a stale local-mode flag. Signed-in users are no longer
  bounced to IndexedDB if they had previously clicked "Use locally".

Local mode:
- New LocalWorkspaceClient (src/static/local-workspace.js) with
  pluggable IndexedDB and File System Access backends. Picked folder
  handles persist across reloads with a Reconnect button when the
  permission lapses.
- Static-only host: scripts/serve_static_editor.py serves src/static/
  with COOP/COEP so SharedArrayBuffer-backed sims keep working.
- Bundled MicroPython stubs ship under src/static/bundled-lib/ for
  static hosting; FastAPI also exposes them at /api/public/lib-bundle.

Workspace import / export:
- Zero-dep ZIP encoder + reader (STORE + DEFLATE via
  DecompressionStream). Export/Import buttons in the workspace badge
  work in both local and server modes; imports are confined to code/.

Pin / ADC / Serial simulation:
- machine.py grows ADC, UART, expanded Pin, and PWM mocks, all driven
  by SharedArrayBuffer when cross-origin isolated and falling back to
  postMessage + [pin-out] stdout markers otherwise — pins, ADC slider,
  and serial input now keep working over plain HTTP / LAN-IP origins.
- NeoPixel pins are claimed via a [pin-claim] marker and dropped from
  the Pins panel so the data line doesn't flicker per write().
- New demos: adc_slider_demo.py, pin_demo.py, serial_demo.py.

Lib layout:
- Single source of truth at repo lib/; workspace/lib/ caching layer
  removed and the directory deleted. Filesystem service reads stubs
  directly from PROJECT_ROOT/lib.

UI:
- Home page slimmed to "Sign in" + "Use locally" with optional editor
  / manage-users links. Admin user/invite UI moved to /users.
- Workspace badge gains storage indicator, Folder…/Reconnect, Export,
  Import, and Exit controls.
- Mobile-friendly tweaks: safer-area padding, larger touch targets,
  iOS-zoom-proof serial input, file-tree highlight fix.

Tests:
- test_auth.py patches PROJECT_ROOT for the lib-shared test so the
  repo-root lib refactor stays green. test_api.py asserts the new
  "LED Editor" branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 06:16:02 +12:00
parent 9f28eabd2d
commit ca0ca6fe7e
26 changed files with 5080 additions and 793 deletions

View File

@@ -34,7 +34,109 @@ await micropip.install("jedi")
self.onmessage = async (event) => {
const { id, type, payload } = event.data || {};
try {
/* Fast-path: fire-and-forget messages used by the SAB-less fallback
for pin input + ADC slider values. We update the worker-local
Int32Array so Python's next read sees the new value, with no
Pyodide round-trip and no reply expected. */
if (type === 'pinIn') {
const view = self.__pin_in_view;
const pin = payload && Number(payload.pin);
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
try {
view[pin] = (payload.value | 0) ? 1 : 0;
} catch (_e) {
// ignore
}
}
return;
}
if (type === 'adcSet') {
const view = self.__adc_view;
const pin = payload && Number(payload.pin);
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
const value = Math.max(0, Math.min(65535, payload.value | 0));
try {
view[pin] = value;
} catch (_e) {
// ignore
}
}
return;
}
if (type === 'init') {
/* The main thread shares an Int32Array-backed SAB so Python ADC.read*()
can pick up live slider values without yielding. */
const adcSab = payload && payload.adcSab;
if (adcSab && typeof adcSab !== 'string') {
try {
self.__adc_view = new Int32Array(adcSab);
} catch (_e) {
self.__adc_view = null;
}
}
/* No SAB? Fall back to a worker-local Int32Array; the main thread
then delivers slider drags via `postMessage({type:'adcSet'})`. */
if (!self.__adc_view) {
try {
self.__adc_view = new Int32Array(64);
} catch (_e) {
self.__adc_view = null;
}
}
/* Pin output state: Int32Array[64] — Python writes packed
[ui_mode << 24 | value (low 24 bits)] entries, the editor UI reads
them every frame to drive the Pins panel indicators. */
const pinOutSab = payload && payload.pinOutSab;
if (pinOutSab && typeof pinOutSab !== 'string') {
try {
self.__pin_out_view = new Int32Array(pinOutSab);
} catch (_e) {
self.__pin_out_view = null;
}
}
if (!self.__pin_out_view) {
try {
self.__pin_out_view = new Int32Array(64);
} catch (_e) {
self.__pin_out_view = null;
}
}
/* Pin input state: Int32Array[64] — UI buttons write 0/1 per pin,
Python's `Pin.value()` for IN mode reads the matching slot. */
const pinInSab = payload && payload.pinInSab;
if (pinInSab && typeof pinInSab !== 'string') {
try {
self.__pin_in_view = new Int32Array(pinInSab);
} catch (_e) {
self.__pin_in_view = null;
}
}
if (!self.__pin_in_view) {
try {
self.__pin_in_view = new Int32Array(64);
} catch (_e) {
self.__pin_in_view = null;
}
}
/* Tell Python whether SAB was actually wired up so machine.py can
emit `[pin-out]` print markers as a fallback for the live UI. */
self.__sab_isolated = Boolean(pinOutSab && typeof pinOutSab !== 'string');
/* Serial-in ring buffer: first 8 bytes are [readIdx, writeIdx] (Int32),
followed by `capacity` bytes of payload data. */
const serialSab = payload && payload.serialSab;
if (serialSab && typeof serialSab !== 'string') {
try {
self.__serial_in_indices = new Int32Array(serialSab, 0, 2);
const capacity = serialSab.byteLength - 8;
self.__serial_in_capacity = capacity;
self.__serial_in_data = new Uint8Array(serialSab, 8, capacity);
} catch (_e) {
self.__serial_in_indices = null;
self.__serial_in_data = null;
self.__serial_in_capacity = 0;
}
}
await ensurePyodide();
self.postMessage({ id, type: 'init', ok: true });
return;