Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ The line-1 comment carries the entire Blockly workspace state. The rest of the f

## The dexie-observable gotcha

Pybricks wraps Dexie with `dexie-observable`, which records mutations in a hidden `_changes` table via Dexie's hook system. Our raw-IndexedDB writes bypass those hooks, so the running React UI does not see our changes. After applying a Pull, `content.js` does `location.reload()` — that's the only currently-working refresh path.
Pybricks wraps Dexie with `dexie-observable`, which records mutations in a hidden `_changes` table via Dexie's hook system. Our raw-IndexedDB writes bypass those hooks, so the running React UI does not see our changes. After applying a Pull, `content.js` does `location.reload()`.

**The no-reload path: `write-files-live`.** A reload drops the hub's Bluetooth connection, so the menu panel's Save instead writes through Pybricks' own Redux store. `inject.js:findAppStore()` walks React's fiber tree from `#root` (`__reactContainer$…`) to the react-redux `<Provider store>` and only accepts a store whose state has `editor.openFileUuids` and `fileStorage.isInitialized === true`. `planLiveWrites()` then mirrors Pybricks' own Explorer import (`pybricks-code` `src/explorer/sagas.ts:importPythonFile`): a file open in an editor tab gets `{type: 'editor.action.replaceFile', uuid, value}`, which updates the open Monaco model in place with an undo stop and lets the app persist it; any other file gets `{type: 'fileStorage.action.writeFile', path, contents}`, a real Dexie write that `useLiveQuery` and dexie-observable both see. Files whose sha already matches get no action. `writeFilesLive()` then polls IndexedDB until every file's contents *and* sha256 match, and resolves `{live: false, reason}` when there's no store, when anything throws (hashing, IndexedDB, the app's reducers), or when nothing is confirmed within 5s. It never rejects. **Callers must fall back to `upsert-files` + reload on `live: false`**, so if an upstream change renames an action or moves the store, Save degrades to the old reload behaviour instead of losing data. Pybricks' editor and file-storage code is MIT-licensed on GitHub (`pybricks/pybricks-code`), so check the action shapes there. Hub downloads read imported modules (`menu_config.py` included) straight from IndexedDB (`pybricksMicropython/lib.ts:resolveModule`), so even the raw path was only unsafe for the open-tab and file-list cases. Pull, new-program and Update-robot-setup still use raw writes + reload.

If this becomes painful, the fix paths are (in order of effort): bundle Dexie into the extension and write through it; reverse-engineer `_changes` row format and write directly; or expose Pybricks' Dexie instance via a hook into the page's React tree. None are necessary for the current prototype.

Expand All @@ -89,7 +91,8 @@ The ISOLATED scripts reach IndexedDB by `window.postMessage`-ing `inject.js` (`p
| `list-databases` | — | `indexedDB.databases()` result | discovery/debug helper |
| `list-files` | — | `{metadata, contents}` | `contents` is `[{path, contents}]` with binary fields stripped |
| `apply-files` | `{files}` | `{added, changed, deleted, unchanged}` | full-sync: adds/updates listed paths **and DELETES any IDB path not in `files`**. Used by Pull. **Never reuse for single-file writes.** |
| `upsert-files` | `{files}` | `{added, changed, deleted, unchanged}` | partial write: updates/inserts only the listed paths, **never deletes** (`deleted` is always 0). Used by the menu panel's Save. |
| `upsert-files` | `{files}` | `{added, changed, deleted, unchanged}` | partial write: updates/inserts only the listed paths, **never deletes** (`deleted` is always 0). Used by new-program, Update-robot-setup, and as the menu Save's fallback. |
| `write-files-live` | `{files}` | `{live: true, dispatched}` or `{live: false, reason}` | writes through the app's Redux store so the running UI (and an open editor tab) sees it with **no reload**; confirmed by IDB read-back. Used by the menu panel's Save. See "The dexie-observable gotcha". |

`apply-files` and `upsert-files` are the same `writeFiles(files, deleteUnlisted)` with the delete pass toggled; both preserve each existing metadata row's `viewState`/`uuid` and only touch `sha256`/`contents`.

Expand Down Expand Up @@ -130,7 +133,7 @@ The hub's on-device menu is driven by a `menu_config.py` file (a `MENU_ITEMS` li

**This adds two top-level statements to the file**, which the starter repo's `check_project.py` does not yet allow — see "Starter-repo follow-ups" below.

- **`menu-panel.js` — `makeMenuPanel(deps)`.** A draggable floating panel listing the current slots (reorder by drag or ▲/▼, toggle `enabled`, remove, edit the hub display via a number/char/5×5-grid popover) and the addable programs. Position and open state persist under the **`menuPanel`** storage key (`{left, top, open}`); `content.js` reopens the panel after a reload when `open` was true. **Save always reloads.** Save regenerates the file and writes it via `upsert-files` (single-path, never deletes), then `location.reload()`s — because dexie-observable can't see our raw IDB write, and if `menu_config.py` happens to be open in Monaco the app's stale buffer would clobber our save on its next write; reloading discards that buffer. The panel resolves the config path and protected set from `lastPullManifest` (defaulting to `menu_config.py`); it intersects `protected` with the live `list-files` result and then **excludes** those protected files (and the config file itself) from the "Programs you can add" list.
- **`menu-panel.js` — `makeMenuPanel(deps)`.** A draggable floating panel listing the current slots (reorder by drag or ▲/▼, toggle `enabled`, remove, edit the hub display via a number/char/5×5-grid popover) and the addable programs. Position and open state persist under the **`menuPanel`** storage key (`{left, top, open}`); `content.js` reopens the panel after a reload when `open` was true. **Save does not reload** (a reload drops the hub's Bluetooth link). Save regenerates the file and writes it via `write-files-live`, which goes through the app's own store. If `menu_config.py` is open in an editor tab, its Monaco model is updated in place. On `live: true` the panel re-runs `loadState()` and shows `Saved ✓`. The slot controls stay live while a save is in flight, so every edit bumps an `editRevision` counter in `markDirty()`. Save re-checks the counter after every await, and again when the fallback's 800 ms reload timer fires. If it moved, the panel keeps the newer (still dirty) slots instead of refreshing over them or reloading them away, and asks for another Save. The fallback regenerates from the current slots right before its raw write. A `saving` flag keeps the re-rendered Save button disabled until the in-flight save settles, so two saves can't race. On `live: false` it falls back to the old path: `upsert-files` (single-path, never deletes), then `location.reload()`, because a raw write is invisible to the app and an open tab's stale buffer would clobber it on the next write. The panel resolves the config path and protected set from `lastPullManifest` (defaulting to `menu_config.py`); it intersects `protected` with the live `list-files` result and then **excludes** those protected files (and the config file itself) from the "Programs you can add" list.
- **`file-list.js` — `makeFileListWatcher(deps)`.** A `MutationObserver` on `document.body` (debounced 250ms) that finds the page's file rows and (a) adds a 🔒 badge to protected files and (b) attaches right-click / long-press "Add to menu" gestures that call back into `menuPanel.addSlot`. **The selectors are documented in `test/e2e/file-list-dom.md`** — primary path is the Blueprint `[role="tree"][aria-label="Files"]` / `li[role="treeitem"]` / `span.bp5-tree-node-label` structure, with a scoped exact-text fallback. **Gated to the mounted Explorer:** it bails when neither `div.pb-activities-tabview` nor the tree is present, because the Explorer unmounts when closed (the default and the post-Pull-reload state) and without the gate every settled editor keystroke would trigger a `list-files` round-trip and a text-walk that could badge editor chrome. The badge is inserted as a **sibling after** the label (never inside it) so the label's `textContent` stays a clean path for the next decorate. `protected` here also comes from `lastPullManifest`.

Consumers of `lastPullManifest.protected` (panel and watcher) **must intersect it with the live file list** before badging/hiding — a manifest can name paths the editor doesn't have.
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@ When the mentor updates the upstream shared repository, each team pulls the chan

In rough priority order:

1. Avoid needing a page refresh, which breaks bluetooth connection to the Prime hub, when the menu is updated.
2. **Open-tab cleanup on delete** — when Pull deletes a file, also clean up its entry in Pybricks' "open tabs" state so the page doesn't log a non-fatal error after reload.
1. **Open-tab cleanup on delete** — when Pull deletes a file, also clean up its entry in Pybricks' "open tabs" state so the page doesn't log a non-fatal error after reload.

## License

Expand Down
116 changes: 116 additions & 0 deletions src/inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ async function handle(op, payload) {
return await applyFiles(payload);
case 'upsert-files':
return await upsertFiles(payload);
case 'write-files-live':
return await writeFilesLive(payload);
default:
throw new Error(`unknown op: ${op}`);
}
Expand Down Expand Up @@ -166,6 +168,120 @@ async function writeFiles(files, deleteUnlisted) {
}
}

// --- Writing through the app (no reload) --------------------------------
//
// Raw IDB writes (above) are invisible to the running app, so callers used to
// follow them with a page reload — which drops the hub's Bluetooth link.
// writeFilesLive instead asks Pybricks' own Redux store to do the write, the
// way its Explorer "Import file" does: a file open in an editor tab gets
// `editor.action.replaceFile` (the open Monaco model is updated in place, with
// an undo stop, and the app persists it), any other file gets
// `fileStorage.action.writeFile` (a Dexie write, so the file list and every
// dexie-observable subscriber see it). Action shapes are from pybricks-code
// src/editor/actions.ts + src/fileStorage/actions.ts.
//
// Everything here is best effort and self-verifying: it resolves
// {live: true} only once IndexedDB holds exactly the requested contents, and
// {live: false, reason} otherwise — store not found, app not initialized, or
// no confirmation within timeoutMs. On {live: false} the caller falls back
// to upsert-files + reload, so an upstream UI change degrades to the old
// behaviour instead of losing a save.

const LIVE_WRITE_TIMEOUT_MS = 5000;

// The app's Redux store, found by walking React's fiber tree from the root
// container down to the react-redux <Provider store>. Only a store whose
// state has the shape we rely on counts. Null when anything is missing.
function findAppStore(rootEl = document.getElementById('root')) {
if (!rootEl) return null;
const key = Object.keys(rootEl).find((k) => k.startsWith('__reactContainer$'));
if (!key) return null;
const stack = [rootEl[key]];
// The Provider sits near the top of the tree; the cap only bounds a
// pathological walk if it ever moves or disappears.
for (let visited = 0; stack.length && visited < 5000; visited++) {
const fiber = stack.pop();
if (!fiber) continue;
const props = fiber.memoizedProps;
const store = props && typeof props === 'object' ? props.store : null;
if (store && typeof store.dispatch === 'function' && typeof store.getState === 'function') {
const st = store.getState();
if (
st && st.editor && Array.isArray(st.editor.openFileUuids) &&
st.fileStorage && st.fileStorage.isInitialized === true
) {
return store;
}
}
if (fiber.sibling) stack.push(fiber.sibling);
if (fiber.child) stack.push(fiber.child);
}
return null;
}

// Pure: which action writes each file. Files already holding the requested
// contents get none (no spurious undo stop in an open tab).
function planLiveWrites(files, metadata, openFileUuids) {
const metaByPath = new Map(metadata.map((m) => [m.path, m]));
const open = new Set(openFileUuids);
const actions = [];
for (const f of files) {
const existing = metaByPath.get(f.path);
if (existing && existing.sha256 === f.sha) continue;
if (existing && open.has(existing.uuid)) {
actions.push({ type: 'editor.action.replaceFile', uuid: existing.uuid, value: f.contents });
} else {
actions.push({ type: 'fileStorage.action.writeFile', path: f.path, contents: f.contents });
}
}
return actions;
}

async function writeFilesLive({ files, timeoutMs = LIVE_WRITE_TIMEOUT_MS, rootEl } = {}) {
const store = findAppStore(rootEl);
if (!store) return { live: false, reason: 'Pybricks app store not found' };
// Never reject: a throw from hashing, IDB, or the app's own reducers is
// just another reason to fall back.
try {
return await liveWriteAttempt(store, files, timeoutMs);
} catch (err) {
return { live: false, reason: `Pybricks could not save the file this way: ${err && err.message ? err.message : err}` };
}
}

async function liveWriteAttempt(store, files, timeoutMs) {
const wanted = await Promise.all(
files.map(async (f) => ({ path: f.path, contents: f.contents, sha: await sha256(f.contents) })),
);
const before = await readStores();
const actions = planLiveWrites(wanted, before.metadata, store.getState().editor.openFileUuids);
for (const action of actions) store.dispatch(action);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const deadline = Date.now() + timeoutMs;
for (;;) {
const now = await readStores();
const byPath = new Map(now.contents.map((c) => [c.path, c.contents]));
const shaByPath = new Map(now.metadata.map((m) => [m.path, m.sha256]));
const done = wanted.every(
(f) => byPath.get(f.path) === f.contents && shaByPath.get(f.path) === f.sha,
);
if (done) return { live: true, dispatched: actions.length };
if (Date.now() >= deadline) {
return { live: false, reason: 'Pybricks did not confirm the write in time' };
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}

async function readStores() {
const db = await openPybricksDb();
try {
return { metadata: await readAll(db, 'metadata'), contents: await readAll(db, '_contents') };
} finally {
db.close();
}
}

async function sha256(text) {
const buf = new TextEncoder().encode(text);
const hash = await crypto.subtle.digest('SHA-256', buf);
Expand Down
Loading
Loading