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
26 changes: 16 additions & 10 deletions CLAUDE.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ Until a Client ID is set, `GITHUB_CLIENT_ID` is empty: **Sign in with GitHub** s
| Action | What it does |
|---|---|
| Click **Commit** | Opens a message input under the button — **Enter** commits (blank message = timestamped default), **Escape** cancels. The extension fetches the fork's head, builds a commit from the editor's files, and pushes it. Button shows `✓ <short-sha> ↑` (committed and pushed), `no changes`, `setup needed` (extension not configured yet), or `error` (see the console). |
| Click **Pull** | The extension fetches the fork and applies its files into the editor. Button shows `↓ +N ~N -N` (added / changed / deleted), or `nothing to pull` when the fork has no commits on the configured branch yet (nothing is applied in that case). When anything changed, the page reloads so the editor picks up the new files. |
| Click **Pull** | The extension fetches the fork and applies its files into the editor. Button shows `↓ +N ~N -N` (added / changed / deleted), or `nothing to pull` when the fork has no commits on the configured branch yet (nothing is applied in that case). The editor updates in place without reloading the page, so the hub stays connected. (Only if the extension can't drive the editor does it fall back to reloading the page.) |

## Shared-code updates

When the mentor updates the upstream shared repository, each team pulls the changes into their own fork by pressing **Sync fork** on GitHub (on the fork's page), then clicking **Pull** in the editor to bring them into Pybricks.

## Known limitations

- **The page reloads after a Pull that changes files.** Pybricks wraps Dexie with `dexie-observable`, and the extension's raw IndexedDB writes bypass its hook system, so React doesn't see them until a reload.
- **The page can still reload in rare cases.** Pull, menu Save, New program and Update robot setup write through the Pybricks app itself, so the page doesn't reload and the hub stays connected over Bluetooth. If a future code.pybricks.com update stops that from working, the extension falls back to writing the files directly and reloading the page (which drops the Bluetooth connection) rather than risk losing work.
- **The credential is stored in `chrome.storage.local`.** Whether you sign in with GitHub (an OAuth token with the `public_repo` scope) or paste a PAT, it lands in `chrome.storage.local` — device-local, but readable by anyone who can use that Chrome profile. The OAuth token can be revoked any time at GitHub → *Settings → Applications*; a pasted PAT should be scoped to the single fork with Contents-only write, as in Setup.
- **A Commit made before the first Pull preserves unknown files rather than deleting them.** Since the extension has no snapshot of what the fork contained, it won't delete starter code it has never seen. This is by design; the preserved paths are logged to the console.
- **Block programming on code.pybricks.com requires a Pybricks licence.** This is upstream's pricing, not something this extension controls or can unlock. Opening a block file raises an **"Enable block coding"** dialog offering a licence code, a Patreon subscription, or a self-serve **7-day trial**; teachers can email `sales@pybricks.com` for a **free 30-day class trial**. Licences are per-user, so a team that builds in blocks needs coverage for the machines its students work on — worth budgeting for before a season starts. **Plain Python programs are unaffected and always free.** Pybricks Git itself needs no licence either way: it treats block files as opaque text, so Pull, Commit, and protected files work on an unlicensed machine. What a licence buys is the ability to *open and edit* those blocks in the editor — which the shared-robot-setup features assume, since students edit the spliced programs as blocks.
Expand Down
71 changes: 58 additions & 13 deletions src/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,14 +375,19 @@ function showErrorPanel(opName, err) {
document.body.appendChild(box);
}

// Kid-facing report of what Pull rescued. Rendered on the page load *after*
// the pull's reload, because that's when the rescued files are actually
// visible in the file list. Click, Escape, or the timeout dismisses it.
// Kid-facing report of what Pull rescued. A live Pull renders it at once
// (renderRescueNotice); a Pull that fell back to a reload persists it under
// pullRescued and it renders on the next load, when the rescued files are
// actually visible. Click, Escape, or the timeout dismisses it.
async function showRescueNotice() {
const rescued = await storageGet('pullRescued');
if (!rescued || !rescued.length) return;
await storageSet({ pullRescued: [] });
renderRescueNotice(rescued);
}

function renderRescueNotice(rescued) {
document.querySelector('[data-pybricks-git-rescue]')?.remove();
const box = document.createElement('div');
box.dataset.pybricksGitRescue = '1';
box.setAttribute('role', 'status');
Expand Down Expand Up @@ -478,18 +483,58 @@ async function pull(btn) {
console.warn('[pybricks-git] rescued local edits:', plan.rescued);
}

// The files apply-files is about to delete — their uuids must also
// leave Pybricks' open-tab history, or the reload tries to reopen them.
const goneUuids = deletedUuids(editor.metadata, plan.files.map((f) => f.path));
const summary = await pageRequest('apply-files', { files: plan.files });
console.log('[pybricks-git] applied:', summary);
// First choice: sync through Pybricks' own store (write-files-live with
// deleteUnlisted — the same full-sync semantics as apply-files), so the
// page never reloads and the hub's Bluetooth link survives. It closes
// the tabs of deleted files itself. Any doubt → the raw apply-files +
// reload below, exactly as before.
let live;
try {
live = await pageRequest('write-files-live', { files: plan.files, deleteUnlisted: true });
} catch (err) {
live = { live: false, reason: err.message };
}
let summary;
let goneUuids = [];
if (live.live) {
summary = live.summary;
if (live.tabsNotReopened || live.activeNotRestored) {
console.warn('[pybricks-git] Pull could not fully restore the editor tabs:', {
tabsNotReopened: live.tabsNotReopened,
activeNotRestored: live.activeNotRestored,
});
}
} else {
console.warn('[pybricks-git] live Pull unavailable, reloading instead:', live.reason);
// The files apply-files is about to delete — their uuids must also
// leave Pybricks' open-tab history, or the reload tries to reopen them.
goneUuids = deletedUuids(editor.metadata, plan.files.map((f) => f.path));
summary = await pageRequest('apply-files', { files: plan.files });
}
console.log('[pybricks-git] applied:', summary, live.live ? '(live)' : '(raw)');
btn.textContent = `↓ +${summary.added} ~${summary.changed} -${summary.deleted}`;
// Both keys are written only after apply-files resolves. The base must
// never claim agreement the editor doesn't hold: if the apply throws,
// the editor is still on the old files, and an advanced base would let
// the next Commit push them over whatever the repo now has. A stale
// pullRescued would likewise render a false notice on the next load.
// Written only once the editor holds the files (live: confirmed by
// read-back; raw: after apply-files resolves). The base must never
// claim agreement the editor doesn't hold: if the apply throws, the
// editor is still on the old files, and an advanced base would let the
// next Commit push them over whatever the repo now has.
await storageSet({ lastPullShas: result.shas });

if (live.live) {
// Nothing reloads, so everything a reload used to refresh is
// refreshed here: the rescue notice shows now, and the panel and
// file list pick up the new files and the new manifest.
if (plan.rescued.length) renderRescueNotice(plan.rescued);
await Promise.all([
menuPanel.refresh().catch((err) => console.warn('[pybricks-git] panel refresh failed:', err)),
fileListWatcher.refresh().catch((err) => console.warn('[pybricks-git] file-list refresh failed:', err)),
]);
setTimeout(() => (btn.textContent = original), 3000);
return;
}

// A stale pullRescued would render a false notice on the next load,
// so it too waits for apply-files.
if (plan.rescued.length) await storageSet({ pullRescued: plan.rescued });

// dexie-observable doesn't see raw IDB writes, so reload to refresh
Expand Down
22 changes: 20 additions & 2 deletions src/file-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,33 @@ function makeFileListWatcher(deps) {
let teamSetup = null;
let debounceTimer = null;

async function start() {
async function loadManifest() {
const manifest = await storageGet('lastPullManifest');
protectedPaths = new Set((manifest && manifest.protected) || []);
teamSetup = (manifest && manifest.teamSetup) || null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async function start() {
await loadManifest();
const observer = new MutationObserver(scheduleDecorate);
observer.observe(document.body, { childList: true, subtree: true });
scheduleDecorate();
}

// Re-reads the manifest after a Pull that didn't reload the page (a reload
// used to restart the watcher). Badges on files that are no longer
// protected come off; decorate() adds the new ones.
async function refresh() {
await loadManifest();
for (const badge of document.querySelectorAll('[data-pybricks-git-badge]')) {
const row = badge.closest('li[role="treeitem"]') || badge.parentNode;
const label = row && row.querySelector('span.bp5-tree-node-label');
const path = label ? label.textContent : null;
if (!path || !protectedPaths.has(path)) badge.remove();
}
scheduleDecorate();
}

function scheduleDecorate() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => void decorate().catch(() => {}), 250);
Expand Down Expand Up @@ -226,5 +244,5 @@ function makeFileListWatcher(deps) {
document.body.appendChild(menu);
}

return { start };
return { start, refresh };
}
Loading
Loading