diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5f98f29c9..97a120fcc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -15,7 +15,7 @@ env:
# renovate: datasource=npm depName=npm
NPM_VERSION: "11"
# renovate: datasource=npm depName=@microbit-foundation/python-editor-v3-microbit registryUrl=https://npm.pkg.github.com
- THEME_VERSION: 0.4.0
+ THEME_VERSION: 0.0.0-home.page.114
# renovate: datasource=npm depName=@microbit-foundation/website-deploy-aws registryUrl=https://npm.pkg.github.com
DEPLOY_AWS_VERSION: "0.6.0"
# renovate: datasource=npm depName=@microbit-foundation/website-deploy-aws-config registryUrl=https://npm.pkg.github.com
@@ -74,7 +74,7 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: reports
- path: reports/
+ path: playwright-report/
retention-days: 3
- run: npm run deploy
if: github.repository_owner == 'microbit-foundation' && (env.STAGE == 'REVIEW' || success())
diff --git a/AGENTS.md b/AGENTS.md
index 040bd2da4..faba89ef1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,8 +5,10 @@
This app uses react-aria-components + Panda CSS via `@microbit/ui`.
Read `../ui/docs/hints.md` before styling/theming/UI work.
The private theme package is the sibling repo
-`../python-editor-v3-microbit` (consumed via a manual `node_modules`
-symlink locally — re-create it after `npm install`).
+`../python-editor-v3-microbit`. Link it with `npm run dev:link-theme`, which
+builds it and symlinks it into `node_modules`; re-run after `npm install`.
+Home page artwork (banner, help cards) lives there and is resolved per file by
+`theme-package/images/*` imports, falling back to `src/deployment/default`.
`@microbit/ui` is consumed as the **published package**, pinned in
`package.json`. To develop against a local `../ui` checkout instead — what you
diff --git a/docs/analytics-events.md b/docs/analytics-events.md
index 0e0eae1f1..857b98e3d 100644
--- a/docs/analytics-events.md
+++ b/docs/analytics-events.md
@@ -108,26 +108,80 @@ No `destination` param: the editor only downloads. ml-trainer's
### `project_import`
User brought files in. Fires once per drop / picker selection, before the
-files are parsed, so it counts attempts.
+files are parsed, so it counts attempts. A hex always becomes a new project.
+Other files join the open project from the editor and become a new project
+from the home and projects pages.
-| Param | Values |
-| -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
-| `source` | `drop` / `file_picker` (same values as ml-trainer) |
-| `format` | `hex` (replaces the project) / `py` (Python file added to the project) / `other` (any other single file) / `multiple` (more than one file) |
-
-### `project_reset`
-
-User chose Reset project, replacing everything with the starter program. Fires
-when the menu action is chosen, before the confirm dialog. No params.
+| Param | Values |
+| --------- | ------------------------------------------------------------------------------------------------------------ |
+| `source` | `drop` / `file_picker` (same values as ml-trainer) |
+| `format` | `hex` / `py` (a single Python file) / `other` (any other single file) / `multiple` (more than one file) |
+| `surface` | `editor` (the Files tab or a drop on the editor) / `home` (the projects row's Import button or a drop there) |
### `project_rename`
User set the project name, from the header or the name-your-project prompt on
-save. No params.
+save, with no params; or from a project card or the projects toolbar, with
+`surface`.
+
+| Param | Values |
+| --------- | --------------------------------------------------------- |
+| `surface` | `home` / `projects` (absent when renamed from the editor) |
+
+## Project management events
+
+The home and projects pages, aligned with ml-trainer's events of the same
+names so one set of custom definitions serves both.
+
+### `project_create`
+
+User named and created a new project from the home page.
+
+| Param | Values |
+| --------- | ------ |
+| `surface` | `home` |
+
+### `project_open`
+
+User opened a project from a card or its menu.
+
+| Param | Values |
+| --------- | ------------------- |
+| `surface` | `home` / `projects` |
+
+### `project_duplicate`
+
+| Param | Values |
+| --------- | ------------------- |
+| `surface` | `home` / `projects` |
+
+### `project_delete`
+
+Fires once for a single delete and once for a multi-select delete, after the
+user confirms.
+
+| Param | Values |
+| --------- | --------------------------------------------------------- |
+| `count` | int (1 for a card's menu; the selection's size otherwise) |
+| `surface` | `home` / `projects` |
+
+### `project_search`
+
+Projects page only. Fires once per intentional search, 400ms after the last
+keystroke, not per keypress. No params.
+
+### `project_sort`
+
+Projects page only. Fires when the user changes the sort field or direction.
+
+| Param | Values |
+| ----------- | ---------------------------------------------- |
+| `field` | `name` / `timestamp` (last modified or opened) |
+| `direction` | `asc` / `desc` |
### `idea_open`
-User opened an idea into the editor.
+User opened an idea from the documentation as a new project.
| Param | Values |
| ----- | -------------------------- |
@@ -234,6 +288,7 @@ reading old dashboards.
| Old name | Status |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| `project_reset` | Removed with the Reset project action (September 2026); replacing a program means importing from the home page. |
| `boot` → `WebUSB-available` | Dropped. Replaced by the `webusb_available` user property. `session_start` auto-fires. |
| `connect` | Replaced by `device_step` (`task: connect`) and `device_success` / `device_failure` / `device_exit`. |
| `disconnect` | Renamed `device_disconnect`; widened with `reason` to also capture unexpected drops. |
diff --git a/docs/home-page.md b/docs/home-page.md
new file mode 100644
index 000000000..6e7a25066
--- /dev/null
+++ b/docs/home-page.md
@@ -0,0 +1,118 @@
+# Python home page / multiple-projects support
+
+Demo: https://review-python-editor-v3.microbit.org/home-page/
+
+Older prototype, for comparison on one open question only (the way home from the editor, see below): https://review-python-editor-v3.microbit.org/multiple-projects-prototype/
+
+## 1. What this is
+
+The Python Editor gains a home page and a projects page, and keeps projects in an IndexedDB database in the browser, so a user can have more than one and come back to them. The editor moves to `/project`. The pages are built from shared components in `@microbit/ui-patterns`, lifted from ml-trainer, so the two apps present projects the same way.
+
+Launch is coordinated with content, support articles and video, so the work sits on the `home-page` integration branch and reaches `main` at launch or for a TBC beta period. The react-router routing foundation and the shared error page shipped earlier.
+
+## 2. Where the work is
+
+Everything described in this document is on `home-page`. ui library work has been shipped but we're holding off on uploading translations to let strings setttle.
+
+Still outstanding before launch:
+
+- The private theme package's `projects-pages` branch (home page images, the `AppLogo` and `OrgLogo` header components, the footer's `copyrightHolder`). `build.yml` pins a temporary branch build, `0.0.0-projects.pages.111`. Merge, publish a release and pin it.
+- `shared-project-components` (ml-trainer) consumes the shared components. Bump its pins to the releases before merging.
+- At launch merge `home-page` to `main`. The database name includes the base path, so beta starts empty.
+
+## 3. Reviewing and running it
+
+Still to try:
+
+- **Iframe controller mode**, which the e2e suite covers only with a synthetic host page. Check the real embeds: `python-editor-embed`'s Storybook, and micro:bit classroom against the `home-page` review build. Look at load, edits reaching the host, `importproject`, a dropped hex or Python file, the replace confirmation on an edited project, the before-unload prompt, and that no home or projects page is reachable.
+- **No IndexedDB outside an iframe.** The session-storage fallback exists for this cohort, but it is not known whether the cohort is real. Test the browsers and modes that plausibly block IndexedDB (Firefox and Safari private windows, Chrome with site data blocked, a school-managed Chromebook profile if one is to hand, Safari's storage settings) and record what actually happens in each. If none blocks it in practice, the fallback can be simplified or dropped; if some do, that is the case the fallback has to be good at.
+
+## 4. Design notes
+
+### Storage
+
+- Write-through via the existing `FSStorage` seam. `FileSystem` keeps its in-memory working copy; the persistent secondary is `IndexedDBFSStorage`, scoped to a project id, in place of session storage. Opening a project is `FileSystem.switchStorage`. There is one record of a project's content and no autosave loop.
+- Schema: `projects` (id, name, timestamp) and `files` keyed by `[projectId, name]`, storing `Uint8Array`. Stores are asserted on open; a version mismatch shows a clear-and-reload page on non-public stages, which share one database. Production and beta databases are separate by base path.
+- Writes are coalesced per file with a short delay and flushed on `pagehide` and `visibilitychange`, stamped with the edit time so "last modified" is the edit. A failed flush drops that batch, keeps the in-memory copy, logs once per session and shows ml-trainer's persistent toast: "Browser storage full" for a quota error, "Failed to save your project to browser storage" otherwise. Page actions that hit the quota show the same toast.
+- Cross-tab: a `BroadcastChannel` carries `changed` and `deleted` project ids. Tabs refresh their lists; a tab with a changed project open reloads it; one with a deleted project open lets go of it. Timestamp bumps (e.g. from open) are not broadcast.
+- No IndexedDB (private browsing, blocked storage): one implicit project in session storage as today, no project management, pages redirect to the editor. Iframe controller mode is unchanged: the host owns the project.
+- Migration: on first load, session-storage files with no current project id become a new project with the stored name, and the editor opens on them. A `#project:` link takes precedence.
+- The dirty flag is not stored in the database. The before-unload prompt applies only without the database, where closing the tab still loses work.
+- Not done, deliberately: `navigator.storage.persist()` (Firefox prompts, Safari ignores it). The home page's info tooltip says projects are stored in this browser.
+
+### Routing
+
+- `react-router` 7, `createBrowserRouter`, one root layout route with `errorElement`, `urls.ts` builders as route paths, as ml-trainer. No project id in the URL; the tab's project is in `sessionStorage`.
+- Editor at `/project` with tabs at `/project/:tab/:slug`; the old `/:tab/:slug` paths redirect; unknown paths are the shared not-found page. Iframe mode has a single-route router with the editor at the root.
+- The editor route's loader chooses the project: the tab's, else migrated session storage, else the most recent, else a new one with the starter program. So `/project` in a fresh tab goes straight to code, which ml-trainer does not do; the editor is low friction today and some users bookmark it.
+- The editor's project is chosen when its route loads, not at boot, so the pages render without one.
+
+### Pages and sharing
+
+- Shared in `@microbit/ui-patterns`: `ProjectCard` and its actions, `ProjectsToolbar`, `SearchInput`, `SortInput`, `NameProjectDialog`, the search, sort and selection hooks, and the `useProjectActions` flows. Kept per app: page composition, banner, resource cards, header, storage, import, the editor. No shared `ProjectsPage` body; revisit if paging arrives.
+- Pages live in `src/pages/`; the `Projects` session in `src/project/projects.ts` owns storage switching and the cross-tab channel. Components never touch storage. Route loaders refresh the list so there is no empty-then-populated flash.
+- Cards show the Python logo in brand colour, and no file list, since it would almost always say `main.py`. File names are still loaded for search.
+- Header: organisation logo, divider, product wordmark, from `AppLogo` and `OrgLogo` in `BrandConfig`, the same `LogoProps` shape as ml-trainer. The OSS build has a text wordmark and no organisation logo; the SVG wordmark stays private because it is set in the brand typeface. The header buttons are pinned to 48px with a 24px icon because this app's dense preset would shrink them (see Open). Page header icon buttons use the `plain` variant, no hover state, as the family does; the editor's black chrome keeps `sidebar`.
+- The projects page's back button is the white `toolbar` pill with ml-trainer's `BackArrow`, copied not promoted.
+- The beta notice is a band under the page header with ml-trainer's copy and a Feedback button; the editor keeps its short sidebar notice, minus the "More" button. English only, since it shows on non-public stages only.
+- Images: `theme-package/images/*` resolves per file, the branded package winning and `src/deployment/default/images/` standing in with ml-trainer's minimal grey placeholders. Project idea card images are the matching MICI pages' images (updated for the recent changes). `ResourceCard` fills its slide (`w="100%"`), which ml-trainer should copy so the width lives in one place.
+
+### Import
+
+- A hex is always a whole program. With the database it becomes a new project, named after the file; without it, it replaces the implicit project, asking first if there are unsaved edits. Ideas and `#project:` links behave the same way.
+- From the editor, other files join the open project under their own names. Files that would overwrite existing ones ask first, in every mode.
+- From the home and projects pages, everything becomes a new project that opens in the editor: a single script becomes `main.py` and names the project; otherwise names are kept and the starter `main.py` added if missing.
+- The editor's drop target wraps the editor routes only. The pages have their own. This fixed two bugs found on 13 September: a drop on the projects page in a fresh tab waited silently on the storage promise and landed in whatever project opened next, and a drop on the home page left the app-level overlay stuck over the editor.
+- Gone: Reset project, the Open button in the action bar, the choose-main-script dialog. The Project tab is Files, with Add files.
+
+What each input does, by surface. "Editor" is the Files tab's Add files button or a drop on the editor. The pages only exist with the projects database; without it, and in iframe mode, their routes redirect to the editor.
+
+| Input | Iframe, editor | No IndexedDB, editor | Database, editor | Database, home or projects page |
+| ---------------------------- | ------------------------------------------ | ---------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
+| One hex | Replaces the project; asks first if edited | Same | New project named after the file; the editor switches to it | New project; the editor opens on it |
+| Hex with other files | Error toast | Same | Same | Same |
+| `.mpy` | Error toast | Same | Same | Same |
+| One `.py` script | Added under its own name | Same | Same | New project named after the script, which becomes `main.py` |
+| `main.py` | Replaces `main.py`; asks first | Same | Same | New untitled project with that `main.py` |
+| Several files, or a module | Added; asks before overwriting any | Same | Same | New untitled project; starter `main.py` added if none |
+| Idea from the Ideas tab | Replaces the project; asks first if edited | Same | New project named after the idea | n/a |
+| `#project:` link at boot | Ignored; the host owns the project | Replaces the project at boot | New project | n/a |
+| Host `importproject` message | Replaces the project | n/a | n/a | n/a |
+
+Against `main`: the replace confirmation used to gate a hex, an idea, reset and `#project:` links when the project was dirty; with the database those make a new project instead, and without it the confirmation remains. The choose-main-script dialog used to default a lone script to `main.py` and let the user pick; that default is the open question in section 6. Same-name replacement used to be shown in that dialog before confirming; it now has its own confirmation.
+
+## 5. Copy for review
+
+All new pages strings are new copy. Changed or notable:
+
+- `homepage-banner-heading` "Python for the BBC micro:bit"; `homepage-banner-subtitle` "Write a program, try it in the simulator, then send it to your micro:bit." microbit.org's vocabulary, describing the flow to a student.
+- `project-tab` "Project" → "Files"; `add-files-action` "Add files…"; `add-files-hover` "Add Python or other files to your project. A hex file opens as a new project."; `import-file-action` "Import" (ml-trainer's id and text).
+- `load-error-mixed` → "A hex file can only be imported on its own."
+- New: `confirm-replace-files-title` "Replace existing files?" and `confirm-replace-files-body`.
+- Restored from `main` with translations: the confirm-replace strings. Copied from ml-trainer so Crowdin dedupes: `storage-error-*`, `project-storage-tooltip`.
+- Never change shipped wording inside a refactor. Strings moved into ui-patterns keep their translations only if the ui-patterns Crowdin sync runs before the apps remove theirs; the sync is manual.
+
+## 6. Open decisions and discussion
+
+- **Home page banner artwork.** The background is ml-trainer's, brought across as a stand-in. It was drawn for CreateAI and does not suit this editor. A replacement drops in as `theme-package/images/banner-background.svg`.
+- **A lone dropped script no longer becomes `main.py`.** The old dialog's default made it so; now it is added under its own name and only a file called `main.py` replaces main. Renaming is the workaround. Hex downloads have been encouraged over script downloads for some time, which may make this moot, but the case that matters is the editor embedded in micro:bit classroom, where there is no home page to import from. Options: treat a single script dropped on the editor as "replace main.py" with a confirmation, or a lighter dialog.
+- **Where the welcome dialog appears.** Its state lives in the editor's sidebar, so a user landing on the home page sees nothing until they first open a project. Inherited, not chosen. Keep it in the editor, or lift it to the root layout. A re-recorded video's script probably decides this: a video that opens on the home page wants the dialog there.
+- **"Learn more" on the banner** links to the user guide for now. Its target is undecided and may be a landing page, which means recasting the "new micro:bit Python Editor" announcement page.
+- **Home page content.** The rows carry the prototype's project ideas, lessons and help cards as a first pass. The real list is to come, along with a feedback pass on cards, banner and copy once there is a shareable build.
+- **The way home from the editor** is the sidebar logo, which links to microbit.org without the database or in iframe mode. The collapsed sidebar has no room for a separate home button; the prototype had one in place of the logos, which is the comparison worth looking at. Revisit with any header change.
+- **Dense preset versus the shared pages.** This app stacks `@microbit/ui`'s dense preset; ml-trainer does not. The pages are now near-identical UI in both apps, so the difference is stark: the same `lg` icon button is 48px there and 42px here. The header pins its sizes as a visible hack. Options: accept the difference; pin more sizes as they come up; drop dense for this app (the editor was designed dense); or un-dense the pages by overriding token variables on the page root, which nothing does yet. The shared components are token-based, so the choice applies to them too.
+- **Promotions to the ui packages**, when the shared header work happens: `BackArrow` (now byte-identical in both apps, the family's bar), the header pattern (logo, divider, wordmark, back button), the beta notice. Also the family-wide absence of hover and active states on icon buttons in the brand bar, which is a gap to fix in `@microbit/ui` rather than here.
+- **ml-trainer follow-ups:** `ResourceCard` width as above; the `shared-error-pages` branches (ml-trainer, classroom, data-microbit-org) consume ui-patterns 0.6.1 and await review outside this stream.
+
+## 7. Decisions taken, subject to review/discussion
+
+- Iframe controller mode unchanged. Editor at `/project`; revisit if server-side projects arrive. New tab at `/` lands on home; `/project` goes straight to code.
+- Database name namespaced by base path; review stages share one. `Uint8Array` in the files store.
+- No IndexedDB: as the iframe path, with session storage kept as the fallback.
+- Before-unload prompt only without the database. Dirty flag not stored.
+- Without the database, a hex or idea asks before replacing an edited project. Revised 13 September: the first version dropped this, reasoning that the before-unload prompt protected the cohort, but that only guards closing the tab, and in classroom a student's dropped hex would silently replace the teacher's starter.
+- Adding files that overwrite existing ones asks first, in every mode.
+- Shared components in `ui-patterns`; no `ui-carousel` dependency there; no shared `ProjectsPage` body.
+- Integration branch `home-page`, merged from `main`, PRs into it reviewed as normal.
+- The SVG wordmark stays in the private theme package. OSS placeholders are ml-trainer's minimal ones.
+- Beta notice and header not shared yet; the "More" button is gone.
diff --git a/i18n.config.mjs b/i18n.config.mjs
index 9e514f06a..ebd7ebabb 100644
--- a/i18n.config.mjs
+++ b/i18n.config.mjs
@@ -34,7 +34,11 @@ export default defineConfig({
// in the format it was created in.
crowdinFormat: "chrome",
out: "src/messages/ui.{lang}.json",
- packages: ["@microbit/ui", "@microbit/ui-patterns"],
+ packages: [
+ "@microbit/ui",
+ "@microbit/ui-patterns",
+ "@microbit/ui-carousel",
+ ],
},
],
});
diff --git a/lang/ui.ca.json b/lang/ui.ca.json
index 35d6d4813..be9646282 100644
--- a/lang/ui.ca.json
+++ b/lang/ui.ca.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "Cancel·lar"
},
- "change-files": {
- "defaultMessage": "Canviar fitxers?"
- },
"choose-main-add-file": {
"defaultMessage": "Afegeix el fitxer {name}"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "Confirma la supressió"
},
- "confirm-replace-body": {
- "defaultMessage": "Substitueix tots els fitxers amb els de l'hexadecimal?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "Vols substituir tots els fitxers pel programa d'inici per defecte?"
- },
- "confirm-replace-title": {
- "defaultMessage": "Confirma la substitució del projecte"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "Vols substituir tots els fitxers per {ideaName}?"
- },
"confirm-save-action": {
"defaultMessage": "Confirma i desa"
},
- "confirm-save-hint": {
- "defaultMessage": "Cancel·la i després utilitza Desa per conservar una còpia del teu projecte."
- },
"connect-action": {
"defaultMessage": "Connecta"
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "Obert"
},
- "open-file-action": {
- "defaultMessage": "Obert"
- },
"open-file-dropped": {
"defaultMessage": "Obre el fitxer quan es deixa anar"
},
- "open-hover": {
- "defaultMessage": "Obre un fitxer hexadecimal o Python o afegeix altres fitxers"
- },
- "options": {
- "defaultMessage": "Opcions"
- },
"parameter-help": {
"defaultMessage": "Ajuda dels paràmetres"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "El nom del projecte no pot estar buit"
},
- "project-tab": {
- "defaultMessage": "Projecte"
- },
- "project-tab-description": {
- "defaultMessage": "Visualitza, crea, afegeix i edita els fitxers del teu projecte"
- },
"python-powered": {
"defaultMessage": "Fet amb Python"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "Referència"
},
- "replace-action-label": {
- "defaultMessage": "Substitueix"
- },
- "reset-project-action": {
- "defaultMessage": "Restableix el projecte"
- },
- "reset-project-feedback": {
- "defaultMessage": "Restableix el projecte al programa d'inici predeterminat"
- },
- "reset-project-hover": {
- "defaultMessage": "Restableix el projecte al programa inicial predeterminat, descartant el teu treball"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {Sense resultats} one {# resultat} other {# resultats}}"
},
diff --git a/lang/ui.de.json b/lang/ui.de.json
index 036828e08..20fa82cbd 100644
--- a/lang/ui.de.json
+++ b/lang/ui.de.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "Abbrechen"
},
- "change-files": {
- "defaultMessage": "Dateien ändern?"
- },
"choose-main-add-file": {
"defaultMessage": "Datei {name} hinzufügen?"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "Löschen bestätigen"
},
- "confirm-replace-body": {
- "defaultMessage": "Alle Dateien durch die in der HEX-Datei ersetzen?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "Alle Dateien durch den Standard-Startcode ersetzen?"
- },
- "confirm-replace-title": {
- "defaultMessage": "Zurücksetzen bestätigen"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "Alle Dateien durch {ideaName} ersetzen?"
- },
"confirm-save-action": {
"defaultMessage": "Bestätigen und speichern"
},
- "confirm-save-hint": {
- "defaultMessage": "Abbrechen und dann Speichern verwenden, um eine Kopie deines Projekts zu behalten."
- },
"connect-action": {
"defaultMessage": "Verbinden"
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "Öffnen"
},
- "open-file-action": {
- "defaultMessage": "Öffnen …"
- },
"open-file-dropped": {
"defaultMessage": "Datei nach Ablegen öffnen"
},
- "open-hover": {
- "defaultMessage": "Eine HEX- oder Python-Datei öffnen oder andere Dateien hinzufügen."
- },
- "options": {
- "defaultMessage": "Optionen"
- },
"parameter-help": {
"defaultMessage": "Parameter-Hilfe"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "Der Projektname darf nicht leer sein"
},
- "project-tab": {
- "defaultMessage": "Projekt"
- },
- "project-tab-description": {
- "defaultMessage": "Anzeigen, Erstellen, Hinzufügen und Bearbeiten der Dateien in deinem Projekt"
- },
"python-powered": {
"defaultMessage": "Python betrieben"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "Referenz"
},
- "replace-action-label": {
- "defaultMessage": "Ersetzen"
- },
- "reset-project-action": {
- "defaultMessage": "Projekt zurücksetzen"
- },
- "reset-project-feedback": {
- "defaultMessage": "Projekt auf den Standard-Startcode zurückgesetzt"
- },
- "reset-project-hover": {
- "defaultMessage": "Setzt das Projekt auf den Standard-Startcode zurück und verwirft deine Arbeit"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {Keine Ergebnisse} one {# Ergebnis} other {# Ergebnisse}}"
},
diff --git a/lang/ui.en.json b/lang/ui.en.json
index 0b1b8a0cb..3448ce8c1 100644
--- a/lang/ui.en.json
+++ b/lang/ui.en.json
@@ -27,6 +27,18 @@
"defaultMessage": "Accessibility",
"description": "Menu item for link to accessibility site"
},
+ "accessibility-resource-title": {
+ "defaultMessage": "Accessibility",
+ "description": "Title of the help card linking to the accessibility statement"
+ },
+ "add-files-action": {
+ "defaultMessage": "Add files…",
+ "description": "Button in the Files tab to add files to the project"
+ },
+ "add-files-hover": {
+ "defaultMessage": "Add Python or other files to your project. A hex file opens as a new project.",
+ "description": "Hover text for the add files button"
+ },
"added-change": {
"defaultMessage": "Added file {changeName}",
"description": "Change made to file"
@@ -71,10 +83,6 @@
"defaultMessage": "Cancel",
"description": "Cancel button text"
},
- "change-files": {
- "defaultMessage": "Change files?",
- "description": "Header for dialog of confirmation of files changed"
- },
"choose-main-add-file": {
"defaultMessage": "Add file {name}",
"description": "Shown in load dialog to confirm actions"
@@ -135,9 +143,13 @@
"defaultMessage": "Replace all files with those in the hex?",
"description": "Confirmation message body for replacing project dialog"
},
- "confirm-replace-reset": {
- "defaultMessage": "Replace all files with the default starter code?",
- "description": "Confirmation message for the reset action"
+ "confirm-replace-files-body": {
+ "defaultMessage": "{count, plural, one {{names} is already in this project and will be replaced.} other {These files are already in this project and will be replaced: {names}}}",
+ "description": "Body of the confirmation shown when added files would overwrite files already in the project. names is a comma-separated list of file names."
+ },
+ "confirm-replace-files-title": {
+ "defaultMessage": "Replace existing files?",
+ "description": "Title of the confirmation shown when added files would overwrite files already in the project"
},
"confirm-replace-title": {
"defaultMessage": "Confirm replace project",
@@ -227,6 +239,10 @@
"defaultMessage": "Create file",
"description": "Button text for action that creates a new blank Python file in the current project"
},
+ "create-project-action": {
+ "defaultMessage": "Create",
+ "description": "Confirm button of the dialog naming a new project"
+ },
"create-python": {
"defaultMessage": "Create a new Python file in this project",
"description": "Hover and dialog title when creating a new Python file"
@@ -315,6 +331,14 @@
"defaultMessage": "The name cannot contain spaces",
"description": "Warning text for new Python file name with whitespace"
},
+ "files-tab": {
+ "defaultMessage": "Files",
+ "description": "Files tab button text. Tab shows the files in the project."
+ },
+ "files-tab-description": {
+ "defaultMessage": "Add Python modules to support micro:bit accessories or organise larger projects",
+ "description": "Files tab description text. Tab shows the files in the project."
+ },
"firmware-update-link": {
"defaultMessage": "You must update your firmware before you can connect to this micro:bit.",
"description": "Text in the firmware update dialog"
@@ -327,6 +351,10 @@
"defaultMessage": "Firmware update required",
"description": "Title for the firmware update dialog"
},
+ "first-lessons-with-python-resource-title": {
+ "defaultMessage": "First lessons with Python and the micro:bit",
+ "description": "Title of the teacher resource card for the first lessons unit"
+ },
"flash-action": {
"defaultMessage": "Flash",
"description": "Text for flash button"
@@ -359,6 +387,10 @@
"defaultMessage": "Help",
"description": "Help menu label"
},
+ "help-resources-row-title": {
+ "defaultMessage": "Help",
+ "description": "Heading of the home page row of help cards"
+ },
"help-support": {
"defaultMessage": "Help & support",
"description": "Menu item for link to support site"
@@ -379,6 +411,18 @@
"defaultMessage": "Simple",
"description": "Highlight code structure option"
},
+ "home-action": {
+ "defaultMessage": "Home",
+ "description": "Link on the editor logo and button on the projects page returning to the home page"
+ },
+ "homepage-banner-heading": {
+ "defaultMessage": "Python for the BBC micro:bit",
+ "description": "Heading of the banner at the top of the home page"
+ },
+ "homepage-banner-subtitle": {
+ "defaultMessage": "Write a program, try it in the simulator, then send it to your micro:bit.",
+ "description": "Text under the home page banner heading"
+ },
"ideas-tab": {
"defaultMessage": "Ideas",
"description": "Ideas tab button text. Tab shows programs to give students ideas."
@@ -387,6 +431,10 @@
"defaultMessage": "Try out these projects, modify them and get inspired",
"description": "Ideas tab description text. Tab shows programs to give students ideas."
},
+ "import-file-action": {
+ "defaultMessage": "Import",
+ "description": "Import file action"
+ },
"insert-code-action": {
"defaultMessage": "Insert code"
},
@@ -394,6 +442,10 @@
"defaultMessage": "Language",
"description": "Language option text"
},
+ "learn-more-action": {
+ "defaultMessage": "Learn more",
+ "description": "Home page banner button linking to microbit.org"
+ },
"less-action": {
"defaultMessage": "Less",
"description": "Less button text (showing less content)"
@@ -407,7 +459,7 @@
"description": "Load error message"
},
"load-error-mixed": {
- "defaultMessage": "A hex file can only be loaded on its own. It replaces all files in the project.",
+ "defaultMessage": "A hex file can only be imported on its own.",
"description": "Load error message"
},
"load-error-mpy": {
@@ -470,6 +522,10 @@
"defaultMessage": "Warning: Only main.py downloaded",
"description": "Title of dialog shown when multiple files are available for download but only the main file downloaded."
},
+ "my-projects-row-title": {
+ "defaultMessage": "My projects",
+ "description": "Heading of the home page row of the user's projects, and of the projects page"
+ },
"name-project": {
"defaultMessage": "Name your project",
"description": "Name your project header"
@@ -486,10 +542,18 @@
"defaultMessage": "We'll add the .py extension for you.",
"description": "Hint shown in the new Python file dialog"
},
+ "new-project-action": {
+ "defaultMessage": "New project",
+ "description": "Home page card creating a new project"
+ },
"next-action": {
"defaultMessage": "Next",
"description": "Next button text"
},
+ "no-projects": {
+ "defaultMessage": "No projects to display",
+ "description": "Shown on the projects page when there are no projects, or none match the search"
+ },
"not-found-checklist-one": {
"defaultMessage": "Is your micro:bit plugged in ? Did you follow these steps?",
"description": "Checklist text in the no micro:bit found dialog"
@@ -522,22 +586,10 @@
"defaultMessage": "Open",
"description": "Open button text"
},
- "open-file-action": {
- "defaultMessage": "Open…",
- "description": "Open file button text"
- },
"open-file-dropped": {
"defaultMessage": "Open file when dropped",
"description": "Aria label for file drop target"
},
- "open-hover": {
- "defaultMessage": "Open a hex or Python file or add other files",
- "description": "Hover text over load button"
- },
- "options": {
- "defaultMessage": "Options",
- "description": "Label for an options menu"
- },
"parameter-help": {
"defaultMessage": "Parameter help",
"description": "Setting label to control whether pop-up documentation for function/method parameters is automatically shown."
@@ -586,6 +638,34 @@
"defaultMessage": "Project header",
"description": "Aria label for the project header area"
},
+ "project-idea-animated-animals-title": {
+ "defaultMessage": "Animated animals",
+ "description": "Title of a project idea card; matches the microbit.org project name"
+ },
+ "project-idea-beating-heart-title": {
+ "defaultMessage": "Beating heart",
+ "description": "Title of a project idea card; matches the microbit.org project name"
+ },
+ "project-idea-emotion-badge-title": {
+ "defaultMessage": "Emotion badge",
+ "description": "Title of a project idea card; matches the microbit.org project name"
+ },
+ "project-idea-flashing-emotions-title": {
+ "defaultMessage": "Flashing emotions",
+ "description": "Title of a project idea card; matches the microbit.org project name"
+ },
+ "project-idea-get-silly-title": {
+ "defaultMessage": "Get silly",
+ "description": "Title of a project idea card; matches the microbit.org project name"
+ },
+ "project-idea-heart-title": {
+ "defaultMessage": "Heart",
+ "description": "Title of a project idea card; matches the microbit.org project name"
+ },
+ "project-ideas-row-title": {
+ "defaultMessage": "Project ideas",
+ "description": "Heading of the home page row of project idea cards"
+ },
"project-name": {
"defaultMessage": "Project name",
"description": "Text used to indicate the project name"
@@ -594,13 +674,13 @@
"defaultMessage": "The project name cannot be empty",
"description": "Validation message for project name"
},
- "project-tab": {
- "defaultMessage": "Project",
- "description": "Project tab button text"
+ "project-storage-tooltip": {
+ "defaultMessage": "Your data is saved in this browser on this device. Clearing your browser's cookies or site data will delete it. It may also be automatically removed by your browser if your device is low on storage.",
+ "description": "Tooltip on the home page projects row heading explaining where projects are stored"
},
- "project-tab-description": {
- "defaultMessage": "View, create, add and edit the files in your project",
- "description": "Project tab description"
+ "projects-page-title": {
+ "defaultMessage": "Projects",
+ "description": "Title of the page listing all projects"
},
"python-powered": {
"defaultMessage": "Python powered",
@@ -634,18 +714,6 @@
"defaultMessage": "Replace",
"description": "Action label for replacing project dialog"
},
- "reset-project-action": {
- "defaultMessage": "Reset project",
- "description": "Action to reset the project to its default state"
- },
- "reset-project-feedback": {
- "defaultMessage": "Project reset to the default starter code",
- "description": "Confirmation message after resetting the project"
- },
- "reset-project-hover": {
- "defaultMessage": "Resets the project to the default starter code, discarding your work",
- "description": "Reset action hover text"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {No results} one {# result} other {# results}}",
"description": "Number of results from a search. Uses ICU syntax for pluralisation: https://formatjs.io/docs/core-concepts/icu-syntax/#plural-format."
@@ -1066,10 +1134,26 @@
"defaultMessage": "Start coding",
"description": "Start coding button text"
},
+ "storage-error-other": {
+ "defaultMessage": "Failed to save your project to browser storage",
+ "description": "Toast shown when an IndexedDB storage write fails for an unknown reason"
+ },
+ "storage-error-quota-description": {
+ "defaultMessage": "Your project edit may not be saved.",
+ "description": "Toast description when browser storage quota is exceeded"
+ },
+ "storage-error-quota-title": {
+ "defaultMessage": "Browser storage full",
+ "description": "Toast title when browser storage quota is exceeded"
+ },
"support": {
"defaultMessage": "Support",
"description": "Support menu option text"
},
+ "teacher-resources-row-title": {
+ "defaultMessage": "Teacher resources",
+ "description": "Heading of the home page row of teacher resource cards"
+ },
"terms-of-use": {
"defaultMessage": "Terms of use",
"description": "Terms of use menu option text"
@@ -1110,6 +1194,10 @@
"defaultMessage": "Transfer saved hex file to micro:bit",
"description": "Title for the transfer hex dialog"
},
+ "troubleshooting-resource-title": {
+ "defaultMessage": "Troubleshooting",
+ "description": "Title of the help card linking to the support site"
+ },
"try-again-action": {
"defaultMessage": "Try again",
"description": "Try again button text"
@@ -1142,6 +1230,10 @@
"defaultMessage": "User guide",
"description": "Menu item for link to user guide site"
},
+ "view-all-projects": {
+ "defaultMessage": "View all",
+ "description": "Link and card on the home page leading to the projects page"
+ },
"visit-dot-org": {
"defaultMessage": "visit microbit.org (opens in a new tab)",
"description": "alt text for logo link to .org"
diff --git a/lang/ui.es-ES.json b/lang/ui.es-ES.json
index 12e164612..34a1f029f 100644
--- a/lang/ui.es-ES.json
+++ b/lang/ui.es-ES.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "Cancelar"
},
- "change-files": {
- "defaultMessage": "¿Cambiar archivos?"
- },
"choose-main-add-file": {
"defaultMessage": "Añadir archivo {name}"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "Confirmar eliminación"
},
- "confirm-replace-body": {
- "defaultMessage": "¿Sustituir todos los archivos por los del HEX?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "¿Sustituir todos los archivos por el código de inicio predeterminado?"
- },
- "confirm-replace-title": {
- "defaultMessage": "¿Sustituir proyecto?"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "¿Sustituir todos los archivos por {ideaName}?"
- },
"confirm-save-action": {
"defaultMessage": "Confirmar y guardar"
},
- "confirm-save-hint": {
- "defaultMessage": "Cancela y utiliza Guardar para conservar una copia de tu proyecto."
- },
"connect-action": {
"defaultMessage": "Conectar"
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "Abrir"
},
- "open-file-action": {
- "defaultMessage": "Abrir…"
- },
"open-file-dropped": {
"defaultMessage": "Abrir archivo al soltarlo"
},
- "open-hover": {
- "defaultMessage": "Abre un archivo HEX o Python o añade otros archivos"
- },
- "options": {
- "defaultMessage": "Opciones"
- },
"parameter-help": {
"defaultMessage": "Ayuda con parámetros"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "El nombre del proyecto no puede estar en blanco"
},
- "project-tab": {
- "defaultMessage": "Proyecto"
- },
- "project-tab-description": {
- "defaultMessage": "Ver, crear, añadir y editar los archivos del proyecto"
- },
"python-powered": {
"defaultMessage": "Funciona con Python"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "Referencia"
},
- "replace-action-label": {
- "defaultMessage": "Sustituir"
- },
- "reset-project-action": {
- "defaultMessage": "Restablecer proyecto"
- },
- "reset-project-feedback": {
- "defaultMessage": "Proyecto restablecido al código de inicio predeterminado"
- },
- "reset-project-hover": {
- "defaultMessage": "Restablece el proyecto al código de inicio predeterminado, descartando tu trabajo"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {Ningún resultado} one {# resultado} other {# resultados}}"
},
diff --git a/lang/ui.fr.json b/lang/ui.fr.json
index 7aff0665c..b26ec02be 100644
--- a/lang/ui.fr.json
+++ b/lang/ui.fr.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "Annuler"
},
- "change-files": {
- "defaultMessage": "Changer de fichiers ?"
- },
"choose-main-add-file": {
"defaultMessage": "Ajouter le fichier {name}"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "Confirmer la suppression"
},
- "confirm-replace-body": {
- "defaultMessage": "Remplacer tous les fichiers par ceux de l’hex ?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "Remplacer tous les fichiers avec le code de départ par défaut ?"
- },
- "confirm-replace-title": {
- "defaultMessage": "Confirmer le remplacement du projet"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "Remplacer tous les fichiers par {ideaName} ?"
- },
"confirm-save-action": {
"defaultMessage": "Confirmer et enregistrer"
},
- "confirm-save-hint": {
- "defaultMessage": "Annulez et utilisez Enregistrer pour conserver une copie de votre projet."
- },
"connect-action": {
"defaultMessage": "Connecter"
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "Ouvrir"
},
- "open-file-action": {
- "defaultMessage": "Ouvrir…"
- },
"open-file-dropped": {
"defaultMessage": "Ouvrir le fichier lorsqu’il est déposé"
},
- "open-hover": {
- "defaultMessage": "Ouvrir un fichier hex ou Python, ou ajouter d'autres fichiers"
- },
- "options": {
- "defaultMessage": "Options"
- },
"parameter-help": {
"defaultMessage": "Aide aux paramètres"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "Le nom du projet ne peut pas être vide"
},
- "project-tab": {
- "defaultMessage": "Projet"
- },
- "project-tab-description": {
- "defaultMessage": "Visualiser, créer, ajouter et modifier les fichiers de votre projet"
- },
"python-powered": {
"defaultMessage": "Fonctionne avec Python"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "Référence"
},
- "replace-action-label": {
- "defaultMessage": "Remplacer"
- },
- "reset-project-action": {
- "defaultMessage": "Réinitialiser le projet"
- },
- "reset-project-feedback": {
- "defaultMessage": "Le projet a été réinitialisé au code de départ par défaut"
- },
- "reset-project-hover": {
- "defaultMessage": "Réinitialise le projet au code de départ par défaut, supprimant ainsi votre travail"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {Aucun résultat} one {# résultat} other {# résultats}}"
},
diff --git a/lang/ui.ga-IE.json b/lang/ui.ga-IE.json
index 1ef1b1c7e..fa3af4b16 100644
--- a/lang/ui.ga-IE.json
+++ b/lang/ui.ga-IE.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "Cealaigh"
},
- "change-files": {
- "defaultMessage": "Athraigh comhaid?"
- },
"choose-main-add-file": {
"defaultMessage": "Cuir comhad {name} leis"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "Deimhnigh scrios"
},
- "confirm-replace-body": {
- "defaultMessage": "Cuir iad siúd sa heics in ionad gach comhad?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "Cuir an cód tosaithe réamhshocraithe in ionad gach comhaid?"
- },
- "confirm-replace-title": {
- "defaultMessage": "Deimhnigh ionadaigh an tionscadal"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "Cuir {ideaName} in ionad gach comhaid?"
- },
"confirm-save-action": {
"defaultMessage": "Deimhnigh agus sábháil"
},
- "confirm-save-hint": {
- "defaultMessage": "Cealaigh ansin úsáid Sábháil chun cóip de do thionscadal a choinneáil."
- },
"connect-action": {
"defaultMessage": "Ceangail"
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "Oscail"
},
- "open-file-action": {
- "defaultMessage": "Oscail…"
- },
"open-file-dropped": {
"defaultMessage": "Oscail comhad nuair a thit sé"
},
- "open-hover": {
- "defaultMessage": "Oscail comhad heicsidheachúlach nó Python nó cuir comhaid eile leis"
- },
- "options": {
- "defaultMessage": "Roghanna"
- },
"parameter-help": {
"defaultMessage": "Cabhair pharaiméadair"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "Ní féidir ainm an tionscadail a fholmhú"
},
- "project-tab": {
- "defaultMessage": "Tionscadal"
- },
- "project-tab-description": {
- "defaultMessage": "Amharc ar na comhaid i do thionscadal, iad a chruthú, a chur leis agus a chur in eagar"
- },
"python-powered": {
"defaultMessage": "Python faoi thiomáint"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "Tagairt"
},
- "replace-action-label": {
- "defaultMessage": "Ionadaigh"
- },
- "reset-project-action": {
- "defaultMessage": "Athshocraigh tionscadal"
- },
- "reset-project-feedback": {
- "defaultMessage": "Athshocraigh an tionscadal go dtí an cód tosaithe réamhshocraithe"
- },
- "reset-project-hover": {
- "defaultMessage": "Athshocraigh an tionscadal chuig an gcód tosaithe réamhshocraithe, ag caitheamh do chuid oibre"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {Gan torthaí} one {# toradh} two {# thoradh} few {# torthaí} many {# torthaí} other {# torthaí}}\n"
},
diff --git a/lang/ui.ja.json b/lang/ui.ja.json
index 2b84de308..128cc1337 100644
--- a/lang/ui.ja.json
+++ b/lang/ui.ja.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "キャンセル"
},
- "change-files": {
- "defaultMessage": "ファイルを変更しますか?"
- },
"choose-main-add-file": {
"defaultMessage": "ファイル {name} を追加"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "削除の確認"
},
- "confirm-replace-body": {
- "defaultMessage": "すべてのファイルを hex に置き換えますか?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "すべてのファイルをデフォルトのスターターコードに置き換えますか?"
- },
- "confirm-replace-title": {
- "defaultMessage": "プロジェクトの置き換えを確認"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "すべてのファイルを {ideaName} に置き換えますか?"
- },
"confirm-save-action": {
"defaultMessage": "確認して保存"
},
- "confirm-save-hint": {
- "defaultMessage": "キャンセルして、保存を使ってプロジェクトのコピーを保持します。"
- },
"connect-action": {
"defaultMessage": "接続"
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "開く"
},
- "open-file-action": {
- "defaultMessage": "開く…"
- },
"open-file-dropped": {
"defaultMessage": "ドロップ時にファイルを開く"
},
- "open-hover": {
- "defaultMessage": "hex ファイルまたは Python ファイルを開くか、他のファイルを追加する"
- },
- "options": {
- "defaultMessage": "オプション"
- },
"parameter-help": {
"defaultMessage": "パラメータのヘルプ"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "プロジェクト名は空にできません"
},
- "project-tab": {
- "defaultMessage": "プロジェクト"
- },
- "project-tab-description": {
- "defaultMessage": "プロジェクト内のファイルの表示、作成、追加、編集"
- },
"python-powered": {
"defaultMessage": "Python搭載"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "リファレンス"
},
- "replace-action-label": {
- "defaultMessage": "置換"
- },
- "reset-project-action": {
- "defaultMessage": "プロジェクトをリセット"
- },
- "reset-project-feedback": {
- "defaultMessage": "プロジェクトがデフォルトのスターターコードにリセットされました"
- },
- "reset-project-hover": {
- "defaultMessage": "プロジェクトをデフォルトのスターターコードにリセットし、作業を破棄します"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {結果なし} other {#件の結果}}"
},
diff --git a/lang/ui.ko.json b/lang/ui.ko.json
index 322ddafd2..fda2ab891 100644
--- a/lang/ui.ko.json
+++ b/lang/ui.ko.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "취소"
},
- "change-files": {
- "defaultMessage": "파일을 변경하시겠습니까?"
- },
"choose-main-add-file": {
"defaultMessage": "{name} 파일 추가"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "삭제 확인"
},
- "confirm-replace-body": {
- "defaultMessage": "모든 파일을 hex 내 파일로 교체하시겠습니까?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "모든 파일을 기본 스타터 코드로 교체하시겠습니까?"
- },
- "confirm-replace-title": {
- "defaultMessage": "프로젝트 교체 확인"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "모든 파일을 {ideaName}(으)로 교체하시겠습니까?"
- },
"confirm-save-action": {
"defaultMessage": "확인 및 저장"
},
- "confirm-save-hint": {
- "defaultMessage": "프로젝트의 사본을 유지하려면 취소하고 저장하세요."
- },
"connect-action": {
"defaultMessage": "연결"
},
@@ -389,18 +371,9 @@
"open-action": {
"defaultMessage": "열기"
},
- "open-file-action": {
- "defaultMessage": "열기…"
- },
"open-file-dropped": {
"defaultMessage": "드롭될 때 파일 열기"
},
- "open-hover": {
- "defaultMessage": "Hex 또는 Python 파일을 열거나 다른 파일을 추가하세요"
- },
- "options": {
- "defaultMessage": "옵션"
- },
"parameter-help": {
"defaultMessage": "매개변수 도움말"
},
@@ -443,12 +416,6 @@
"project-name-not-empty": {
"defaultMessage": "프로젝트 이름을 입력해야 합니다"
},
- "project-tab": {
- "defaultMessage": "프로젝트"
- },
- "project-tab-description": {
- "defaultMessage": "프로젝트에서 파일을 확인하고, 생성하고, 추가하고 편집할 수 있습니다"
- },
"python-powered": {
"defaultMessage": "Python powered"
},
@@ -470,18 +437,6 @@
"reference-tab": {
"defaultMessage": "참조"
},
- "replace-action-label": {
- "defaultMessage": "교체"
- },
- "reset-project-action": {
- "defaultMessage": "프로젝트 초기화"
- },
- "reset-project-feedback": {
- "defaultMessage": "프로젝트를 기본 스타터 코드로 초기화합니다."
- },
- "reset-project-hover": {
- "defaultMessage": "프로젝트를 기본 스타터 코드로 초기화하고 작업한 내용을 삭제합니다"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {결과 없음} one {#개의 결과} other {#개의 결과}}"
},
diff --git a/lang/ui.lol.json b/lang/ui.lol.json
index 2effea779..e68fba758 100644
--- a/lang/ui.lol.json
+++ b/lang/ui.lol.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "crwdns331388:0crwdne331388:0"
},
- "change-files": {
- "defaultMessage": "crwdns331390:0crwdne331390:0"
- },
"choose-main-add-file": {
"defaultMessage": "crwdns331392:0{name}crwdne331392:0"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "crwdns331418:0crwdne331418:0"
},
- "confirm-replace-body": {
- "defaultMessage": "crwdns331420:0crwdne331420:0"
- },
- "confirm-replace-reset": {
- "defaultMessage": "crwdns331422:0crwdne331422:0"
- },
- "confirm-replace-title": {
- "defaultMessage": "crwdns331424:0crwdne331424:0"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "crwdns331426:0{ideaName}crwdne331426:0"
- },
"confirm-save-action": {
"defaultMessage": "crwdns331428:0crwdne331428:0"
},
- "confirm-save-hint": {
- "defaultMessage": "crwdns331430:0crwdne331430:0"
- },
"connect-action": {
"defaultMessage": "crwdns331432:0crwdne331432:0"
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "crwdns331610:0crwdne331610:0"
},
- "open-file-action": {
- "defaultMessage": "crwdns331612:0crwdne331612:0"
- },
"open-file-dropped": {
"defaultMessage": "crwdns331614:0crwdne331614:0"
},
- "open-hover": {
- "defaultMessage": "crwdns331616:0crwdne331616:0"
- },
- "options": {
- "defaultMessage": "crwdns331618:0crwdne331618:0"
- },
"parameter-help": {
"defaultMessage": "crwdns331620:0crwdne331620:0"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "crwdns331644:0crwdne331644:0"
},
- "project-tab": {
- "defaultMessage": "crwdns331646:0crwdne331646:0"
- },
- "project-tab-description": {
- "defaultMessage": "crwdns331648:0crwdne331648:0"
- },
"python-powered": {
"defaultMessage": "crwdns331650:0crwdne331650:0"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "crwdns331662:0crwdne331662:0"
},
- "replace-action-label": {
- "defaultMessage": "crwdns331664:0crwdne331664:0"
- },
- "reset-project-action": {
- "defaultMessage": "crwdns331666:0crwdne331666:0"
- },
- "reset-project-feedback": {
- "defaultMessage": "crwdns331668:0crwdne331668:0"
- },
- "reset-project-hover": {
- "defaultMessage": "crwdns331670:0crwdne331670:0"
- },
"results-count": {
"defaultMessage": "crwdns331672:0count={count}crwdne331672:0"
},
diff --git a/lang/ui.nl.json b/lang/ui.nl.json
index 35543d2fb..25c1ba9ad 100644
--- a/lang/ui.nl.json
+++ b/lang/ui.nl.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "Annuleren"
},
- "change-files": {
- "defaultMessage": "Bestanden wijzigen?"
- },
"choose-main-add-file": {
"defaultMessage": "Voeg bestand {name} toe"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "Verwijderen bevestigen"
},
- "confirm-replace-body": {
- "defaultMessage": "Alle bestanden vervangen door bestanden in de hex?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "Alle bestanden vervangen door de standaard startcode?"
- },
- "confirm-replace-title": {
- "defaultMessage": "Vervanging bevestigen"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "Alle bestanden vervangen door {ideaName}?"
- },
"confirm-save-action": {
"defaultMessage": "Bevestigen en opslaan"
},
- "confirm-save-hint": {
- "defaultMessage": "Annuleer dan Opslaan om een kopie van je project te bewaren."
- },
"connect-action": {
"defaultMessage": "Verbinden "
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "Open"
},
- "open-file-action": {
- "defaultMessage": "Open…"
- },
"open-file-dropped": {
"defaultMessage": "Open bestand wanneer gedropt"
},
- "open-hover": {
- "defaultMessage": "Open een hex of Python bestand of voeg andere bestanden toe"
- },
- "options": {
- "defaultMessage": "Opties"
- },
"parameter-help": {
"defaultMessage": "Hulp parameter"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "De projectnaam kan niet leeg zijn"
},
- "project-tab": {
- "defaultMessage": "Project"
- },
- "project-tab-description": {
- "defaultMessage": "Bekijk, aanmaken, toevoegen en bewerken van de bestanden in je project"
- },
"python-powered": {
"defaultMessage": "Python actief"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "Referentie"
},
- "replace-action-label": {
- "defaultMessage": "Vervangen"
- },
- "reset-project-action": {
- "defaultMessage": "Reset project"
- },
- "reset-project-feedback": {
- "defaultMessage": "Project opnieuw instellen op de standaard startcode"
- },
- "reset-project-hover": {
- "defaultMessage": "Reset het project naar de standaard start code, waarmee je jouw werk weggooit"
- },
"results-count": {
"defaultMessage": "{count, plural, =0{# results} one{# result} other{# results}}"
},
diff --git a/lang/ui.pl.json b/lang/ui.pl.json
index d89896a94..f74b39ea3 100644
--- a/lang/ui.pl.json
+++ b/lang/ui.pl.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "Anuluj"
},
- "change-files": {
- "defaultMessage": "Zmienić pliki?"
- },
"choose-main-add-file": {
"defaultMessage": "Dodaj plik {name}"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "Potwierdź usunięcie"
},
- "confirm-replace-body": {
- "defaultMessage": "Zastąpić wszystkie pliki tymi z hex?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "Zastąpić wszystkie pliki domyślnym kodem startowym?"
- },
- "confirm-replace-title": {
- "defaultMessage": "Potwierdź zastąpienie projektu"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "Zastąpić wszystkie pliki {ideaName}?"
- },
"confirm-save-action": {
"defaultMessage": "Potwierdź i zapisz"
},
- "confirm-save-hint": {
- "defaultMessage": "Anuluj następnie użycie Zapisz, aby zachować kopię projektu."
- },
"connect-action": {
"defaultMessage": "Podłącz"
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "Otwórz"
},
- "open-file-action": {
- "defaultMessage": "Otwórz…"
- },
"open-file-dropped": {
"defaultMessage": "Otwórz plik po upuszczeniu"
},
- "open-hover": {
- "defaultMessage": "Otwórz plik hex lub Pythona lub dodaj inne pliki"
- },
- "options": {
- "defaultMessage": "Opcje"
- },
"parameter-help": {
"defaultMessage": "Pomoc dot. parametru"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "Nazwa projektu nie może być pusta"
},
- "project-tab": {
- "defaultMessage": "Projekt"
- },
- "project-tab-description": {
- "defaultMessage": "Przejrzyj, utwórz, dodaj i edytuj pliki w projekcie"
- },
"python-powered": {
"defaultMessage": "Obsługiwany przez Python"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "Referencje"
},
- "replace-action-label": {
- "defaultMessage": "Zastąp"
- },
- "reset-project-action": {
- "defaultMessage": "Resetuj projekt"
- },
- "reset-project-feedback": {
- "defaultMessage": "Reset projektu do domyślnego kodu startowego"
- },
- "reset-project-hover": {
- "defaultMessage": "Resetuje projekt do domyślnego kodu startowego, odrzucając Twoją pracę"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {Brak wyników} one {# wynik} few {# wyników} many {# wyników} other {# wyników}}"
},
diff --git a/lang/ui.zh-CN.json b/lang/ui.zh-CN.json
index d875e1e92..6980e8746 100644
--- a/lang/ui.zh-CN.json
+++ b/lang/ui.zh-CN.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "取消"
},
- "change-files": {
- "defaultMessage": "更改文件?"
- },
"choose-main-add-file": {
"defaultMessage": "添加文件 {name}"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "确认删除"
},
- "confirm-replace-body": {
- "defaultMessage": "将所有文件替换成 hex 文件?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "用默认启动代码替换所有文件?"
- },
- "confirm-replace-title": {
- "defaultMessage": "确认替换项目"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "用 {ideaName} 替换所有文件?"
- },
"confirm-save-action": {
"defaultMessage": "确认并保存"
},
- "confirm-save-hint": {
- "defaultMessage": "取消然后使用 Save (保存)来保留您的项目副本。"
- },
"connect-action": {
"defaultMessage": "连接"
},
@@ -392,18 +374,9 @@
"open-action": {
"defaultMessage": "打开"
},
- "open-file-action": {
- "defaultMessage": "打开…"
- },
"open-file-dropped": {
"defaultMessage": "放下时打开文件"
},
- "open-hover": {
- "defaultMessage": "打开一 份 hex 或 Python 文件,或添加其他文件"
- },
- "options": {
- "defaultMessage": "选项"
- },
"parameter-help": {
"defaultMessage": "参数帮助"
},
@@ -446,12 +419,6 @@
"project-name-not-empty": {
"defaultMessage": "项目名称不能为空"
},
- "project-tab": {
- "defaultMessage": "项目"
- },
- "project-tab-description": {
- "defaultMessage": "查看、创建、添加和编辑您项目中的文件"
- },
"python-powered": {
"defaultMessage": "Python 驱动"
},
@@ -473,18 +440,6 @@
"reference-tab": {
"defaultMessage": "参考"
},
- "replace-action-label": {
- "defaultMessage": "替换"
- },
- "reset-project-action": {
- "defaultMessage": "重置项目"
- },
- "reset-project-feedback": {
- "defaultMessage": "项目重置为默认启动代码"
- },
- "reset-project-hover": {
- "defaultMessage": "将项目重置为默认启动代码,放弃您的工作"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {No results} other {# results}}"
},
diff --git a/lang/ui.zh-TW.json b/lang/ui.zh-TW.json
index c540556db..0a4ead808 100644
--- a/lang/ui.zh-TW.json
+++ b/lang/ui.zh-TW.json
@@ -53,9 +53,6 @@
"cancel-action": {
"defaultMessage": "取消"
},
- "change-files": {
- "defaultMessage": "是否要變更檔案?"
- },
"choose-main-add-file": {
"defaultMessage": "新增檔案 {name}"
},
@@ -98,24 +95,9 @@
"confirm-delete": {
"defaultMessage": "確認刪除"
},
- "confirm-replace-body": {
- "defaultMessage": "是否要用 hex 取代所有檔案?"
- },
- "confirm-replace-reset": {
- "defaultMessage": "是否要用預設起始程式碼來取代所有檔案?"
- },
- "confirm-replace-title": {
- "defaultMessage": "確認取代專案"
- },
- "confirm-replace-with-idea": {
- "defaultMessage": "是否要用 {ideaName} 取代所有檔案?"
- },
"confirm-save-action": {
"defaultMessage": "確認並儲存"
},
- "confirm-save-hint": {
- "defaultMessage": "取消,然後使用「儲存」來保留您的專案副本。"
- },
"connect-action": {
"defaultMessage": "連線"
},
@@ -389,18 +371,9 @@
"open-action": {
"defaultMessage": "開啟"
},
- "open-file-action": {
- "defaultMessage": "開啟..."
- },
"open-file-dropped": {
"defaultMessage": "放置時開啟檔案"
},
- "open-hover": {
- "defaultMessage": "開啟 Hex 或 Python 檔案,或是新增其他檔案"
- },
- "options": {
- "defaultMessage": "選項"
- },
"parameter-help": {
"defaultMessage": "參數說明"
},
@@ -443,12 +416,6 @@
"project-name-not-empty": {
"defaultMessage": "專案名稱不可為空白"
},
- "project-tab": {
- "defaultMessage": "專案"
- },
- "project-tab-description": {
- "defaultMessage": "檢視、建立、新增和編輯專案中的檔案"
- },
"python-powered": {
"defaultMessage": "已啟動 Python"
},
@@ -470,18 +437,6 @@
"reference-tab": {
"defaultMessage": "參考資料"
},
- "replace-action-label": {
- "defaultMessage": "取代"
- },
- "reset-project-action": {
- "defaultMessage": "重設專案"
- },
- "reset-project-feedback": {
- "defaultMessage": "專案重設為預設起始程式碼"
- },
- "reset-project-hover": {
- "defaultMessage": "將專案重設為預設起始程式碼,放棄您的工作"
- },
"results-count": {
"defaultMessage": "{count, plural, =0 {沒有結果} one {# 個結果} other {# 個結果}}"
},
diff --git a/package-lock.json b/package-lock.json
index 911087448..9eb50c9c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"@microbit/microbit-fs": "^0.10.0",
"@microbit/micropython-microbit-stubs": "^1.0.0",
"@microbit/ui": "^0.5.0",
+ "@microbit/ui-carousel": "^0.4.0",
"@microbit/ui-patterns": "^0.7.0",
"@sanity/block-content-to-react": "^3.0.0",
"@sanity/image-url": "^1.0.1",
@@ -34,6 +35,7 @@
"crelt": "^1.0.5",
"dompurify": "^3.2.5",
"file-saver": "^2.0.5",
+ "idb": "^8.0.3",
"lunr": "^2.3.9",
"lunr-languages": "^1.14.0",
"lzma": "^2.3.2",
@@ -47,6 +49,7 @@
"react-icons": "^4.12.0",
"react-intl": "^6.6.8",
"react-router": "^7.18.3",
+ "swiper": "^14.2.0",
"vite": "^7.3.1",
"vscode-jsonrpc": "^9.0.0",
"vscode-languageserver-protocol": "^3.16.0",
@@ -68,6 +71,7 @@
"cross-env": "^7.0.3",
"ejs": "^3.1.9",
"eslint": "^10.9.1",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
"playwright": "^1.58.2",
"prettier": "2.3.2",
@@ -3395,6 +3399,20 @@
"react-intl": "^6.6.8 || ^7.0.0"
}
},
+ "node_modules/@microbit/ui-carousel": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@microbit/ui-carousel/-/ui-carousel-0.4.0.tgz",
+ "integrity": "sha512-oDAaqZfaSLBWeDYSi9EAlCYC8rKw9nsU6TmOm5BBGNm1OyD34M2d3QA8ukFJ4hM8RmAVg/8DJ2MhOk7UNhXMjQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@microbit/ui": "^0.5.0",
+ "@pandacss/dev": "^1.11.4",
+ "react": "^18.3.1",
+ "react-aria": "^3.50.0",
+ "react-intl": "^6.6.8 || ^7.0.0",
+ "swiper": "^14.0.0"
+ }
+ },
"node_modules/@microbit/ui-patterns": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@microbit/ui-patterns/-/ui-patterns-0.7.0.tgz",
@@ -7545,6 +7563,16 @@
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
"license": "MIT"
},
+ "node_modules/fake-indexeddb": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
+ "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -8278,10 +8306,9 @@
}
},
"node_modules/idb": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
- "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
- "dev": true,
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
+ "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ignore": {
@@ -11653,6 +11680,25 @@
"node": ">=8"
}
},
+ "node_modules/swiper": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/swiper/-/swiper-14.2.0.tgz",
+ "integrity": "sha512-GsL4M9Fq7Sg22OyL3SjdNj/0bxTfIxPJJHHW394Li0u5vYtExi0a09iBUc8CuLafJi5pAlH2AHUe1PocxHl45g==",
+ "funding": [
+ {
+ "type": "custom",
+ "url": "https://sponsors.nolimits4web.com"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nolimits4web"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.7.0"
+ }
+ },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -13129,6 +13175,13 @@
"workbox-core": "7.4.1"
}
},
+ "node_modules/workbox-background-sync/node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/workbox-broadcast-update": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz",
@@ -13286,6 +13339,13 @@
"workbox-core": "7.4.1"
}
},
+ "node_modules/workbox-expiration/node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/workbox-google-analytics": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz",
diff --git a/package.json b/package.json
index a89ce9bb1..db321125b 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@microbit/microbit-fs": "^0.10.0",
"@microbit/micropython-microbit-stubs": "^1.0.0",
"@microbit/ui": "^0.5.0",
+ "@microbit/ui-carousel": "^0.4.0",
"@microbit/ui-patterns": "^0.7.0",
"@sanity/block-content-to-react": "^3.0.0",
"@sanity/image-url": "^1.0.1",
@@ -38,6 +39,7 @@
"crelt": "^1.0.5",
"dompurify": "^3.2.5",
"file-saver": "^2.0.5",
+ "idb": "^8.0.3",
"lunr": "^2.3.9",
"lunr-languages": "^1.14.0",
"lzma": "^2.3.2",
@@ -51,6 +53,7 @@
"react-icons": "^4.12.0",
"react-intl": "^6.6.8",
"react-router": "^7.18.3",
+ "swiper": "^14.2.0",
"vite": "^7.3.1",
"vscode-jsonrpc": "^9.0.0",
"vscode-languageserver-protocol": "^3.16.0",
@@ -72,6 +75,7 @@
"cross-env": "^7.0.3",
"ejs": "^3.1.9",
"eslint": "^10.9.1",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^28.1.0",
"playwright": "^1.58.2",
"prettier": "2.3.2",
@@ -89,7 +93,8 @@
"prebuild": "npm run generate",
"ci": "npm run typecheck && npm run test && npm run lint && npm run i18n:tidy -- --check && npm run build",
"deploy": "website-deploy-aws",
- "dev:link-ui": "rm -rf node_modules/@microbit/ui node_modules/@microbit/ui-patterns node_modules/@microbit/i18n-tools && ln -s ../../../ui/packages/ui node_modules/@microbit/ui && ln -s ../../../ui/packages/ui-patterns node_modules/@microbit/ui-patterns && ln -s ../../../ui/packages/i18n-tools node_modules/@microbit/i18n-tools && ln -sf ../@microbit/i18n-tools/bin/microbit-i18n.mjs node_modules/.bin/microbit-i18n && rm -rf styled-system node_modules/.vite && npm run panda",
+ "dev:link-ui": "rm -rf node_modules/@microbit/ui node_modules/@microbit/ui-patterns node_modules/@microbit/ui-carousel node_modules/@microbit/i18n-tools && ln -s ../../../ui/packages/ui node_modules/@microbit/ui && ln -s ../../../ui/packages/ui-patterns node_modules/@microbit/ui-patterns && ln -s ../../../ui/packages/ui-carousel node_modules/@microbit/ui-carousel && ln -s ../../../ui/packages/i18n-tools node_modules/@microbit/i18n-tools && ln -sf ../@microbit/i18n-tools/bin/microbit-i18n.mjs node_modules/.bin/microbit-i18n && rm -rf styled-system node_modules/.vite && npm run panda",
+ "dev:link-theme": "rm -rf node_modules/@microbit-foundation/python-editor-v3-microbit && mkdir -p node_modules/@microbit-foundation && ln -s ../../../python-editor-v3-microbit node_modules/@microbit-foundation/python-editor-v3-microbit && npm run build --prefix ../python-editor-v3-microbit && rm -rf styled-system node_modules/.vite && npm run panda",
"fix-licensing-headers": "node bin/fix-licensing-headers.cjs",
"generate": "npm run panda && npm run i18n:compile && npm run stubs",
"i18n:compile": "microbit-i18n compile",
diff --git a/panda.config.ts b/panda.config.ts
index f8afe2a94..3a6522ad7 100644
--- a/panda.config.ts
+++ b/panda.config.ts
@@ -52,6 +52,7 @@ export default defineConfig({
// broken non-recipe styling — check the resolved node_modules path.
"./node_modules/@microbit/ui/src/**/*.{ts,tsx}",
"./node_modules/@microbit/ui-patterns/src/**/*.{ts,tsx}",
+ "./node_modules/@microbit/ui-carousel/src/**/*.{ts,tsx}",
],
outdir: "styled-system",
});
diff --git a/src/App.tsx b/src/App.tsx
index 37a7bae50..fd2439bf4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,8 +5,9 @@
*/
import { SharedUIProvider, ToastProvider } from "@microbit/ui";
import { polyfill } from "mobile-drag-drop";
-import { useEffect, useMemo } from "react";
+import { useEffect } from "react";
import "./App.css";
+import { deferred } from "./common/deferred";
import { DialogProvider } from "./common/use-dialogs";
import VisualViewPortCSSVariables from "./common/VisualViewportCSSVariables";
import { deployment, useDeployment } from "./deployment";
@@ -22,15 +23,21 @@ import SearchProvider from "./documentation/search/search-hooks";
import { ActiveEditorProvider } from "./editor/active-editor-hooks";
import { FileSystem } from "./fs/fs";
import { FileSystemProvider } from "./fs/fs-hooks";
-import { createHost } from "./fs/host";
+import { openProjectsDatabase } from "./fs/current-project";
+import { createHost, IframeHost } from "./fs/host";
+import { PendingMigration } from "./fs/migration";
+import { FSStorage } from "./fs/storage";
+import { IframeModeProvider } from "./iframe-mode-hooks";
+import { Projects } from "./project/projects";
+import { ProjectsProvider } from "./project/projects-hooks";
import { fetchMicroPython } from "./micropython/micropython";
import { LanguageServerClientProvider } from "./language-server/language-server-hooks";
import { logDeviceStatusChange } from "./logging/analytics";
import { LoggingProvider } from "./logging/logging-hooks";
import TranslationProvider from "./messages/TranslationProvider";
-import ProjectDropTarget from "./project/ProjectDropTarget";
import { RouterProvider } from "react-router/dom";
import { createRouter } from "./router";
+import StorageErrorToast from "./project/storage-error-toast";
import SessionSettingsProvider from "./settings/session-settings";
import SettingsProvider from "./settings/settings";
import BeforeUnloadDirtyCheck from "./workbench/BeforeUnloadDirtyCheck";
@@ -47,12 +54,34 @@ const device: MicrobitUSBConnection = isMockDeviceMode()
? new MockDeviceConnection()
: createUSBConnection({ logging });
-const host = createHost(logging);
+// The database opens at boot; the project the editor shows is chosen when
+// the editor route loads, so the pages need not pick one.
+const projectStorage = deferred();
+const migration = new PendingMigration(window.location.href);
+const host = createHost(logging, migration, projectStorage.promise);
+const iframeMode = host instanceof IframeHost;
const fs = new FileSystem(logging, host, fetchMicroPython);
+const projects = iframeMode
+ ? undefined
+ : new Projects(
+ fs,
+ logging,
+ openProjectsDatabase(logging),
+ projectStorage,
+ migration
+ );
+if (!projects) {
+ projectStorage.resolve(undefined);
+}
-// If this fails then we retry on access.
+// If this fails then we retry on access. Until a project is opened this
+// waits on the storage, so only in iframe mode does it run straight away.
fs.initializeInBackground();
+// Created once here: a browser router starts running its loaders as soon as
+// it exists, and React's development-mode double render would make two.
+const router = createRouter({ projects });
+
const App = () => {
useEffect(() => {
logging.setUserProperty(
@@ -75,46 +104,48 @@ const App = () => {
const deployment = useDeployment();
const { ConsentProvider } = deployment.compliance;
- const router = useMemo(() => createRouter(), []);
return (
<>
-
-
-
- {/* Inside TranslationProvider: SharedUIProvider passes the app
+
+
+
+
+ {/* Inside TranslationProvider: SharedUIProvider passes the app
locale to react-aria for its built-in strings, and the
toast region's close label and status announcements are
react-intl messages. */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
>
);
diff --git a/src/RootLayout.tsx b/src/RootLayout.tsx
index 918edb8d8..168e47aeb 100644
--- a/src/RootLayout.tsx
+++ b/src/RootLayout.tsx
@@ -6,6 +6,8 @@
import { ErrorBoundary, UnexpectedErrorPage } from "@microbit/ui-patterns";
import { useCallback } from "react";
import { Outlet } from "react-router";
+import StorageVersionErrorPage from "./fs/StorageVersionErrorPage";
+import { useStorageVersionError } from "./fs/storage-status";
import { useDeployment } from "./deployment";
import { useLogging } from "./logging/logging-hooks";
@@ -20,6 +22,7 @@ const RootLayout = () => {
(error: unknown) => logging.error("Uncaught render error", error),
[logging]
);
+ const storageVersionError = useStorageVersionError();
return (
{
)}
>
-
+ {storageVersionError ? : }
);
};
diff --git a/src/common/FileDropTarget.tsx b/src/common/FileDropTarget.tsx
index e818f910e..3e34ea3fc 100644
--- a/src/common/FileDropTarget.tsx
+++ b/src/common/FileDropTarget.tsx
@@ -6,9 +6,9 @@
import { ReactNode, useCallback, useState } from "react";
import { RiFolderOpenLine } from "react-icons/ri";
import { useIntl } from "react-intl";
-import { Box, Center } from "styled-system/jsx";
+import { Box, BoxProps, Center } from "styled-system/jsx";
-interface FileDropTargetProps {
+interface FileDropTargetProps extends BoxProps {
children: ReactNode;
onFileDrop: (files: File[]) => void;
"data-testid"?: string;
@@ -16,11 +16,15 @@ interface FileDropTargetProps {
/**
* An area that handles multiple dropped files.
+ *
+ * The drop overlay covers this element's box, so size it to the content it
+ * wraps: the editor fills its parent, the pages grow with their content.
*/
const FileDropTarget = ({
children,
onFileDrop,
"data-testid": dataTestId,
+ ...props
}: FileDropTargetProps) => {
const [dragOver, setDragOver] = useState(false);
@@ -56,7 +60,7 @@ const FileDropTarget = ({
data-testid={dataTestId}
onDragOver={handleDragOver}
position="relative"
- height="100%"
+ {...props}
>
{dragOver && (
{
+ promise: Promise;
+ resolve: (value: T) => void;
+}
+
+export const deferred = (): Deferred => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+};
diff --git a/src/deployment/default/images/accessibility.svg b/src/deployment/default/images/accessibility.svg
new file mode 100644
index 000000000..b55922589
--- /dev/null
+++ b/src/deployment/default/images/accessibility.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/deployment/default/images/animated-animals.jpg b/src/deployment/default/images/animated-animals.jpg
new file mode 100644
index 000000000..32975ea49
Binary files /dev/null and b/src/deployment/default/images/animated-animals.jpg differ
diff --git a/src/deployment/default/images/banner-background.svg b/src/deployment/default/images/banner-background.svg
new file mode 100644
index 000000000..625fd367a
--- /dev/null
+++ b/src/deployment/default/images/banner-background.svg
@@ -0,0 +1 @@
+
diff --git a/src/deployment/default/images/beating-heart.jpg b/src/deployment/default/images/beating-heart.jpg
new file mode 100644
index 000000000..32975ea49
Binary files /dev/null and b/src/deployment/default/images/beating-heart.jpg differ
diff --git a/src/deployment/default/images/emotion-badge.png b/src/deployment/default/images/emotion-badge.png
new file mode 100644
index 000000000..667d5f01d
Binary files /dev/null and b/src/deployment/default/images/emotion-badge.png differ
diff --git a/src/deployment/default/images/first-lessons-python.svg b/src/deployment/default/images/first-lessons-python.svg
new file mode 100644
index 000000000..b55922589
--- /dev/null
+++ b/src/deployment/default/images/first-lessons-python.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/deployment/default/images/flashing-emotions.jpg b/src/deployment/default/images/flashing-emotions.jpg
new file mode 100644
index 000000000..32975ea49
Binary files /dev/null and b/src/deployment/default/images/flashing-emotions.jpg differ
diff --git a/src/deployment/default/images/get-silly.png b/src/deployment/default/images/get-silly.png
new file mode 100644
index 000000000..667d5f01d
Binary files /dev/null and b/src/deployment/default/images/get-silly.png differ
diff --git a/src/deployment/default/images/heart.png b/src/deployment/default/images/heart.png
new file mode 100644
index 000000000..667d5f01d
Binary files /dev/null and b/src/deployment/default/images/heart.png differ
diff --git a/src/deployment/default/images/troubleshooting.svg b/src/deployment/default/images/troubleshooting.svg
new file mode 100644
index 000000000..b55922589
--- /dev/null
+++ b/src/deployment/default/images/troubleshooting.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/deployment/default/images/user-guide.svg b/src/deployment/default/images/user-guide.svg
new file mode 100644
index 000000000..b55922589
--- /dev/null
+++ b/src/deployment/default/images/user-guide.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/deployment/default/index.tsx b/src/deployment/default/index.tsx
index 550713d65..6200d6203 100644
--- a/src/deployment/default/index.tsx
+++ b/src/deployment/default/index.tsx
@@ -3,10 +3,72 @@
*
* SPDX-License-Identifier: MIT
*/
-import { BrandConfigFactory } from "..";
+import { BrandConfigFactory, LogoProps } from "..";
+
+// Inline styles rather than Panda, matching the private brand package,
+// which is resolved from node_modules outside Panda's extraction scope.
+const AppLogo = ({ h, color }: LogoProps) => (
+
+ Python Editor
+
+);
+
+// The sidebar header sizes these from their width alone, so a logo without
+// intrinsic height leaves the link that wraps it unclickable.
+const squareLogo = (
+
+
+
+ Py
+
+
+);
+
+const horizontalLogo = (
+
+
+ Python Editor
+
+
+);
const defaultBrandFactory: BrandConfigFactory = () => ({
product: "python-editor",
+ AppLogo,
+ squareLogo,
+ horizontalLogo,
// This isn't ideal as it's the branded version. You can just remove the field to remove the welcome dialog.
welcomeVideoYouTubeId: "mREwMW69qKc",
});
diff --git a/src/deployment/index.ts b/src/deployment/index.ts
index 46f0f9d96..edcdce185 100644
--- a/src/deployment/index.ts
+++ b/src/deployment/index.ts
@@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: MIT
*/
-import React, { ReactNode, useContext } from "react";
+import React, { ComponentType, ReactNode, useContext } from "react";
import { createStubCompliance } from "../compliance/stub";
import { createWebCompliance } from "../compliance/web";
import { Logger } from "../logging/logger";
@@ -29,6 +29,13 @@ export interface BrandConfig {
welcomeVideoYouTubeId?: string;
squareLogo?: ReactNode;
horizontalLogo?: ReactNode;
+ /**
+ * Product wordmark for the page header, e.g. "Python Editor". Draws in
+ * currentColor. Same shape as ml-trainer's so the brand packages match.
+ */
+ AppLogo?: ComponentType;
+ /** Organisation logo shown before the wordmark, e.g. the micro:bit logo. */
+ OrgLogo?: ComponentType;
supportLink?: string;
guideLink?: string;
@@ -37,6 +44,18 @@ export interface BrandConfig {
termsOfUseLink?: string;
privacyPolicyLink?: string;
translationLink?: string;
+ /**
+ * Name shown after the copyright symbol in the home page footer. Omit it
+ * and no copyright line is shown.
+ */
+ copyrightHolder?: string;
+}
+
+export interface LogoProps {
+ /** CSS height, e.g. "20px". */
+ h?: string;
+ /** CSS color; the logos draw in currentColor. */
+ color?: string;
}
export type BrandConfigFactory = (env: Record) => BrandConfig;
diff --git a/src/e2e/app-test-fixtures.ts b/src/e2e/app-test-fixtures.ts
index 2d84dda53..7e5077e77 100644
--- a/src/e2e/app-test-fixtures.ts
+++ b/src/e2e/app-test-fixtures.ts
@@ -1,20 +1,36 @@
import { test as base } from "@playwright/test";
-import { App } from "./app.js";
+import { App, baseUrl } from "./app.js";
+import { HomePage } from "./home-page.js";
+import { ProjectsPage } from "./projects-page.js";
type MyFixtures = {
app: App;
+ homePage: HomePage;
+ projectsPage: ProjectsPage;
};
-export const test = base.extend({
- app: async ({ page, context }, use) => {
- const app = new App(page, context);
+type Options = {
+ /** Hide IndexedDB so the editor falls back to session storage. */
+ noIndexedDB: boolean;
+ /** Open the editor before the test. Off for tests that embed it. */
+ autoGoto: boolean;
+};
+
+export const test = base.extend({
+ noIndexedDB: [false, { option: true }],
+ autoGoto: [true, { option: true }],
+ // On the context rather than the app fixture: the compliance notice is a
+ // modal dialog that hides the page from the accessibility tree, so a test
+ // that only drives the pages needs the cookie just as much as one that
+ // drives the editor.
+ context: async ({ context, noIndexedDB }, use) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await context.addCookies([
{
// See corresponding code in App.tsx.
name: "mockDevice",
value: "1",
- url: app.baseUrl,
+ url: baseUrl,
},
// Don't show compliance notice for Foundation builds
{
@@ -26,10 +42,31 @@ export const test = base.extend({
functional: true,
})
),
- url: app.baseUrl,
+ url: baseUrl,
},
]);
- await app.goto();
+ if (noIndexedDB) {
+ await context.addInitScript(() => {
+ // The getter lives on the prototype so an own property is needed.
+ Object.defineProperty(window, "indexedDB", {
+ value: undefined,
+ configurable: true,
+ });
+ });
+ }
+ await use(context);
+ },
+ app: async ({ page, context, autoGoto }, use) => {
+ const app = new App(page, context);
+ if (autoGoto) {
+ await app.goto();
+ }
await use(app);
},
+ homePage: async ({ page }, use) => {
+ await use(new HomePage(page));
+ },
+ projectsPage: async ({ page }, use) => {
+ await use(new ProjectsPage(page));
+ },
});
diff --git a/src/e2e/app.ts b/src/e2e/app.ts
index 363bd056b..c9a4eb02a 100644
--- a/src/e2e/app.ts
+++ b/src/e2e/app.ts
@@ -17,63 +17,30 @@ import { fileURLToPath } from "url";
import { readFileSync } from "fs";
import { DeviceErrorCode } from "@microbit/microbit-connection";
-export enum LoadDialogType {
- CONFIRM,
- REPLACE,
- CONFIRM_BUT_LOAD_AS_MODULE,
- NONE,
-}
-
export interface BrowserDownload {
filename: string;
data: Buffer;
}
// E2E_PORT points the suite at a server on another port.
-const baseUrl = `http://localhost:${process.env.E2E_PORT ?? "3000"}`;
+export const baseUrl = `http://localhost:${process.env.E2E_PORT ?? "3000"}`;
-interface UrlOptions {
+// We didn't use BASE_URL here as CRA seems to set it to "" before running jest.
+// Maybe can be changed since the Vite upgrade.
+const basePath = process.env.E2E_BASE_URL ?? "/";
+
+export interface UrlOptions {
flags?: Flag[];
fragment?: string;
language?: string;
+ /** Iframe controller mode, as embedded by classroom. */
+ controller?: boolean;
}
interface SaveOptions {
waitForDownload: boolean;
}
-class LoadDialog {
- private confirmButton: Locator;
- private replaceButton: Locator;
- private optionsButton: Locator;
- private type: LoadDialogType;
-
- constructor(public readonly page: Page, type: LoadDialogType) {
- this.type = type;
- this.confirmButton = this.page.getByRole("button", { name: "Confirm" });
- this.replaceButton = this.page.getByRole("button", { name: "Replace" });
- this.optionsButton = this.page.getByRole("button", {
- name: "Options",
- exact: true,
- });
- }
-
- async submit() {
- switch (this.type) {
- case LoadDialogType.CONFIRM:
- return await this.confirmButton.click();
- case LoadDialogType.REPLACE:
- return await this.replaceButton.click();
- case LoadDialogType.CONFIRM_BUT_LOAD_AS_MODULE:
- await this.optionsButton.click();
- await this.page.getByText(/^(Add|Replace) file .+\.py$/).click();
- return await this.confirmButton.click();
- default:
- return;
- }
- }
-}
-
class FileActionsMenu {
public saveButton: Locator;
public editButton: Locator;
@@ -101,8 +68,8 @@ class ProjectTabPanel {
private openButton: Locator;
constructor(public readonly page: Page) {
this.openButton = this.page
- .getByRole("tabpanel", { name: "Project" })
- .getByTestId("open");
+ .getByRole("tabpanel", { name: "Files" })
+ .getByTestId("add-files");
}
async openFileActionsMenu(filename: string) {
@@ -280,7 +247,7 @@ export class App {
}
async goto(options: UrlOptions = {}) {
- await this.page.goto(optionsToURL(options));
+ await this.page.goto(editorUrl(options));
// Wait for the page to be loaded
await this.editor.waitFor();
}
@@ -334,12 +301,12 @@ export class App {
}
}
- async switchTab(tabName: "Project" | "API" | "Reference" | "Ideas") {
+ async switchTab(tabName: "Files" | "API" | "Reference" | "Ideas") {
await this.page.getByRole("tab", { name: tabName }).click();
}
async createNewFile(name: string): Promise {
- await this.switchTab("Project");
+ await this.switchTab("Files");
await this.page.getByRole("button", { name: "Create file" }).click();
await this.page.getByLabel("Name*").fill(name);
await this.page
@@ -347,12 +314,6 @@ export class App {
.click();
}
- async resetProject(): Promise {
- await this.switchTab("Project");
- await this.page.getByRole("button", { name: "Reset project" }).click();
- await this.page.getByRole("button", { name: "Replace" }).click();
- }
-
async expectEditorContainText(match: RegExp | string) {
// Scroll to the top of code text area
await this.editorTextArea.click();
@@ -361,32 +322,31 @@ export class App {
}
async expectProjectFiles(expected: string[]): Promise {
- await this.switchTab("Project");
+ await this.switchTab("Files");
await expect(this.page.getByRole("listitem")).toHaveText(expected);
}
- async loadFiles(
- filePathFromProjectRoot: string,
- options: { acceptDialog?: LoadDialogType } = {}
- ) {
- await this.switchTab("Project");
+ /** Adds files from the Files tab; a hex opens as a new project. */
+ async loadFiles(filePathFromProjectRoot: string) {
+ await this.switchTab("Files");
await this.projectTab.chooseFile(filePathFromProjectRoot);
-
- if (options.acceptDialog !== undefined) {
- const loadDialog = new LoadDialog(this.page, options.acceptDialog);
- await loadDialog.submit();
- }
}
+ /**
+ * Drops a file on a drop target. The default is the editor's; the home and
+ * projects pages have their own.
+ */
async dropFile(
filePathFromProjectRoot: string,
- options: { acceptDialog?: LoadDialogType } = {}
+ target: string = "project-drop-target"
) {
const filePath = getAbsoluteFilePath(filePathFromProjectRoot);
const filename = getFilename(filePathFromProjectRoot);
- // Wait for page to load
- await this.saveButton.waitFor();
+ if (target === "project-drop-target") {
+ // Wait for page to load
+ await this.saveButton.waitFor();
+ }
// Playwright drag and drop file method taken from
// https://github.com/microsoft/playwright/issues/10667#issuecomment-998397241
@@ -403,17 +363,25 @@ export class App {
// Drag file over target area to reveal drop zone
await this.page
- .getByTestId("project-drop-target")
+ .getByTestId(target)
.dispatchEvent("dragover", { dataTransfer });
- const dropZone = this.page.getByTestId("project-drop-target-overlay");
+ const dropZone = this.page.getByTestId(`${target}-overlay`);
await dropZone.waitFor();
await dropZone.dispatchEvent("drop", { dataTransfer });
+ }
- if (options.acceptDialog !== undefined) {
- const loadDialog = new LoadDialog(this.page, options.acceptDialog);
- await loadDialog.submit();
- }
+ /** No drop overlay left showing after a drop. */
+ async expectNoDropOverlay(): Promise {
+ await expect(this.page.locator('[data-testid$="-overlay"]')).toHaveCount(0);
+ }
+
+ /** Answers the open confirmation dialog. */
+ async answerDialog(buttonName: string): Promise {
+ await this.page
+ .getByRole("alertdialog")
+ .getByRole("button", { name: buttonName })
+ .click();
}
async expectAlertText(title: string, description?: string): Promise {
@@ -424,19 +392,19 @@ export class App {
}
async isDeleteFileOptionDisabled(filename: string) {
- await this.switchTab("Project");
+ await this.switchTab("Files");
const fileOptionMenu = await this.projectTab.openFileActionsMenu(filename);
return await fileOptionMenu.deleteButton.isDisabled();
}
async isEditFileOptionDisabled(filename: string) {
- await this.switchTab("Project");
+ await this.switchTab("Files");
const fileOptionMenu = await this.projectTab.openFileActionsMenu(filename);
return await fileOptionMenu.editButton.isDisabled();
}
async editFile(filename: string): Promise {
- await this.switchTab("Project");
+ await this.switchTab("Files");
const fileOptionMenu = await this.projectTab.openFileActionsMenu(filename);
await fileOptionMenu.editButton.click();
}
@@ -481,7 +449,7 @@ export class App {
}
async deleteFile(filename: string) {
- await this.switchTab("Project");
+ await this.switchTab("Files");
const fileOptionMenu = await this.projectTab.openFileActionsMenu(filename);
await fileOptionMenu.delete();
}
@@ -495,19 +463,28 @@ export class App {
await this.page.getByRole("button", { name: "Close" }).click();
}
- async closeAndExpectBeforeUnloadDialogVisible(
- visible: boolean
- ): Promise {
- if (visible) {
- this.page.on("dialog", async (dialog) => {
- expect(dialog.type() === "beforeunload").toEqual(visible);
-
- // Though https://playwright.dev/docs/api/class-page#page-event-dialog
- // says that dialog.dismiss() is needed otherwise the page will freeze,
- // in practice, it appears that the dialog is dismissed automatically.
- });
- }
+ async closeWithoutBeforeUnloadPrompt(): Promise {
+ expect(await this.closeAndCollectDialogs()).not.toContain("beforeunload");
+ }
+
+ async closeAndExpectBeforeUnloadPrompt(): Promise {
+ expect(await this.closeAndCollectDialogs()).toContain("beforeunload");
+ }
+
+ /**
+ * Playwright accepts a beforeunload dialog itself if nobody listens, so
+ * listen to see it. Any dialog is handled before the page can close.
+ */
+ private async closeAndCollectDialogs(): Promise {
+ const dialogs: string[] = [];
+ this.page.on("dialog", async (dialog) => {
+ dialogs.push(dialog.type());
+ await dialog.accept();
+ });
+ const closed = this.page.waitForEvent("close");
await this.page.close({ runBeforeUnload: true });
+ await closed;
+ return dialogs;
}
async expectDocumentationTopLevelHeading(
@@ -765,11 +742,22 @@ export class App {
}
async expectFocusOnLoad(): Promise {
- const link = this.page.getByLabel(
- "visit microbit.org (opens in a new tab)"
- );
+ // The logo's home link leads the sidebar header.
await this.page.keyboard.press("Tab");
- await expect(link).toBeFocused();
+ await expect(this.homeLink).toBeFocused();
+ }
+
+ private get homeLink() {
+ return this.page.getByRole("link", { name: "Home" });
+ }
+
+ /**
+ * Follows the sidebar logo to the home page. Only available with the
+ * projects database active outside iframe mode; otherwise the logo links
+ * to microbit.org.
+ */
+ async goHome(): Promise {
+ await this.homeLink.click();
}
async assertFocusOnSidebar(): Promise {
@@ -818,12 +806,38 @@ export const getFilename = (filePath: string) => {
return filename;
};
-const getAbsoluteFilePath = (filePathFromProjectRoot: string) => {
+export const getAbsoluteFilePath = (filePathFromProjectRoot: string) => {
const dir = path.dirname(fileURLToPath(import.meta.url));
return path.join(dir.replace("src/e2e", ""), filePathFromProjectRoot);
};
-const optionsToURL = (options: UrlOptions): string => {
+export const editorUrl = (options: UrlOptions = {}): string =>
+ // In controller mode the editor is the only page and stays at the root.
+ appUrl(options.controller ? "" : "project", options);
+
+export const homeUrl = (options: UrlOptions = {}): string =>
+ appUrl("", options);
+
+export const projectsPageUrl = (options: UrlOptions = {}): string =>
+ appUrl("projects", options);
+
+/**
+ * @param path The page's path within the app, without a leading slash.
+ */
+/**
+ * Matches a page's URL, wherever the app is deployed. The base URL's trailing
+ * slash is optional: the app's own links to the home page are without it.
+ *
+ * @param path The page's path within the app, without a leading slash.
+ */
+export const appUrlPattern = (path: string = ""): RegExp => {
+ const prefix = (baseUrl + basePath + path).replace(/\/$/, "");
+ return new RegExp(
+ `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/?([?#]|$)`
+ );
+};
+
+export const appUrl = (path: string, options: UrlOptions = {}): string => {
const flags = new Set([
"none",
"noWelcome",
@@ -836,11 +850,13 @@ const optionsToURL = (options: UrlOptions): string => {
if (options.language) {
params.push(["l", options.language]);
}
+ if (options.controller) {
+ params.push(["controller", "1"]);
+ }
return (
baseUrl +
- // We didn't use BASE_URL here as CRA seems to set it to "" before running jest.
- // Maybe can be changed since the Vite upgrade.
- (process.env.E2E_BASE_URL ?? "/") +
+ basePath +
+ path +
"?" +
new URLSearchParams(params).toString() +
(options.fragment ?? "")
diff --git a/src/e2e/edits.test.ts b/src/e2e/edits.test.ts
index 356dcf528..e73aea805 100644
--- a/src/e2e/edits.test.ts
+++ b/src/e2e/edits.test.ts
@@ -7,25 +7,29 @@ import { test } from "./app-test-fixtures.js";
test.describe("edits", () => {
test("doesn't prompt on close if no edits made", async ({ app }) => {
- await app.closeAndExpectBeforeUnloadDialogVisible(false);
+ await app.closeWithoutBeforeUnloadPrompt();
});
- test("prompts on close if file edited", async ({ app }) => {
+ test("doesn't prompt on close if file edited, as the project is saved", async ({
+ app,
+ }) => {
await app.typeInEditor("A change!");
await app.expectEditorContainText(/A change/);
- await app.closeAndExpectBeforeUnloadDialogVisible(true);
+ await app.closeWithoutBeforeUnloadPrompt();
});
- test("prompts on close if project name edited", async ({ app }) => {
+ test("doesn't prompt on close if project name edited, as the project is saved", async ({
+ app,
+ }) => {
const name = "idiosyncratic ruminant";
await app.setProjectName(name);
await app.expectProjectName(name);
- await app.closeAndExpectBeforeUnloadDialogVisible(true);
+ await app.closeWithoutBeforeUnloadPrompt();
});
- test("retains text across a reload via session storage", async ({ app }) => {
+ test("retains text across a reload", async ({ app }) => {
await app.typeInEditor("A change!");
await app.expectEditorContainText(/A change/);
@@ -33,4 +37,15 @@ test.describe("edits", () => {
await app.expectEditorContainText(/A change/);
});
+
+ test("retains text across a reload straight after typing", async ({
+ app,
+ }) => {
+ // Writes are coalesced for a few hundred milliseconds, so this relies on
+ // the pending ones being flushed as the page unloads.
+ await app.typeInEditor("A change!");
+ await app.page.reload();
+
+ await app.expectEditorContainText(/A change/);
+ });
});
diff --git a/src/e2e/home-page.ts b/src/e2e/home-page.ts
new file mode 100644
index 000000000..78d404598
--- /dev/null
+++ b/src/e2e/home-page.ts
@@ -0,0 +1,127 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { expect, Locator, Page } from "@playwright/test";
+import { appUrlPattern, getAbsoluteFilePath, homeUrl } from "./app.js";
+
+/**
+ * The modal dialog rather than a menu popover, which also has the dialog
+ * role. Modals render on a section; popovers are divs.
+ */
+export const modalDialog = (page: Page): Locator =>
+ page.getByRole("dialog").and(page.locator("section"));
+
+/**
+ * The project cards and their menus, shared by the home and projects pages.
+ */
+export class ProjectCards {
+ constructor(public readonly page: Page) {}
+
+ card(name: string): Locator {
+ return this.page.getByRole("button", { name, exact: true });
+ }
+
+ async open(name: string): Promise {
+ await this.card(name).click();
+ }
+
+ async expectVisible(name: string): Promise {
+ await expect(this.card(name)).toBeVisible();
+ }
+
+ async expectNotVisible(name: string): Promise {
+ await expect(this.card(name)).toBeHidden();
+ }
+
+ async openMenu(name: string): Promise {
+ await this.page
+ .getByRole("button", { name: `${name} actions menu`, exact: true })
+ .click();
+ }
+
+ async menuOpen(name: string): Promise {
+ await this.openMenu(name);
+ await this.page.getByRole("menuitem", { name: "Open" }).click();
+ }
+
+ async menuRename(name: string, newName: string): Promise {
+ await this.openMenu(name);
+ await this.page.getByRole("menuitem", { name: "Rename" }).click();
+ await this.fillNameDialog(newName, "Rename");
+ }
+
+ async menuDuplicate(name: string, newName: string): Promise {
+ await this.openMenu(name);
+ await this.page.getByRole("menuitem", { name: "Duplicate" }).click();
+ await this.fillNameDialog(newName, "Duplicate");
+ }
+
+ async menuDelete(name: string): Promise {
+ await this.openMenu(name);
+ await this.page.getByRole("menuitem", { name: "Delete" }).click();
+ await this.confirmDelete();
+ }
+
+ async fillNameDialog(newName: string, confirmLabel: string): Promise {
+ const dialog = modalDialog(this.page);
+ await expect(dialog).toBeVisible();
+ const nameInput = dialog.getByRole("textbox");
+ await nameInput.fill(newName);
+ await dialog.getByRole("button", { name: confirmLabel }).click();
+ await expect(dialog).toBeHidden();
+ }
+
+ async confirmDelete(): Promise {
+ const dialog = this.page.getByRole("alertdialog");
+ await expect(dialog).toBeVisible();
+ // "Delete", or "Delete N projects" for a selection.
+ await dialog.getByRole("button", { name: /^Delete/ }).click();
+ await expect(dialog).toBeHidden();
+ }
+}
+
+export class HomePage {
+ public cards: ProjectCards;
+ private projectsHeading: Locator;
+
+ constructor(public readonly page: Page) {
+ this.cards = new ProjectCards(page);
+ this.projectsHeading = page.getByRole("heading", { name: "My projects" });
+ }
+
+ async goto(): Promise {
+ await this.page.goto(homeUrl());
+ await this.expectOnPage();
+ }
+
+ async expectOnPage(): Promise {
+ await expect(this.projectsHeading).toBeVisible();
+ await expect(this.page).toHaveURL(appUrlPattern());
+ }
+
+ /** Creates a project, accepting the default name unless one is given. */
+ async newProject(name?: string): Promise {
+ await this.page.getByRole("button", { name: "New project" }).click();
+ if (name !== undefined) {
+ await this.cards.fillNameDialog(name, "Create");
+ } else {
+ await modalDialog(this.page)
+ .getByRole("button", { name: "Create" })
+ .click();
+ }
+ }
+
+ /** Imports a file from the projects row as a new project. */
+ async importFile(filePathFromProjectRoot: string): Promise {
+ const fileChooserPromise = this.page.waitForEvent("filechooser");
+ await this.page.getByRole("button", { name: "Import" }).click();
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(getAbsoluteFilePath(filePathFromProjectRoot));
+ }
+
+ async viewAllProjects(): Promise {
+ await this.page.getByRole("link", { name: "View all" }).click();
+ }
+}
diff --git a/src/e2e/home.test.ts b/src/e2e/home.test.ts
new file mode 100644
index 000000000..cc4d57be5
--- /dev/null
+++ b/src/e2e/home.test.ts
@@ -0,0 +1,149 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { expect } from "@playwright/test";
+import { appUrlPattern } from "./app.js";
+import { test } from "./app-test-fixtures.js";
+
+test.describe("home page", () => {
+ // The app fixture sets up the context; these tests start from home.
+ test.use({ autoGoto: false });
+
+ test("shows the banner and the resource rows", async ({ homePage }) => {
+ await homePage.goto();
+ await expect(
+ homePage.page.getByRole("heading", {
+ name: "Python for the BBC micro:bit",
+ })
+ ).toBeVisible();
+ for (const row of ["Project ideas", "Teacher resources", "Help"]) {
+ await expect(
+ homePage.page.getByRole("heading", { name: row })
+ ).toBeVisible();
+ }
+ });
+
+ test("creates a project and opens it in the editor", async ({
+ app,
+ homePage,
+ }) => {
+ await homePage.goto();
+ await homePage.newProject("Night light");
+
+ await app.expectProjectName("Night light");
+ await expect(app.page).toHaveURL(appUrlPattern("project"));
+ await app.expectEditorContainText("from microbit import");
+ });
+
+ test("returns home from the editor with the project listed", async ({
+ app,
+ homePage,
+ }) => {
+ await homePage.goto();
+ await homePage.newProject("Night light");
+ await app.expectProjectName("Night light");
+
+ await app.goHome();
+
+ await homePage.expectOnPage();
+ await homePage.cards.expectVisible("Night light");
+ });
+
+ test("reopens a project from its card with the edits kept", async ({
+ app,
+ homePage,
+ }) => {
+ await homePage.goto();
+ await homePage.newProject("Night light");
+ await app.expectProjectName("Night light");
+ await app.typeInEditor("# a change");
+ await app.expectEditorContainText("# a change");
+ await app.goHome();
+ await homePage.expectOnPage();
+ await homePage.newProject("Other");
+ await app.expectProjectName("Other");
+ await app.goHome();
+ await homePage.expectOnPage();
+
+ await homePage.cards.open("Night light");
+
+ await app.expectProjectName("Night light");
+ await app.expectEditorContainText("# a change");
+ });
+
+ test("renames, duplicates and deletes from the card menu", async ({
+ app,
+ homePage,
+ }) => {
+ await homePage.goto();
+ await homePage.newProject("Night light");
+ await app.expectProjectName("Night light");
+ await app.goHome();
+ await homePage.expectOnPage();
+
+ await homePage.cards.menuRename("Night light", "Day light");
+ await homePage.cards.expectVisible("Day light");
+ await homePage.cards.expectNotVisible("Night light");
+
+ await homePage.cards.menuDuplicate("Day light", "Copy of Day light");
+ await homePage.cards.expectVisible("Copy of Day light");
+ await homePage.cards.expectVisible("Day light");
+
+ await homePage.cards.menuDelete("Day light");
+ await homePage.cards.expectNotVisible("Day light");
+ await homePage.cards.expectVisible("Copy of Day light");
+ });
+
+ test("imports a hex as a new project", async ({ app, homePage }) => {
+ await homePage.goto();
+ await homePage.importFile("testData/1.0.1.hex");
+
+ await app.expectProjectName("1.0.1");
+ await app.expectEditorContainText(/PASS1/);
+ await app.goHome();
+ await homePage.cards.expectVisible("1.0.1");
+ });
+
+ test("imports a Python script as a new project named after it", async ({
+ app,
+ homePage,
+ }) => {
+ await homePage.goto();
+ await homePage.importFile("testData/samplefile.py");
+
+ await app.expectProjectName("samplefile");
+ await app.expectProjectFiles(["main.py"]);
+ });
+
+ test("a dropped hex opens as a new project with no overlay left", async ({
+ app,
+ homePage,
+ }) => {
+ await homePage.goto();
+ await app.dropFile("testData/1.0.1.hex", "home-drop-target");
+
+ await app.expectProjectName("1.0.1");
+ await app.expectNoDropOverlay();
+ });
+
+ test("keeps projects across a reload", async ({ app, homePage }) => {
+ await homePage.goto();
+ await homePage.newProject("Night light");
+ await app.expectProjectName("Night light");
+ await app.goHome();
+ await homePage.expectOnPage();
+
+ await homePage.page.reload();
+
+ await homePage.expectOnPage();
+ await homePage.cards.expectVisible("Night light");
+ });
+
+ test("links to the projects page", async ({ homePage, projectsPage }) => {
+ await homePage.goto();
+ await homePage.viewAllProjects();
+ await projectsPage.expectOnPage();
+ });
+});
diff --git a/src/e2e/iframe.test.ts b/src/e2e/iframe.test.ts
new file mode 100644
index 000000000..e2f87707e
--- /dev/null
+++ b/src/e2e/iframe.test.ts
@@ -0,0 +1,142 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { expect, FrameLocator, Page } from "@playwright/test";
+import { editorUrl } from "./app.js";
+import { test } from "./app-test-fixtures.js";
+
+interface EditorMessage {
+ type: "pyeditor";
+ action: string;
+ project?: { files: Record };
+}
+
+const hostCode = "display.scroll('from the host')";
+
+/**
+ * The host page is served by intercepting a request for it and is defined
+ * here, in the tests, so it is never part of the app.
+ */
+const hostPage = (editorSrc: string) => `
+Iframe host harness
+
+
+`;
+
+const hostUrl = new URL("e2e-iframe-host.html", editorUrl()).href;
+
+const openEmbeddedEditor = async (page: Page): Promise => {
+ await page.route(hostUrl, (route) =>
+ route.fulfill({
+ contentType: "text/html",
+ body: hostPage(editorUrl({ controller: true })),
+ })
+ );
+ await page.goto(hostUrl);
+ const frame = page.frameLocator("iframe[name='editor']");
+ await frame.getByTestId("editor").waitFor();
+ return frame;
+};
+
+const editorText = (frame: FrameLocator) =>
+ frame.getByTestId("editor").getByRole("textbox");
+
+// fill() on the CodeMirror contenteditable sometimes inserts rather than
+// replaces, so select everything first.
+const replaceEditorText = async (
+ page: Page,
+ frame: FrameLocator,
+ text: string
+) => {
+ await editorText(frame).click();
+ await page.keyboard.press(
+ process.platform === "darwin" ? "Meta+a" : "Control+a"
+ );
+ await page.keyboard.type(text);
+};
+
+// Set by the host harness page. Evaluate callbacks run in the page, so the
+// cast has to be repeated inside each rather than shared.
+const receivedActions = (page: Page) =>
+ page.evaluate(() =>
+ (window as unknown as { messages: EditorMessage[] }).messages.map(
+ (m) => m.action
+ )
+ );
+
+const lastSavedMain = (page: Page) =>
+ page.evaluate(() => {
+ const { messages } = window as unknown as { messages: EditorMessage[] };
+ const saves = messages.filter((m) => m.action === "workspacesave");
+ const encoded = saves[saves.length - 1]?.project?.files["main.py"];
+ return encoded === undefined ? undefined : atob(encoded);
+ });
+
+/**
+ * Controller mode, as embedded by classroom: the host owns the project and
+ * there is no storage or project management in the editor.
+ */
+test.describe("iframe controller mode", () => {
+ test.use({ autoGoto: false });
+
+ test("loads the host's project and reports back edits", async ({ app }) => {
+ const frame = await openEmbeddedEditor(app.page);
+
+ await expect(editorText(frame)).toContainText("from the host");
+ expect(await receivedActions(app.page)).toEqual([
+ "workspacesync",
+ "workspaceloaded",
+ ]);
+
+ await replaceEditorText(app.page, frame, "display.scroll('edited')");
+ await expect
+ .poll(() => lastSavedMain(app.page))
+ .toEqual("display.scroll('edited')");
+ });
+
+ test("replaces the project when the host imports one", async ({ app }) => {
+ const frame = await openEmbeddedEditor(app.page);
+ await expect(editorText(frame)).toContainText("from the host");
+
+ await app.page.evaluate(() => {
+ const editor = document.querySelector("iframe")!.contentWindow!;
+ editor.postMessage(
+ {
+ type: "pyeditor",
+ action: "importproject",
+ project: "display.scroll('imported')",
+ },
+ "*"
+ );
+ });
+
+ await expect(editorText(frame)).toContainText("imported");
+ });
+
+ test("prompts on close if file edited, as the host may not have saved", async ({
+ app,
+ }) => {
+ const frame = await openEmbeddedEditor(app.page);
+ await expect(editorText(frame)).toContainText("from the host");
+
+ await replaceEditorText(app.page, frame, "display.scroll('edited')");
+ await expect(editorText(frame)).toContainText("edited");
+
+ await app.closeAndExpectBeforeUnloadPrompt();
+ });
+});
diff --git a/src/e2e/migration.test.ts b/src/e2e/migration.test.ts
index 8bae53368..7ecc8aa78 100644
--- a/src/e2e/migration.test.ts
+++ b/src/e2e/migration.test.ts
@@ -12,7 +12,7 @@ const sunlightSensorMigrationFragment =
"#project:XQAAgAByAQAAAAAAAAA9iImmlGSt1R++5LD+ZJ36cRz46B+lhYtNRoWF0nijpaVyZlK7ACfSpeoQpgfk21st4ty06R4PEOW6kOsIEMK7SL0Qco7jgsHFKZXfjv/XcHWvXG9qyz1a/a3NUulFDj/FDJxVAIV+WZLpRoo4E6MbW70FOgIfBPWP2hDVsojpoLc7ZfKI8SHxv54FSfB5bkbzaAKO+8CO73t6Odtv691JGjJ9MExFighY6GxyM/DoNInDDpAjFeaqCWrYdwENX7ZVM3we8f4swI71tL28N7sg588aB//A78AA";
test.describe("migration", () => {
- test("Loads the project from the URL", async ({ app }) => {
+ test("Loads the project from the URL", async ({ app, homePage }) => {
await app.goto({ fragment: heartMigrationFragment });
await app.page.reload();
await app.expectProjectName("Hearts");
@@ -29,5 +29,9 @@ test.describe("migration", () => {
// wait for page to load
await app.saveButton.waitFor();
await app.expectEditorContainText("display.read_light_level");
+
+ // Each link made a project rather than replacing the last.
+ await app.goHome();
+ await homePage.cards.expectVisible("Hearts");
});
});
diff --git a/src/e2e/multiple-files.test.ts b/src/e2e/multiple-files.test.ts
index a33f94bfa..fcdbe0a5d 100644
--- a/src/e2e/multiple-files.test.ts
+++ b/src/e2e/multiple-files.test.ts
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: MIT
*/
import { expect } from "@playwright/test";
-import { LoadDialogType } from "./app.js";
import { test } from "./app-test-fixtures.js";
test.describe("multiple-files", () => {
@@ -30,23 +29,18 @@ test.describe("multiple-files", () => {
});
test("Copes with non-main file being updated", async ({ app }) => {
- await app.loadFiles("testData/usermodule.py", {
- acceptDialog: LoadDialogType.CONFIRM_BUT_LOAD_AS_MODULE,
- });
+ await app.loadFiles("testData/usermodule.py");
await app.editFile("usermodule.py");
await app.expectEditorContainText(/b_works/);
- await app.loadFiles("testData/updated/usermodule.py", {
- acceptDialog: LoadDialogType.CONFIRM_BUT_LOAD_AS_MODULE,
- });
+ await app.loadFiles("testData/updated/usermodule.py");
+ await app.answerDialog("Replace");
await app.expectEditorContainText(/c_works/);
});
test("Shows warning for third-party module", async ({ app }) => {
- await app.loadFiles("testData/module.py", {
- acceptDialog: LoadDialogType.CONFIRM,
- });
+ await app.loadFiles("testData/module.py");
await app.editFile("module.py");
await app.expectThirdPartModuleWarning("a", "1.0.0");
@@ -57,16 +51,13 @@ test.describe("multiple-files", () => {
await app.toggleSettingThirdPartyModuleEditing();
}
- await app.loadFiles("testData/updated/module.py", {
- acceptDialog: LoadDialogType.CONFIRM,
- });
+ await app.loadFiles("testData/updated/module.py");
+ await app.answerDialog("Replace");
await app.expectThirdPartModuleWarning("a", "1.1.0");
});
test("Copes with currently open file being deleted", async ({ app }) => {
- await app.loadFiles("testData/module.py", {
- acceptDialog: LoadDialogType.CONFIRM,
- });
+ await app.loadFiles("testData/module.py");
await app.editFile("module.py");
await app.deleteFile("module.py");
await app.expectEditorContainText(/Hello/);
diff --git a/src/e2e/open.test.ts b/src/e2e/open.test.ts
index fb55d8b10..723be8ef7 100644
--- a/src/e2e/open.test.ts
+++ b/src/e2e/open.test.ts
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: MIT
*/
import { expect } from "@playwright/test";
-import { LoadDialogType } from "./app.js";
import { test } from "./app-test-fixtures.js";
test.describe("open", () => {
@@ -17,19 +16,31 @@ test.describe("open", () => {
);
});
- test("Loads a Python file", async ({ app }) => {
- await app.loadFiles("testData/samplefile.py", {
- acceptDialog: LoadDialogType.CONFIRM,
- });
+ test("Adds a Python file to the project", async ({ app }) => {
+ await app.loadFiles("testData/samplefile.py");
- await app.expectAlertText("Updated file main.py");
+ await app.expectAlertText("Added file samplefile.py");
+ await app.expectProjectFiles(["main.py", "samplefile.py"]);
await app.expectProjectName("Untitled project");
});
+ test("Asks before replacing a file with the same name", async ({ app }) => {
+ await app.loadFiles("testData/samplefile.py");
+ await app.expectAlertText("Added file samplefile.py");
+
+ await app.loadFiles("testData/samplefile.py");
+ await app.expectDialog("Replace existing files?");
+ await app.answerDialog("Cancel");
+ await app.expectProjectFiles(["main.py", "samplefile.py"]);
+
+ await app.loadFiles("testData/samplefile.py");
+ await app.expectDialog("Replace existing files?");
+ await app.answerDialog("Replace");
+ await app.expectAlertText("Updated file samplefile.py");
+ });
+
test("Correctly handles a hex that's actually Python", async ({ app }) => {
- await app.loadFiles("testData/not-a-hex.hex", {
- acceptDialog: LoadDialogType.NONE,
- });
+ await app.loadFiles("testData/not-a-hex.hex");
await app.expectAlertText(
"Cannot load file",
@@ -39,31 +50,38 @@ test.describe("open", () => {
);
});
- test("Loads a v1.0.1 hex file", async ({ app }) => {
+ test("Opens a v1.0.1 hex file as a new project", async ({
+ app,
+ homePage,
+ }) => {
+ await app.typeInEditor("# Keep me");
await app.loadFiles("testData/1.0.1.hex");
await app.expectEditorContainText(/PASS1/);
await app.expectProjectName("1.0.1");
+
+ // The previous project is untouched.
+ await app.goHome();
+ await homePage.cards.expectVisible("1.0.1");
+ await homePage.cards.open("Untitled project");
+ await app.expectEditorContainText("# Keep me");
});
- test("Loads a v0.9 hex file", async ({ app }) => {
+ test("Opens a v0.9 hex file as a new project", async ({ app }) => {
await app.loadFiles("testData/0.9.hex");
await app.expectEditorContainText(/PASS2/);
await app.expectProjectName("0.9");
});
- test("Loads via drag and drop", async ({ app }) => {
+ test("Opens a hex via drag and drop", async ({ app }) => {
await app.dropFile("testData/1.0.1.hex");
await app.expectProjectName("1.0.1");
- // await app.findVisibleEditorContents(/PASS1/);
});
test("Correctly handles an mpy file", async ({ app }) => {
- await app.loadFiles("testData/samplempyfile.mpy", {
- acceptDialog: LoadDialogType.NONE,
- });
+ await app.loadFiles("testData/samplempyfile.mpy");
await app.expectAlertText(
"Cannot load file",
@@ -74,9 +92,7 @@ test.describe("open", () => {
test("Correctly handles a file with an invalid extension", async ({
app,
}) => {
- await app.loadFiles("testData/sampletxtfile.txt", {
- acceptDialog: LoadDialogType.CONFIRM,
- });
+ await app.loadFiles("testData/sampletxtfile.txt");
expect(await app.isEditFileOptionDisabled("sampletxtfile.txt")).toEqual(
true
@@ -86,60 +102,12 @@ test.describe("open", () => {
test("Correctly imports modules with the 'magic comment' in the filesystem.", async ({
app,
}) => {
- await app.loadFiles("testData/module.py", {
- acceptDialog: LoadDialogType.CONFIRM,
- });
+ await app.loadFiles("testData/module.py");
await app.expectAlertText("Added file module.py");
- await app.loadFiles("testData/module.py", {
- acceptDialog: LoadDialogType.CONFIRM,
- });
+ await app.loadFiles("testData/module.py");
+ await app.answerDialog("Replace");
await app.expectAlertText("Updated file module.py");
});
-
- test("Warns before load if you have changes", async ({ app }) => {
- await app.typeInEditor("# Different text");
- await app.loadFiles("testData/1.0.1.hex", {
- acceptDialog: LoadDialogType.REPLACE,
- });
- await app.expectEditorContainText(/PASS1/);
- await app.expectProjectName("1.0.1");
- });
-
- test("No warn before load if you save hex", async ({ app }) => {
- await app.setProjectName("Avoid dialog");
- await app.typeInEditor("# Different text");
- await app.save();
- await app.closeDialog("Project saved");
-
- // No dialog accepted
- await app.loadFiles("testData/1.0.1.hex");
- await app.expectEditorContainText(/PASS1/);
- });
-
- test("No warn before load if you save main file", async ({ app }) => {
- await app.setProjectName("Avoid dialog");
- await app.typeInEditor("# Different text");
- await app.savePythonScript();
-
- // No dialog accepted
- await app.loadFiles("testData/1.0.1.hex");
- await app.expectEditorContainText(/PASS1/);
- });
-
- test("Warn before load if you save main file only and you have others", async ({
- app,
- }) => {
- await app.setProjectName("Avoid dialog");
- await app.typeInEditor("# Different text");
- await app.createNewFile("another");
- await app.savePythonScript();
- await app.closeDialog("Warning: Only main.py downloaded");
-
- await app.loadFiles("testData/1.0.1.hex", {
- acceptDialog: LoadDialogType.REPLACE,
- });
- await app.expectEditorContainText(/PASS1/);
- });
});
diff --git a/src/e2e/projects-page.ts b/src/e2e/projects-page.ts
new file mode 100644
index 000000000..7a85aefe9
--- /dev/null
+++ b/src/e2e/projects-page.ts
@@ -0,0 +1,122 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { expect, Locator, Page } from "@playwright/test";
+import { appUrlPattern, projectsPageUrl } from "./app.js";
+import { ProjectCards } from "./home-page.js";
+
+export class ProjectsPage {
+ public cards: ProjectCards;
+ private heading: Locator;
+ private searchInput: Locator;
+ private toolbar: Locator;
+
+ constructor(public readonly page: Page) {
+ this.cards = new ProjectCards(page);
+ this.heading = page.getByRole("heading", { name: "My projects" });
+ this.searchInput = page.getByRole("searchbox", { name: "Search" });
+ // Two toolbars are in the DOM, for wide and narrow layouts; only one is
+ // shown, so act on the visible one.
+ this.toolbar = page
+ .getByRole("group", { name: "Selection actions" })
+ .locator("visible=true");
+ }
+
+ async goto(): Promise {
+ await this.page.goto(projectsPageUrl());
+ await this.expectOnPage();
+ }
+
+ async expectOnPage(): Promise {
+ await expect(this.heading).toBeVisible();
+ await expect(this.page).toHaveURL(appUrlPattern("projects"));
+ }
+
+ async goHome(): Promise {
+ await this.page.getByRole("button", { name: "Home" }).click();
+ }
+
+ async expectProjectCount(count: number): Promise {
+ await expect(this.page.getByRole("checkbox")).toHaveCount(count);
+ }
+
+ async expectNoProjects(): Promise {
+ await expect(this.page.getByText("No projects to display")).toBeVisible();
+ }
+
+ async expectProjectOrder(names: string[]): Promise {
+ const checkboxes = this.page.getByRole("checkbox");
+ await expect(checkboxes).toHaveCount(names.length);
+ for (let i = 0; i < names.length; i++) {
+ await expect(checkboxes.nth(i)).toHaveAccessibleName(
+ `Select ${names[i]}`
+ );
+ }
+ }
+
+ async search(query: string): Promise {
+ await this.searchInput.fill(query);
+ }
+
+ async clearSearch(): Promise {
+ await this.page.getByRole("button", { name: "Clear" }).click();
+ }
+
+ async select(name: string): Promise {
+ const checkbox = this.page.getByRole("checkbox", {
+ name: `Select ${name}`,
+ exact: true,
+ });
+ await checkbox.check({ force: true });
+ await expect(checkbox).toBeChecked();
+ }
+
+ async expectToolbarVisible(): Promise {
+ await expect(this.toolbar).toBeVisible();
+ }
+
+ async expectToolbarHidden(): Promise {
+ await expect(
+ this.page.getByRole("group", { name: "Selection actions" })
+ ).toHaveCount(0);
+ }
+
+ async expectToolbarButtons(names: string[]): Promise {
+ const buttons = this.toolbar.getByRole("button");
+ await expect(buttons).toHaveCount(names.length);
+ for (const name of names) {
+ await expect(this.toolbar.getByRole("button", { name })).toBeVisible();
+ }
+ }
+
+ async toolbarRename(newName: string): Promise {
+ await this.toolbar.getByRole("button", { name: "Rename" }).click();
+ await this.cards.fillNameDialog(newName, "Rename");
+ }
+
+ async toolbarDuplicate(newName: string): Promise {
+ await this.toolbar.getByRole("button", { name: "Duplicate" }).click();
+ await this.cards.fillNameDialog(newName, "Duplicate");
+ }
+
+ async toolbarDelete(): Promise {
+ await this.toolbar.getByRole("button", { name: /^Delete/ }).click();
+ await this.cards.confirmDelete();
+ }
+
+ async toolbarClear(): Promise {
+ await this.toolbar.getByRole("button", { name: "Clear" }).click();
+ }
+
+ async sortBy(field: "Name" | "Last modified"): Promise {
+ await this.page
+ .getByRole("combobox", { name: "Sort projects" })
+ .selectOption({ label: field });
+ }
+
+ async toggleSortDirection(): Promise {
+ await this.page.getByRole("button", { name: /order$/ }).click();
+ }
+}
diff --git a/src/e2e/projects.test.ts b/src/e2e/projects.test.ts
new file mode 100644
index 000000000..c7505700e
--- /dev/null
+++ b/src/e2e/projects.test.ts
@@ -0,0 +1,125 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { test } from "./app-test-fixtures.js";
+
+test.describe("projects page", () => {
+ test.use({ autoGoto: false });
+
+ test("shows a message when there are no projects", async ({
+ projectsPage,
+ }) => {
+ await projectsPage.goto();
+ await projectsPage.expectNoProjects();
+ await projectsPage.expectToolbarHidden();
+ });
+
+ test("goes back home", async ({ homePage, projectsPage }) => {
+ await projectsPage.goto();
+ await projectsPage.goHome();
+ await homePage.expectOnPage();
+ });
+
+ test.describe("with projects", () => {
+ test.beforeEach(async ({ app, homePage, projectsPage }) => {
+ await homePage.goto();
+ for (const name of ["Alpha", "Beta"]) {
+ await homePage.newProject(name);
+ await app.expectProjectName(name);
+ await app.goHome();
+ await homePage.expectOnPage();
+ }
+ await projectsPage.goto();
+ await projectsPage.expectProjectCount(2);
+ });
+
+ test("lists most recent first and sorts by name", async ({
+ projectsPage,
+ }) => {
+ await projectsPage.expectProjectOrder(["Beta", "Alpha"]);
+ await projectsPage.sortBy("Name");
+ await projectsPage.expectProjectOrder(["Alpha", "Beta"]);
+ await projectsPage.toggleSortDirection();
+ await projectsPage.expectProjectOrder(["Beta", "Alpha"]);
+ });
+
+ test("searches by name", async ({ projectsPage }) => {
+ await projectsPage.search("alp");
+ await projectsPage.expectProjectOrder(["Alpha"]);
+ await projectsPage.search("nothing here");
+ await projectsPage.expectNoProjects();
+ await projectsPage.clearSearch();
+ await projectsPage.expectProjectCount(2);
+ });
+
+ test("a dropped hex becomes a new project open in the editor", async ({
+ app,
+ projectsPage,
+ }) => {
+ await app.dropFile("testData/1.0.1.hex", "projects-drop-target");
+ await app.expectProjectName("1.0.1");
+ await app.expectNoDropOverlay();
+ await app.goHome();
+ await projectsPage.goto();
+ await projectsPage.expectProjectCount(3);
+ });
+
+ test("a dropped Python file becomes a new project", async ({
+ app,
+ projectsPage,
+ }) => {
+ await app.dropFile("testData/samplefile.py", "projects-drop-target");
+ await app.expectProjectName("samplefile");
+ await app.expectProjectFiles(["main.py"]);
+ await projectsPage.goto();
+ await projectsPage.expectProjectCount(3);
+ });
+
+ test("opens a project from its menu", async ({ app, projectsPage }) => {
+ await projectsPage.cards.menuOpen("Alpha");
+ await app.expectProjectName("Alpha");
+ });
+
+ test("renames, duplicates and deletes from the toolbar", async ({
+ projectsPage,
+ }) => {
+ await projectsPage.select("Alpha");
+ await projectsPage.expectToolbarVisible();
+ await projectsPage.expectToolbarButtons([
+ "Rename",
+ "Duplicate",
+ "Delete",
+ "Clear",
+ ]);
+
+ await projectsPage.toolbarRename("Gamma");
+ await projectsPage.cards.expectVisible("Gamma");
+
+ await projectsPage.toolbarDuplicate("Gamma copy");
+ await projectsPage.expectProjectCount(3);
+
+ await projectsPage.toolbarClear();
+ await projectsPage.expectToolbarHidden();
+
+ await projectsPage.select("Gamma");
+ await projectsPage.select("Gamma copy");
+ await projectsPage.expectToolbarButtons(["Delete 2 projects", "Clear"]);
+ await projectsPage.toolbarDelete();
+ await projectsPage.expectProjectOrder(["Beta"]);
+ });
+
+ test("deletes the open project and the editor moves on", async ({
+ app,
+ projectsPage,
+ }) => {
+ // Beta was created last, so it is the tab's open project.
+ await projectsPage.cards.menuDelete("Beta");
+ await projectsPage.expectProjectOrder(["Alpha"]);
+
+ await app.goto();
+ await app.expectProjectName("Alpha");
+ });
+ });
+});
diff --git a/src/e2e/reset.test.ts b/src/e2e/reset.test.ts
deleted file mode 100644
index 2ccfd9cea..000000000
--- a/src/e2e/reset.test.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * (c) 2021, Micro:bit Educational Foundation and contributors
- *
- * SPDX-License-Identifier: MIT
- */
-import { test } from "./app-test-fixtures.js";
-
-test.describe("reset", () => {
- test("sets language via URL", async ({ app }) => {
- await app.setProjectName("My project");
- await app.selectAllInEditor();
- await app.typeInEditor("# Not the default starter code");
- await app.createNewFile("testing");
-
- await app.resetProject();
-
- // Everything's back to normal.
- await app.expectProjectName("Untitled project");
- await app.expectEditorContainText("from microbit import");
- await app.expectProjectFiles(["main.py"]);
- });
-});
diff --git a/src/e2e/routing.test.ts b/src/e2e/routing.test.ts
new file mode 100644
index 000000000..6532aeb65
--- /dev/null
+++ b/src/e2e/routing.test.ts
@@ -0,0 +1,63 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { expect } from "@playwright/test";
+import { appUrl, appUrlPattern, homeUrl } from "./app.js";
+import { test } from "./app-test-fixtures.js";
+
+const heartMigrationFragment =
+ "#project:XQAAgACRAAAAAAAAAAA9iImmlGSt1R++5LD+ZJ36cRz46B+lhYtNRoWF0nijpaVyZlK7ACfSpeoQpgfk21st4ty06R4PEOM4sSAXBT95G3en+tghrYmE+YJp6EiYgzA9ThKkyShWq2UdvmCzqxoNfYc1wlmTqlNv/Piaz3WoSe3flvr/ItyLl0aolQlEpv4LA8A=";
+
+test.describe("routing", () => {
+ test.use({ autoGoto: false });
+
+ test("redirects the editor's old documentation URLs", async ({ app }) => {
+ await app.page.goto(appUrl("reference/display"));
+ await expect(app.page).toHaveURL(
+ appUrlPattern("project/reference/display")
+ );
+ await expect(
+ app.page.getByRole("tab", { name: "Reference" })
+ ).toHaveAttribute("aria-selected", "true");
+ });
+
+ test("shows not found for an unknown page", async ({ app }) => {
+ await app.page.goto(appUrl("nonsense/whatever/else"));
+ await expect(
+ app.page.getByRole("heading", { name: "Page not found" })
+ ).toBeVisible();
+ });
+
+ // microbit.org links carry the v2 editor's #import: prefix; the editor's
+ // own share links are bare.
+ for (const [name, fragment] of [
+ ["a #project:", heartMigrationFragment],
+ ["an #import:", "#import:" + heartMigrationFragment],
+ ]) {
+ test(`${name} link at the root opens the program in the editor`, async ({
+ app,
+ }) => {
+ await app.page.goto(homeUrl({ fragment }));
+ await expect(app.page).toHaveURL(appUrlPattern("project"));
+ await app.expectProjectName("Hearts");
+ await app.expectEditorContainText("display.show(Image.HEART)");
+ });
+ }
+
+ test("a new tab at the editor opens the most recent project", async ({
+ app,
+ homePage,
+ }) => {
+ await homePage.goto();
+ await homePage.newProject("Most recent");
+ await app.expectProjectName("Most recent");
+
+ // Session storage is per tab, so a new page has no current project.
+ const other = await app.context.newPage();
+ const otherApp = new (await import("./app.js")).App(other, app.context);
+ await otherApp.goto();
+ await otherApp.expectProjectName("Most recent");
+ });
+});
diff --git a/src/e2e/save.test.ts b/src/e2e/save.test.ts
index cf2b3d074..941774a41 100644
--- a/src/e2e/save.test.ts
+++ b/src/e2e/save.test.ts
@@ -5,7 +5,6 @@
*/
import { expect } from "@playwright/test";
import fs from "fs";
-import { LoadDialogType } from "./app.js";
import { test } from "./app-test-fixtures.js";
test.describe("save", () => {
@@ -31,10 +30,8 @@ test.describe("save", () => {
}) => {
// Set the project name to avoid calling the edit project name input dialog.
await app.setProjectName("not default name");
- await app.loadFiles("testData/too-large.py", {
- acceptDialog: LoadDialogType.CONFIRM,
- });
- await app.expectEditorContainText(/# Filler/);
+ await app.loadFiles("testData/too-large.py");
+ await app.expectAlertText("Added file too-large.py");
await app.save({ waitForDownload: false });
await app.expectAlertText(
@@ -60,9 +57,7 @@ test.describe("save", () => {
app,
}) => {
await app.setProjectName("not default name");
- await app.loadFiles("testData/module.py", {
- acceptDialog: LoadDialogType.CONFIRM,
- });
+ await app.loadFiles("testData/module.py");
await app.savePythonScript();
await app.expectDialog("Warning: Only main.py downloaded");
});
diff --git a/src/e2e/storage-errors.test.ts b/src/e2e/storage-errors.test.ts
new file mode 100644
index 000000000..441479cdf
--- /dev/null
+++ b/src/e2e/storage-errors.test.ts
@@ -0,0 +1,116 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { expect } from "@playwright/test";
+import { App } from "./app.js";
+import { test } from "./app-test-fixtures.js";
+
+/**
+ * Without IndexedDB the editor keeps the project in session storage, as it
+ * did before the projects database, so closing the tab loses it and the
+ * before-unload prompt is back.
+ */
+test.describe("storage fallback", () => {
+ test.use({ noIndexedDB: true });
+
+ test("doesn't prompt on close if no edits made", async ({ app }) => {
+ await app.closeWithoutBeforeUnloadPrompt();
+ });
+
+ test("prompts on close if file edited", async ({ app }) => {
+ await app.typeInEditor("A change!");
+ await app.expectEditorContainText(/A change/);
+
+ await app.closeAndExpectBeforeUnloadPrompt();
+ });
+
+ test("prompts on close if project name edited", async ({ app }) => {
+ const name = "idiosyncratic ruminant";
+ await app.setProjectName(name);
+ await app.expectProjectName(name);
+
+ await app.closeAndExpectBeforeUnloadPrompt();
+ });
+
+ test("replaces the project with a hex without asking if not edited", async ({
+ app,
+ }) => {
+ await app.loadFiles("testData/1.0.1.hex");
+ await app.expectProjectName("1.0.1");
+ });
+
+ test("asks before a hex replaces an edited project", async ({ app }) => {
+ await app.typeInEditor("A change!");
+ await app.expectEditorContainText(/A change/);
+
+ await app.loadFiles("testData/1.0.1.hex");
+ await app.expectDialog("Confirm replace project");
+ await app.answerDialog("Cancel");
+ await app.expectEditorContainText(/A change/);
+
+ await app.loadFiles("testData/1.0.1.hex");
+ await app.answerDialog("Replace");
+ await app.expectProjectName("1.0.1");
+ await app.expectEditorContainText(/PASS1/);
+ });
+
+ test("retains text across a reload via session storage", async ({ app }) => {
+ await app.typeInEditor("A change!");
+ await app.expectEditorContainText(/A change/);
+
+ await app.page.reload();
+
+ await app.expectEditorContainText(/A change/);
+ });
+});
+
+/**
+ * Makes the next IndexedDB put throw, then restores it. The first put in a
+ * project flush is the project record, so this fails the whole flush.
+ */
+const injectWriteError = async (
+ app: App,
+ errorName: "QuotaExceededError" | "UnknownError"
+) => {
+ await app.page.evaluate((errorName) => {
+ const original = IDBObjectStore.prototype.put;
+ IDBObjectStore.prototype.put = function () {
+ IDBObjectStore.prototype.put = original;
+ throw new DOMException(`Simulated ${errorName}`, errorName);
+ };
+ }, errorName);
+};
+
+/**
+ * A failed write to the projects database keeps the edit in the editor and
+ * shows a persistent toast, as ml-trainer does.
+ */
+test.describe("storage write errors", () => {
+ test("shows the storage full toast on a quota error", async ({ app }) => {
+ await injectWriteError(app, "QuotaExceededError");
+ await app.typeInEditor("A change!");
+
+ await expect(app.page.getByText("Browser storage full")).toBeVisible();
+ await expect(
+ app.page.getByText("Your project edit may not be saved.")
+ ).toBeVisible();
+ await expect(
+ app.page.getByText("An unexpected error occurred")
+ ).toBeHidden();
+ await app.expectEditorContainText(/A change/);
+ });
+
+ test("shows a generic toast on another write error", async ({ app }) => {
+ await injectWriteError(app, "UnknownError");
+ await app.typeInEditor("A change!");
+
+ await expect(
+ app.page.getByText("Failed to save your project to browser storage")
+ ).toBeVisible();
+ await expect(
+ app.page.getByText("An unexpected error occurred")
+ ).toBeHidden();
+ });
+});
diff --git a/src/environment.ts b/src/environment.ts
index 92ad85ded..48c3efe6a 100644
--- a/src/environment.ts
+++ b/src/environment.ts
@@ -8,3 +8,10 @@ export const version = import.meta.env.VITE_VERSION || "local";
export type Stage = "local" | "REVIEW" | "STAGING" | "PRODUCTION";
export const stage = (import.meta.env.VITE_STAGE || "local") as Stage;
+
+/**
+ * Stages real users reach. Development affordances (like offering to clear
+ * an incompatible projects database) are for the others.
+ */
+export const isPublicFacingStage = (s: Stage = stage): boolean =>
+ s === "STAGING" || s === "PRODUCTION";
diff --git a/src/external-links.ts b/src/external-links.ts
index f5e7dcfe2..6c79bfdb4 100644
--- a/src/external-links.ts
+++ b/src/external-links.ts
@@ -28,3 +28,12 @@ export const microbitOrgMiciProjectsUrl = (languageId: string) =>
`https://microbit.org/${langPath(
languageId
)}projects/make-it-code-it/?filters=python`;
+
+/** A "make it: code it" project, opened for the Python editor. */
+export const microbitOrgProjectUrl = (slug: string, languageId: string) =>
+ `https://microbit.org/${langPath(
+ languageId
+ )}projects/make-it-code-it/${encodeURIComponent(slug)}/?editor=python`;
+
+export const microbitOrgLessonUrl = (slug: string) =>
+ `https://microbit.org/teach/lessons/${encodeURIComponent(slug)}/`;
diff --git a/src/flags.ts b/src/flags.ts
index 55b1e344c..9ca8cff58 100644
--- a/src/flags.ts
+++ b/src/flags.ts
@@ -54,9 +54,10 @@ export type Flag =
*/
| "translate";
-interface FlagMetadata {
+// Exposed for testing.
+export interface FlagMetadata {
defaultOnStages: Stage[];
- name: Flag;
+ name: F;
}
const allFlags: FlagMetadata[] = [
@@ -66,7 +67,7 @@ const allFlags: FlagMetadata[] = [
{ name: "dndDebug", defaultOnStages: [] },
{ name: "noLang", defaultOnStages: [] },
{ name: "translate", defaultOnStages: [] },
- { name: "noWelcome", defaultOnStages: ["local", "REVIEW"] },
+ { name: "noWelcome", defaultOnStages: ["local"] },
{
name: "pwa",
defaultOnStages: ["REVIEW", "STAGING", "PRODUCTION"],
@@ -75,8 +76,17 @@ const allFlags: FlagMetadata[] = [
type Flags = Record;
-// Exposed for testing.
-export const flagsForParams = (stage: Stage, params: URLSearchParams) => {
+/**
+ * Resolves flag values from the stage, query params and local storage.
+ *
+ * The flag metadata is a parameter so tests can exercise the resolution
+ * rules without depending on the real flag defaults.
+ */
+export const flagsForParams = (
+ stage: Stage,
+ params: URLSearchParams,
+ flagMetadata: FlagMetadata[] = allFlags as FlagMetadata[]
+): Record => {
const enableFlags = new Set(params.getAll("flag"));
try {
localStorage
@@ -92,15 +102,15 @@ export const flagsForParams = (stage: Stage, params: URLSearchParams) => {
? true
: undefined;
return Object.fromEntries(
- allFlags.map((f) => [
+ flagMetadata.map((f) => [
f.name,
isEnabled(f, stage, allFlagsDefault, enableFlags.has(f.name)),
])
- ) as Flags;
+ ) as Record;
};
const isEnabled = (
- f: FlagMetadata,
+ f: FlagMetadata,
stage: Stage,
allFlagsDefault: boolean | undefined,
thisFlagOn: boolean
diff --git a/src/fs/StorageVersionErrorPage.tsx b/src/fs/StorageVersionErrorPage.tsx
new file mode 100644
index 000000000..a63750f3c
--- /dev/null
+++ b/src/fs/StorageVersionErrorPage.tsx
@@ -0,0 +1,56 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Button, Text } from "@microbit/ui";
+import { ErrorPage } from "@microbit/ui-patterns";
+import {
+ getCurrentProjectId,
+ sessionStorageIfPossible,
+} from "./current-project";
+import { databaseName } from "./projects-db";
+
+const deleteDatabase = (name: string) =>
+ new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(name);
+ request.onsuccess = () => resolve();
+ request.onerror = () =>
+ reject(request.error ?? new Error("deleteDatabase failed"));
+ });
+
+/**
+ * Shown on non-public stages when the projects database was created by an
+ * incompatible build. Review builds share one database, so this is expected
+ * to happen there now and then; the fix is to start again.
+ *
+ * Deliberately untranslated: it never appears on a public deployment.
+ */
+const StorageVersionErrorPage = () => {
+ const handleClearAndReload = async () => {
+ try {
+ await deleteDatabase(databaseName());
+ } catch {
+ // Best effort; the reload will show this page again if it failed.
+ }
+ const session = sessionStorageIfPossible();
+ if (session && getCurrentProjectId(session)) {
+ session.removeItem("currentProjectId");
+ }
+ window.location.reload();
+ };
+ return (
+
+
+ The project storage format has changed in this pre-release version and
+ the old data is not supported. Clearing removes every project stored by
+ review builds in this browser.
+
+
+ Clear data and reload
+
+
+ );
+};
+
+export default StorageVersionErrorPage;
diff --git a/src/fs/current-project.test.ts b/src/fs/current-project.test.ts
new file mode 100644
index 000000000..78b2f842a
--- /dev/null
+++ b/src/fs/current-project.test.ts
@@ -0,0 +1,174 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import "fake-indexeddb/auto";
+import { renderHook } from "@testing-library/react";
+import { vi } from "vitest";
+import { MockLogging } from "../logging/mock";
+import {
+ chooseProject,
+ getCurrentProjectId,
+ openProjectsDatabase,
+ setCurrentProjectId,
+} from "./current-project";
+import { MAIN_FILE } from "./fs";
+import { parseMigrationFromUrl } from "./migration";
+import { testMigrationUrl } from "./migration-test-data";
+import { databaseName, ProjectsDatabase } from "./projects-db";
+import { SessionStorageFSStorage } from "./storage";
+import {
+ hasStorageVersionError,
+ resetStorageStatus,
+ useProjectsDatabaseActive,
+} from "./storage-status";
+
+const deleteDatabase = () =>
+ new Promise((resolve, reject) => {
+ const request = indexedDB.deleteDatabase(databaseName());
+ request.onsuccess = () => resolve();
+ request.onerror = () =>
+ reject(request.error ?? new Error("deleteDatabase failed"));
+ });
+
+const encode = (text: string) => new TextEncoder().encode(text);
+const isActive = () =>
+ renderHook(() => useProjectsDatabaseActive()).result.current;
+
+describe("openProjectsDatabase", () => {
+ let logging: MockLogging;
+ beforeEach(async () => {
+ await deleteDatabase();
+ logging = new MockLogging();
+ resetStorageStatus();
+ });
+
+ it("opens the database and reports it active", async () => {
+ const db = await openProjectsDatabase(logging);
+ expect(db).toBeInstanceOf(ProjectsDatabase);
+ expect(isActive()).toEqual(true);
+ db!.close();
+ });
+
+ it("gives up and logs when the database cannot be opened", async () => {
+ vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce(
+ new Error("blocked")
+ );
+ expect(await openProjectsDatabase(logging)).toBeUndefined();
+ expect(logging.errors[0].message).toMatch(/using session storage/);
+ expect(isActive()).toEqual(false);
+ });
+
+ it("gives up quietly on an incompatible database on public stages", async () => {
+ vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce(
+ new DOMException("nope", "VersionError")
+ );
+ expect(await openProjectsDatabase(logging, true)).toBeUndefined();
+ expect(hasStorageVersionError()).toEqual(false);
+ expect(logging.errors).toHaveLength(1);
+ });
+
+ it("reports an incompatible database for clearing on non-public stages", async () => {
+ vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce(
+ new DOMException("nope", "VersionError")
+ );
+ expect(await openProjectsDatabase(logging, false)).toBeUndefined();
+ expect(logging.errors).toEqual([]);
+ expect(hasStorageVersionError()).toEqual(true);
+ });
+
+ it("gives up without IndexedDB", async () => {
+ const original = globalThis.indexedDB;
+ Object.defineProperty(globalThis, "indexedDB", {
+ value: undefined,
+ configurable: true,
+ });
+ try {
+ expect(await openProjectsDatabase(logging)).toBeUndefined();
+ } finally {
+ Object.defineProperty(globalThis, "indexedDB", {
+ value: original,
+ configurable: true,
+ });
+ }
+ });
+});
+
+describe("chooseProject", () => {
+ let counter = 0;
+ let db: ProjectsDatabase;
+ beforeEach(async () => {
+ sessionStorage.clear();
+ db = await ProjectsDatabase.open(`choose-${Date.now()}-${counter++}`);
+ });
+ afterEach(() => db.close());
+
+ it("creates a project with the starter program when there is nothing", async () => {
+ const id = await chooseProject(db, sessionStorage);
+ expect(getCurrentProjectId(sessionStorage)).toEqual(id);
+ expect((await db.list()).map((p) => p.id)).toEqual([id]);
+ expect(await db.fileNames(id)).toEqual([MAIN_FILE]);
+ });
+
+ it("reopens the tab's current project and marks it most recent", async () => {
+ await db.create({ id: "old", name: "Old", timestamp: 1 }, {});
+ await db.create({ id: "cur", name: "Cur", timestamp: 2 }, {});
+ await db.touch("old", 3);
+ setCurrentProjectId(sessionStorage, "cur");
+
+ expect(await chooseProject(db, sessionStorage)).toEqual("cur");
+ expect((await db.mostRecent())?.id).toEqual("cur");
+ });
+
+ it("opens the most recent project when the tab has none", async () => {
+ await db.create({ id: "old", name: "Old", timestamp: 1 }, {});
+ await db.create({ id: "recent", name: "Recent", timestamp: 2 }, {});
+
+ expect(await chooseProject(db, sessionStorage)).toEqual("recent");
+ expect(getCurrentProjectId(sessionStorage)).toEqual("recent");
+ });
+
+ it("falls back to the most recent when the tab's project is gone", async () => {
+ await db.create({ id: "recent", name: "Recent", timestamp: 2 }, {});
+ setCurrentProjectId(sessionStorage, "deleted");
+
+ expect(await chooseProject(db, sessionStorage)).toEqual("recent");
+ });
+
+ it("makes a #project: link a new project ahead of everything", async () => {
+ await db.create({ id: "cur", name: "Cur", timestamp: 2 }, {});
+ setCurrentProjectId(sessionStorage, "cur");
+ const { migration } = parseMigrationFromUrl(testMigrationUrl)!;
+
+ const id = await chooseProject(db, sessionStorage, migration);
+
+ expect(id).not.toEqual("cur");
+ expect(getCurrentProjectId(sessionStorage)).toEqual(id);
+ expect((await db.get(id))?.name).toEqual("Hearts");
+ expect(new TextDecoder().decode(await db.file(id, MAIN_FILE))).toMatch(
+ /display.show\(Image.HEART\)/
+ );
+ });
+
+ it("migrates a project from session storage ahead of anything else", async () => {
+ await db.create({ id: "recent", name: "Recent", timestamp: 2 }, {});
+ const legacy = new SessionStorageFSStorage(sessionStorage);
+ await legacy.write("main.py", encode("# mine"));
+ await legacy.write("helper.py", encode("# helper"));
+ await legacy.setProjectName("My project");
+ sessionStorage.setItem("unrelated", "kept");
+
+ const id = await chooseProject(db, sessionStorage);
+
+ expect(id).not.toEqual("recent");
+ expect((await db.get(id))?.name).toEqual("My project");
+ expect(await db.fileNames(id)).toEqual(["helper.py", "main.py"]);
+ expect(Array.from((await db.file(id, "main.py"))!)).toEqual(
+ Array.from(encode("# mine"))
+ );
+ expect(await legacy.ls()).toEqual([]);
+ expect(sessionStorage.getItem("unrelated")).toEqual("kept");
+ expect(getCurrentProjectId(sessionStorage)).toEqual(id);
+ });
+});
diff --git a/src/fs/current-project.ts b/src/fs/current-project.ts
new file mode 100644
index 000000000..5f0dc37e9
--- /dev/null
+++ b/src/fs/current-project.ts
@@ -0,0 +1,174 @@
+/**
+ * Which project the editor opens, and its storage.
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { toByteArray } from "base64-js";
+import { isPublicFacingStage } from "../environment";
+import { Logging } from "../logging/logging";
+import { generateId } from "./fs-util";
+import { MAIN_FILE } from "./fs";
+import { defaultInitialProject } from "./initial-project";
+import { Migration } from "./migration";
+import { ProjectsDatabase } from "./projects-db";
+import { SessionStorageFSStorage } from "./storage";
+import {
+ reportProjectsDatabaseActive,
+ reportStorageVersionError,
+} from "./storage-status";
+
+/**
+ * The open project is per tab, as the whole project used to be.
+ */
+const currentProjectKey = "currentProjectId";
+
+export const getCurrentProjectId = (
+ session: Storage | undefined
+): string | undefined => session?.getItem(currentProjectKey) ?? undefined;
+
+export const setCurrentProjectId = (
+ session: Storage | undefined,
+ id: string | undefined
+): void => {
+ if (id === undefined) {
+ session?.removeItem(currentProjectKey);
+ } else {
+ session?.setItem(currentProjectKey, id);
+ }
+};
+
+export const sessionStorageIfPossible = (): Storage | undefined => {
+ try {
+ return window.sessionStorage;
+ } catch {
+ // SecurityError in some embedding scenarios (issue 736) and no window in
+ // tests; either way there is nothing to read.
+ return undefined;
+ }
+};
+
+/**
+ * Opens the projects database, or explains why not.
+ *
+ * Without IndexedDB (unavailable, blocked, or an incompatible database) the
+ * result is undefined and the editor falls back to session storage, which is
+ * what it used before. On non-public stages an incompatible database is
+ * reported for the UI to offer clearing it instead, since review builds share
+ * one database.
+ */
+export const openProjectsDatabase = async (
+ logging: Logging,
+ publicFacing: boolean = isPublicFacingStage()
+): Promise => {
+ if (typeof indexedDB === "undefined") {
+ return undefined;
+ }
+ try {
+ const db = await ProjectsDatabase.open();
+ reportProjectsDatabaseActive();
+ return db;
+ } catch (e) {
+ if (isVersionError(e) && !publicFacing) {
+ reportStorageVersionError(e);
+ } else {
+ logging.error("Projects database unavailable, using session storage", e);
+ }
+ return undefined;
+ }
+};
+
+/**
+ * The files of a new project: the starter program.
+ */
+export const defaultProjectFiles = (): Record =>
+ Object.fromEntries(
+ Object.entries(defaultInitialProject.files).map(([name, base64]) => [
+ name,
+ toByteArray(base64),
+ ])
+ );
+
+/**
+ * The files of a project shared as a #project: link.
+ */
+export const migrationProjectFiles = (
+ migration: Migration
+): Record => ({
+ [MAIN_FILE]: new TextEncoder().encode(migration.source),
+});
+
+/**
+ * Decides which project the editor opens and marks it most recent.
+ *
+ * In order: a new project from the #project: link the app booted with; the
+ * project the tab already has open; a project migrated from the
+ * session-storage file system that predates the projects database, so a
+ * reload after deploying this lands in the user's work; the most recently
+ * used project, so a straight-to-editor bookmark keeps working; otherwise a
+ * new project.
+ */
+export const chooseProject = async (
+ db: ProjectsDatabase,
+ session: Storage | undefined,
+ migration?: Migration
+): Promise => {
+ if (migration) {
+ const id = generateId();
+ await db.create(
+ { id, name: migration.meta.name, timestamp: Date.now() },
+ migrationProjectFiles(migration)
+ );
+ setCurrentProjectId(session, id);
+ return id;
+ }
+ const current = getCurrentProjectId(session);
+ if (current && (await db.get(current))) {
+ await db.touch(current);
+ return current;
+ }
+ const legacy = session && new SessionStorageFSStorage(session);
+ if (legacy && (await legacy.ls()).length > 0) {
+ const id = await migrateLegacyProject(db, legacy);
+ setCurrentProjectId(session, id);
+ return id;
+ }
+ const recent = await db.mostRecent();
+ if (recent) {
+ await db.touch(recent.id);
+ setCurrentProjectId(session, recent.id);
+ return recent.id;
+ }
+ const id = generateId();
+ await db.create(
+ { id, name: undefined, timestamp: Date.now() },
+ defaultProjectFiles()
+ );
+ setCurrentProjectId(session, id);
+ return id;
+};
+
+/**
+ * Moves the single session-storage project into the database. The session
+ * storage copy is removed so this happens once; the id then stands in for it.
+ */
+const migrateLegacyProject = async (
+ db: ProjectsDatabase,
+ legacy: SessionStorageFSStorage
+): Promise => {
+ const files: Record = {};
+ for (const name of await legacy.ls()) {
+ files[name] = await legacy.read(name);
+ }
+ const id = generateId();
+ await db.create(
+ { id, name: await legacy.projectName(), timestamp: Date.now() },
+ files
+ );
+ await legacy.removeAll();
+ return id;
+};
+
+const isVersionError = (e: unknown): boolean =>
+ e instanceof DOMException && e.name === "VersionError";
diff --git a/src/fs/fs.test.ts b/src/fs/fs.test.ts
index baa8cc299..e9d7df2a6 100644
--- a/src/fs/fs.test.ts
+++ b/src/fs/fs.test.ts
@@ -20,6 +20,7 @@ import {
} from "./fs";
import { DefaultHost } from "./host";
import { defaultInitialProject } from "./initial-project";
+import { InMemoryFSStorage } from "./storage";
const hexes = [
fs.readFileSync("src/micropython/microbit-micropython-v1.hex", {
@@ -147,9 +148,11 @@ describe("Filesystem", () => {
await ufs.write("other.txt", "content", VersionAction.INCREMENT);
const originalId = ufs.project.id;
- await ufs.replaceWithHexContents(
+ await ufs.replaceWithFiles(
"new project name",
- await fsp.readFile("testData/1.0.1.hex", { encoding: "ascii" })
+ await ufs.filesFromHex(
+ await fsp.readFile("testData/1.0.1.hex", { encoding: "ascii" })
+ )
);
expect(await asString(ufs.read(MAIN_FILE))).toMatch(/PASS1/);
@@ -159,6 +162,28 @@ describe("Filesystem", () => {
expect(ufs.project.id === originalId).toEqual(false);
});
+ it("reads the appended script of an old hex as main.py", async () => {
+ const files = await ufs.filesFromHex(
+ await fsp.readFile("testData/0.9.hex", { encoding: "ascii" })
+ );
+ expect(Object.keys(files)).toEqual([MAIN_FILE]);
+ expect(new TextDecoder().decode(files[MAIN_FILE])).toMatch(/PASS2/);
+ });
+
+ it("rejects a hex with no Python", async () => {
+ await expect(ufs.filesFromHex(hexes[1])).rejects.toThrow(
+ "No appended code found in the hex file"
+ );
+ });
+
+ it("reading a hex leaves the open project alone", async () => {
+ await ufs.initialize();
+ await ufs.filesFromHex(
+ await fsp.readFile("testData/1.0.1.hex", { encoding: "ascii" })
+ );
+ expect(await asString(ufs.read(MAIN_FILE))).not.toMatch(/PASS1/);
+ });
+
it("can order files ascendingly according to their file names", async () => {
await ufs.initialize();
@@ -178,9 +203,11 @@ describe("Filesystem", () => {
await ufs.setProjectName("new name");
expect(ufs.dirty).toEqual(true);
- await ufs.replaceWithHexContents(
+ await ufs.replaceWithFiles(
"different name",
- await fsp.readFile("testData/1.0.1.hex", { encoding: "ascii" })
+ await ufs.filesFromHex(
+ await fsp.readFile("testData/1.0.1.hex", { encoding: "ascii" })
+ )
);
expect(ufs.dirty).toEqual(false);
@@ -295,5 +322,94 @@ describe("fs - diff", () => {
});
});
+describe("Filesystem switchStorage", () => {
+ const logging = new ConsoleLogging();
+ const host = new DefaultHost();
+ const encode = (text: string) => new TextEncoder().encode(text);
+
+ const otherProject = async () => {
+ const storage = new InMemoryFSStorage("Other project");
+ await storage.write(MAIN_FILE, encode("# other main"));
+ await storage.write("helper.py", encode("# helper"));
+ await storage.markDirty();
+ return storage;
+ };
+
+ it("presents the new storage's files, name and dirty flag", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ const events: Project[] = [];
+ ufs.addEventListener("project_updated", (e) => {
+ events.push(e.project);
+ });
+
+ const storage = await otherProject();
+ await ufs.switchStorage(storage);
+
+ expect(ufs.project.name).toEqual("Other project");
+ expect(ufs.project.files.map((f) => f.name)).toEqual([
+ MAIN_FILE,
+ "helper.py",
+ ]);
+ expect(await asString(ufs.read(MAIN_FILE))).toEqual("# other main");
+ expect(ufs.dirty).toEqual(true);
+ expect(events).toHaveLength(1);
+ });
+
+ it("bumps versions so editors reload the same-named file", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ const before = ufs.project.files.find((f) => f.name === MAIN_FILE)!;
+
+ await ufs.switchStorage(await otherProject());
+
+ const after = ufs.project.files.find((f) => f.name === MAIN_FILE)!;
+ expect(after.version).toBeGreaterThan(before.version);
+ });
+
+ it("gives the project a new id", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ const before = ufs.project.id;
+ await ufs.switchStorage(await otherProject());
+ expect(ufs.project.id).not.toEqual(before);
+ });
+
+ it("refills the hex file system from the new storage", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ await ufs.switchStorage(await otherProject());
+
+ const stats = await ufs.statistics();
+ expect(stats.files).toEqual(2);
+ expect(await ufs.toHexForSave()).toContain(":");
+ });
+
+ it("directs later writes and removes to the new storage only", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ await ufs.initialize();
+ const storage = await otherProject();
+ await ufs.switchStorage(storage);
+
+ await ufs.write("new.py", "# new", VersionAction.INCREMENT);
+ await ufs.remove("helper.py");
+
+ expect(await storage.ls()).toEqual([MAIN_FILE, "new.py"]);
+ const original = await new DefaultHost().createStorage(logging).ls();
+ expect(original).not.toContain("new.py");
+ });
+
+ it("waits for an in-flight initialisation", async () => {
+ const ufs = new FileSystem(logging, host, fsMicroPythonSource);
+ const initializing = ufs.initialize();
+ await ufs.switchStorage(await otherProject());
+ await initializing;
+
+ expect(ufs.project.name).toEqual("Other project");
+ expect(await asString(ufs.read(MAIN_FILE))).toEqual("# other main");
+ expect((await ufs.statistics()).files).toEqual(2);
+ });
+});
+
const asString = async (f: Promise) =>
new TextDecoder().decode((await f).data);
diff --git a/src/fs/fs.ts b/src/fs/fs.ts
index c032246fb..58d2fba85 100644
--- a/src/fs/fs.ts
+++ b/src/fs/fs.ts
@@ -7,6 +7,7 @@ import {
getIntelHexAppendedScript,
microbitBoardId,
MicropythonFsHex,
+ IntelHexWithId,
} from "@microbit/microbit-fs";
import { fromByteArray, toByteArray } from "base64-js";
import { sortBy } from "../common/sort-util";
@@ -160,8 +161,9 @@ export const isNameLengthValid = (filename: string): boolean =>
/**
* The MicroPython file system adapted for convienient use from the UI.
*
- * For now we store contents backed by session storage so they're only
- * persistent over a browser refresh or Chrome tab restore.
+ * Contents are held in memory and mirrored to the host's persistent storage:
+ * the current project in the IndexedDB projects database, or session storage where
+ * that is unavailable.
*
* We version files in a way that's designed to make UI updates simple.
* If a UI action updates a file (e.g. load from disk) then we bump its version.
@@ -253,6 +255,29 @@ export class FileSystem extends TypedEventTarget {
return this.fs!;
}
+ /**
+ * Switch to a different backing storage, typically another project.
+ *
+ * The new storage is the record from here on: reads, writes and the hex
+ * file system all reflect it. Versions of files present in the new storage
+ * are bumped so editors showing a same-named file reload it.
+ */
+ async switchStorage(storage: FSStorage): Promise {
+ if (this.initializing) {
+ await this.initializing;
+ }
+ this.storage = storage;
+ this._dirty = await storage.isDirty();
+ this.project = { ...this.project, id: generateId() };
+ if (this.fs) {
+ await this.initializeFsFromStorage(this.fs);
+ }
+ for (const name of await storage.ls()) {
+ this.incrementFileVersion(name);
+ }
+ return this.notify();
+ }
+
/**
* Update the project name.
*
@@ -354,34 +379,50 @@ export class FileSystem extends TypedEventTarget {
}
async replaceWithMultipleFiles(project: PythonProject): Promise {
+ const files = Object.fromEntries(
+ Object.entries(project.files).map(([name, base64]) => [
+ name,
+ toByteArray(base64),
+ ])
+ );
+ await this.replaceWithFiles(project.projectName, files);
+ }
+
+ /**
+ * Replace the project's files and name. For the single implicit project
+ * of the iframe and session-storage cases; with the projects database an
+ * import becomes a new project instead.
+ */
+ async replaceWithFiles(
+ projectName: string | undefined,
+ files: Record
+ ): Promise {
const fs = await this.initialize();
fs.ls().forEach((f) => fs.remove(f));
- for (const key in project.files) {
- const content = toByteArray(project.files[key]);
- fs.write(key, content);
+ for (const [name, content] of Object.entries(files)) {
+ fs.write(name, content);
}
- await this.replaceCommon(project.projectName);
+ await this.replaceCommon(projectName);
}
- async replaceWithHexContents(
- projectName: string,
- hex: string
- ): Promise {
- const fs = await this.initialize();
+ /**
+ * The files in a hex saved by the editor, or the script appended to a hex
+ * by older editors as main.py. Throws if there is neither.
+ *
+ * Uses a separate file system so the open project is untouched.
+ */
+ async filesFromHex(hex: string): Promise> {
+ const fs = await this.createInternalFileSystem();
try {
- fs.importFilesFromHex(hex, {
- overwrite: true,
- formatFirst: true,
- });
+ fs.importFilesFromHex(hex, { overwrite: true, formatFirst: true });
} catch {
const code = getIntelHexAppendedScript(hex);
if (!code) {
throw new Error("No appended code found in the hex file");
}
- fs.ls().forEach((f) => fs.remove(f));
- fs.write(MAIN_FILE, code);
+ return { [MAIN_FILE]: new TextEncoder().encode(code) };
}
- await this.replaceCommon(projectName);
+ return Object.fromEntries(fs.ls().map((f) => [f, fs.readBytes(f)]));
}
async replaceCommon(projectName?: string): Promise {
@@ -507,9 +548,16 @@ export class FileSystem extends TypedEventTarget {
}
}
+ private microPython: Promise | undefined;
+
private createInternalFileSystem = async () => {
- const microPython = await this.microPythonSource();
- return new MicropythonFsHex(microPython, {
+ // Fetched once for the open project and any hex imports; a failed fetch
+ // is forgotten so the next attempt retries it.
+ this.microPython ??= this.microPythonSource().catch((e) => {
+ this.microPython = undefined;
+ throw e;
+ });
+ return new MicropythonFsHex(await this.microPython, {
maxFsSize: commonFsSize,
});
};
diff --git a/src/fs/host-default.test.ts b/src/fs/host-default.test.ts
index 2853c2e27..2fb8557f2 100644
--- a/src/fs/host-default.test.ts
+++ b/src/fs/host-default.test.ts
@@ -5,13 +5,14 @@
import { fromByteArray } from "base64-js";
import { MAIN_FILE } from "./fs";
import { DefaultHost } from "./host";
+import { PendingMigration } from "./migration";
import { defaultInitialProject } from "./initial-project";
import { testMigrationUrl } from "./migration-test-data";
describe("DefaultHost", () => {
it("uses migration if available", async () => {
const project = await new DefaultHost(
- testMigrationUrl
+ new PendingMigration(testMigrationUrl)
).createInitialProject();
expect(project).toEqual({
files: {
@@ -25,7 +26,7 @@ describe("DefaultHost", () => {
});
});
it("otherwise uses defaults", async () => {
- const project = await new DefaultHost("").createInitialProject();
+ const project = await new DefaultHost().createInitialProject();
expect(project).toEqual(defaultInitialProject);
});
});
diff --git a/src/fs/host.ts b/src/fs/host.ts
index 37480617f..7e836a088 100644
--- a/src/fs/host.ts
+++ b/src/fs/host.ts
@@ -11,13 +11,8 @@ import {
PythonProject,
projectFilesToBase64,
} from "./initial-project";
-import { parseMigrationFromUrl } from "./migration";
-import {
- FSStorage,
- InMemoryFSStorage,
- SessionStorageFSStorage,
- SplitStrategyStorage,
-} from "./storage";
+import { PendingMigration } from "./migration";
+import { FSStorage, InMemoryFSStorage, SplitStrategyStorage } from "./storage";
const messages = {
type: "pyeditor",
@@ -37,38 +32,48 @@ export interface Host {
}
export class DefaultHost implements Host {
- constructor(private url: string = "") {}
+ /**
+ * @param migration The #project: link at boot, if the projects session
+ * has not already made a project of it.
+ * @param persistentStorage The record of the project, once one is opened.
+ * Until then the file system waits, and without one it stays in memory.
+ */
+ constructor(
+ private migration: PendingMigration = new PendingMigration(""),
+ private persistentStorage: Promise = Promise.resolve(
+ undefined
+ )
+ ) {}
createStorage(logging: Logging): FSStorage {
return new SplitStrategyStorage(
new InMemoryFSStorage(undefined),
- SessionStorageFSStorage.create(),
+ this.persistentStorage,
logging
);
}
async shouldReinitializeProject(storage: FSStorage): Promise {
- const migration = parseMigrationFromUrl(this.url);
- if (migration) {
- return true;
- }
- return !(await storage.exists(MAIN_FILE));
+ // Waits for the project's storage, by which time the projects session
+ // has taken a #project: link if it is going to.
+ const hasMain = await storage.exists(MAIN_FILE);
+ return this.migration.pending || !hasMain;
}
async createInitialProject(): Promise {
- const migrationParseResult = parseMigrationFromUrl(this.url);
- if (migrationParseResult) {
- const { migration, postMigrationUrl } = migrationParseResult;
- const project = {
+ const migration = this.migration.take();
+ if (migration) {
+ // The path may have changed since boot (the root redirects to the
+ // editor), so strip the hash from the current URL rather than using
+ // the parsed one.
+ const { pathname, search } = window.location;
+ window.history.replaceState(null, "", pathname + search);
+ return {
files: projectFilesToBase64({
[MAIN_FILE]: migration.source,
}),
projectName: migration.meta.name,
};
- // Remove the migration information from the URL so that a refresh
- // will reload from storage not remigrate.
- window.history.replaceState(null, "", postMigrationUrl);
- return project;
}
return defaultInitialProject;
}
@@ -147,12 +152,16 @@ export class IframeHost implements Host {
}
}
-export const createHost = (logging: Logging): Host => {
+export const createHost = (
+ logging: Logging,
+ migration: PendingMigration,
+ persistentStorage: Promise
+): Host => {
const iframeHost = getControllerHost(logging);
if (iframeHost) {
return new IframeHost(iframeHost, window);
}
- return new DefaultHost(window.location.href);
+ return new DefaultHost(migration, persistentStorage);
};
const getControllerHost = (logging: Logging): Window | undefined => {
diff --git a/src/fs/indexeddb-storage.test.ts b/src/fs/indexeddb-storage.test.ts
new file mode 100644
index 000000000..8112c6286
--- /dev/null
+++ b/src/fs/indexeddb-storage.test.ts
@@ -0,0 +1,125 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import "fake-indexeddb/auto";
+import { vi } from "vitest";
+import { IndexedDBFSStorage } from "./indexeddb-storage";
+import { ProjectsDatabase } from "./projects-db";
+import { commonStorageTests } from "./storage-tests";
+
+let counter = 0;
+const uniqueName = () => `test-${Date.now()}-${counter++}`;
+const projectId = "p1";
+const contents = (files: Record) =>
+ Object.fromEntries(
+ Object.entries(files).map(([name, data]) => [name, Array.from(data)])
+ );
+
+const openWithProject = async () => {
+ const db = await ProjectsDatabase.open(uniqueName());
+ await db.create({ id: projectId, name: undefined, timestamp: 1 }, {});
+ return db;
+};
+
+describe("IndexedDBFSStorage", () => {
+ let db: ProjectsDatabase;
+ let storage: IndexedDBFSStorage;
+ let errors: unknown[];
+ let changes: number;
+ beforeEach(async () => {
+ db = await openWithProject();
+ errors = [];
+ changes = 0;
+ storage = new IndexedDBFSStorage(
+ db,
+ projectId,
+ (e) => errors.push(e),
+ () => changes++,
+ 20
+ );
+ });
+ afterEach(async () => {
+ await storage.dispose();
+ db.close();
+ });
+
+ commonStorageTests(() => storage);
+
+ it("coalesces writes into one transaction after the delay", async () => {
+ const apply = vi.spyOn(db, "apply");
+ await storage.write("main.py", new Uint8Array([1]));
+ await storage.write("main.py", new Uint8Array([2]));
+ await storage.write("other.py", new Uint8Array([3]));
+ await storage.setProjectName("Renamed");
+ expect(apply).not.toHaveBeenCalled();
+
+ await new Promise((resolve) => setTimeout(resolve, 40));
+
+ expect(apply).toHaveBeenCalledTimes(1);
+ expect(contents(await db.files(projectId))).toEqual({
+ "main.py": [2],
+ "other.py": [3],
+ });
+ expect((await db.get(projectId))?.name).toEqual("Renamed");
+ });
+
+ it("does not track the dirty flag", async () => {
+ const apply = vi.spyOn(db, "apply");
+ await storage.markDirty();
+ await storage.flush();
+ expect(await storage.isDirty()).toEqual(false);
+ expect(apply).not.toHaveBeenCalled();
+ });
+
+ it("reports each successful flush for cross-tab sync", async () => {
+ await storage.write("main.py", new Uint8Array([1]));
+ await storage.flush();
+ await storage.flush();
+ expect(changes).toEqual(1);
+ });
+
+ it("a remove after a write in the same batch wins", async () => {
+ await storage.write("main.py", new Uint8Array([1]));
+ await storage.remove("main.py");
+ await storage.flush();
+ expect(contents(await db.files(projectId))).toEqual({});
+ });
+
+ it("bumps the project timestamp when it flushes changes", async () => {
+ await storage.write("main.py", new Uint8Array([1]));
+ await storage.flush();
+ expect((await db.get(projectId))?.timestamp).toBeGreaterThan(1);
+ });
+
+ it("flushes when the page is hidden", async () => {
+ await storage.write("main.py", new Uint8Array([1]));
+ Object.defineProperty(document, "visibilityState", {
+ value: "hidden",
+ configurable: true,
+ });
+ document.dispatchEvent(new Event("visibilitychange"));
+ // The flush is asynchronous; wait less than the scheduled delay.
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ expect(contents(await db.files(projectId))).toEqual({ "main.py": [1] });
+ Object.defineProperty(document, "visibilityState", {
+ value: "visible",
+ configurable: true,
+ });
+ });
+
+ it("reports a failed flush and drops those changes rather than throwing", async () => {
+ vi.spyOn(db, "apply").mockRejectedValueOnce(
+ new DOMException("full", "QuotaExceededError")
+ );
+ await storage.write("main.py", new Uint8Array([1]));
+ await storage.flush();
+ expect(errors).toHaveLength(1);
+ expect((errors[0] as DOMException).name).toEqual("QuotaExceededError");
+
+ await storage.write("other.py", new Uint8Array([2]));
+ await storage.flush();
+ expect(contents(await db.files(projectId))).toEqual({ "other.py": [2] });
+ });
+});
diff --git a/src/fs/indexeddb-storage.ts b/src/fs/indexeddb-storage.ts
new file mode 100644
index 000000000..374a56cfe
--- /dev/null
+++ b/src/fs/indexeddb-storage.ts
@@ -0,0 +1,172 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { ProjectChanges, ProjectsDatabase } from "./projects-db";
+import { FSStorage } from "./storage";
+
+const defaultFlushDelayMs = 300;
+
+/**
+ * File system storage for one project in the IndexedDB projects database.
+ *
+ * Intended as the secondary of a SplitStrategyStorage, so reads are rare and
+ * writes arrive on every keystroke. Writes are coalesced per file and flushed
+ * as one transaction after a short delay, and when the page is hidden or
+ * unloading. Reads flush first so they always see the latest write.
+ *
+ * Failures never propagate: the in-memory primary still holds the content,
+ * so a failed flush is reported through onError and the changes are dropped
+ * rather than retried forever against, say, a full quota.
+ *
+ * The dirty flag is not stored: it exists to warn before work is lost, and
+ * a project in the database outlives the tab.
+ */
+export class IndexedDBFSStorage implements FSStorage {
+ private pendingWrites = new Map();
+ private pendingMeta: NonNullable = {};
+ // When the oldest pending change was made: the project's "last modified"
+ // is the edit, not the flush, which may come later than another project's.
+ private pendingSince: number | undefined;
+ private timer: ReturnType | undefined;
+ private flushing: Promise = Promise.resolve();
+ private readonly handleHidden = () => {
+ if (document.visibilityState === "hidden") {
+ void this.flush();
+ }
+ };
+
+ /**
+ * @param onError Reports a failed flush; the changes in it are dropped.
+ * @param onChange Called after a successful flush, for cross-tab sync.
+ */
+ constructor(
+ private db: ProjectsDatabase,
+ private projectId: string,
+ private onError: (e: unknown) => void,
+ private onChange: () => void = () => {},
+ private flushDelayMs: number = defaultFlushDelayMs
+ ) {
+ if (typeof document !== "undefined") {
+ document.addEventListener("visibilitychange", this.handleHidden);
+ window.addEventListener("pagehide", this.handleHidden);
+ }
+ }
+
+ /**
+ * Flushes and stops listening. The database connection is shared with
+ * the projects list and stays open.
+ */
+ async dispose(): Promise {
+ if (typeof document !== "undefined") {
+ document.removeEventListener("visibilitychange", this.handleHidden);
+ window.removeEventListener("pagehide", this.handleHidden);
+ }
+ await this.flush();
+ }
+
+ async ls(): Promise {
+ await this.flush();
+ return this.db.fileNames(this.projectId);
+ }
+
+ async exists(filename: string): Promise {
+ await this.flush();
+ return (await this.db.file(this.projectId, filename)) !== undefined;
+ }
+
+ async read(filename: string): Promise {
+ await this.flush();
+ const data = await this.db.file(this.projectId, filename);
+ if (data === undefined) {
+ throw new Error(`No such file ${filename}`);
+ }
+ return data;
+ }
+
+ async write(name: string, content: Uint8Array): Promise {
+ this.pendingWrites.set(name, content);
+ this.schedule();
+ }
+
+ async remove(name: string): Promise {
+ this.pendingWrites.set(name, null);
+ this.schedule();
+ }
+
+ async clear(): Promise {
+ for (const name of await this.ls()) {
+ this.pendingWrites.set(name, null);
+ }
+ this.pendingMeta = { name: undefined };
+ await this.flush();
+ }
+
+ async setProjectName(projectName: string | undefined): Promise {
+ this.pendingMeta.name = projectName;
+ this.schedule();
+ }
+
+ async projectName(): Promise {
+ await this.flush();
+ return (await this.db.get(this.projectId))?.name;
+ }
+
+ async markDirty(): Promise {}
+
+ async clearDirty(): Promise {}
+
+ async isDirty(): Promise {
+ return false;
+ }
+
+ private schedule(): void {
+ this.pendingSince ??= Date.now();
+ if (this.timer === undefined) {
+ this.timer = setTimeout(() => void this.flush(), this.flushDelayMs);
+ }
+ }
+
+ /**
+ * Writes everything pending in one transaction. Safe to call at any time;
+ * concurrent calls queue behind each other.
+ */
+ flush(): Promise {
+ clearTimeout(this.timer);
+ this.timer = undefined;
+ this.flushing = this.flushing.then(() => this.flushPending());
+ return this.flushing;
+ }
+
+ private async flushPending(): Promise {
+ if (this.pendingWrites.size === 0 && isEmpty(this.pendingMeta)) {
+ return;
+ }
+ const writes: Record = {};
+ const deletes: string[] = [];
+ for (const [name, data] of this.pendingWrites) {
+ if (data === null) {
+ deletes.push(name);
+ } else {
+ writes[name] = data;
+ }
+ }
+ const changes: ProjectChanges = {
+ meta: { ...this.pendingMeta, timestamp: this.pendingSince ?? Date.now() },
+ writes,
+ deletes,
+ };
+ this.pendingWrites = new Map();
+ this.pendingMeta = {};
+ this.pendingSince = undefined;
+ try {
+ await this.db.apply(this.projectId, changes);
+ this.onChange();
+ } catch (e) {
+ this.onError(e);
+ }
+ }
+}
+
+const isEmpty = (o: object) => Object.keys(o).length === 0;
diff --git a/src/fs/migration.ts b/src/fs/migration.ts
index 8a494813a..d561dca72 100644
--- a/src/fs/migration.ts
+++ b/src/fs/migration.ts
@@ -35,6 +35,13 @@ interface MigrationParseResult {
postMigrationUrl: string;
}
+/**
+ * True if the URL carries a #project: link. On microbit.org links it sits
+ * behind the v2 editor's #import: prefix, so it isn't always the whole hash.
+ */
+export const hasProjectLink = (url: string): boolean =>
+ url.includes("#project:");
+
export const parseMigrationFromUrl = (
url: string
): MigrationParseResult | undefined => {
@@ -56,3 +63,30 @@ export const parseMigrationFromUrl = (
}
return undefined;
};
+
+/**
+ * The #project: link the app booted with, if any, handed out once.
+ *
+ * With the projects database the link becomes a new project when the editor
+ * chooses one; without it the host writes the program into the single
+ * implicit project. Whichever runs first takes it. The taker also sees to
+ * removing the hash from the URL, so a reload opens what is stored rather
+ * than importing again.
+ */
+export class PendingMigration {
+ private migration: Migration | undefined;
+
+ constructor(url: string) {
+ this.migration = parseMigrationFromUrl(url)?.migration;
+ }
+
+ get pending(): boolean {
+ return this.migration !== undefined;
+ }
+
+ take(): Migration | undefined {
+ const migration = this.migration;
+ this.migration = undefined;
+ return migration;
+ }
+}
diff --git a/src/fs/projects-db.test.ts b/src/fs/projects-db.test.ts
new file mode 100644
index 000000000..8ee5d290d
--- /dev/null
+++ b/src/fs/projects-db.test.ts
@@ -0,0 +1,128 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import "fake-indexeddb/auto";
+import { openDB } from "idb";
+import { databaseName, ProjectMeta, ProjectsDatabase } from "./projects-db";
+
+let counter = 0;
+const uniqueName = () => `test-${Date.now()}-${counter++}`;
+
+const meta = (id: string, timestamp: number, name = id): ProjectMeta => ({
+ id,
+ name,
+ timestamp,
+});
+const bytes = (...values: number[]) => new Uint8Array(values);
+// Typed arrays read back through fake-indexeddb under jsdom belong to another
+// realm, so compare contents rather than objects.
+const contents = (files: Record) =>
+ Object.fromEntries(
+ Object.entries(files).map(([name, data]) => [name, Array.from(data)])
+ );
+
+describe("databaseName", () => {
+ it("is namespaced by the base path so production and beta stay apart", () => {
+ expect(databaseName("local", "/")).toEqual("python-editor");
+ expect(databaseName("PRODUCTION", "/v/3/")).toEqual("python-editor/v/3");
+ expect(databaseName("STAGING", "/v/beta/")).toEqual("python-editor/v/beta");
+ });
+
+ it("is shared by every review build", () => {
+ expect(databaseName("REVIEW", "/some-branch/")).toEqual(
+ "python-editor-review"
+ );
+ expect(databaseName("REVIEW", "/another/")).toEqual("python-editor-review");
+ });
+});
+
+describe("ProjectsDatabase", () => {
+ let db: ProjectsDatabase;
+ beforeEach(async () => {
+ db = await ProjectsDatabase.open(uniqueName());
+ });
+ afterEach(() => db.close());
+
+ it("starts empty", async () => {
+ expect(await db.list()).toEqual([]);
+ expect(await db.mostRecent()).toBeUndefined();
+ });
+
+ it("creates projects with files and lists them most recent first", async () => {
+ await db.create(meta("a", 1), { "main.py": bytes(1) });
+ await db.create(meta("b", 2), { "main.py": bytes(2), "x.py": bytes(3) });
+ expect((await db.list()).map((p) => p.id)).toEqual(["b", "a"]);
+ expect(await db.mostRecent()).toEqual(meta("b", 2));
+ expect(contents(await db.files("b"))).toEqual({
+ "main.py": [2],
+ "x.py": [3],
+ });
+ expect(await db.fileNames("a")).toEqual(["main.py"]);
+ expect(Array.from((await db.file("a", "main.py"))!)).toEqual([1]);
+ expect(await db.file("a", "nope.py")).toBeUndefined();
+ });
+
+ it("applies metadata, writes and deletes together", async () => {
+ await db.create(meta("a", 1), { "main.py": bytes(1), "old.py": bytes(9) });
+ await db.apply("a", {
+ meta: { name: "renamed", timestamp: 5 },
+ writes: { "main.py": bytes(2), "new.py": bytes(3) },
+ deletes: ["old.py"],
+ });
+ expect(await db.get("a")).toEqual({
+ id: "a",
+ name: "renamed",
+ timestamp: 5,
+ });
+ expect(contents(await db.files("a"))).toEqual({
+ "main.py": [2],
+ "new.py": [3],
+ });
+ });
+
+ it("refuses changes to a project that does not exist", async () => {
+ await expect(db.apply("missing", { meta: { name: "x" } })).rejects.toThrow(
+ /No such project missing/
+ );
+ });
+
+ it("touch makes a project the most recent", async () => {
+ await db.create(meta("a", 1), {});
+ await db.create(meta("b", 2), {});
+ await db.touch("a", 3);
+ expect((await db.mostRecent())?.id).toEqual("a");
+ });
+
+ it("duplicates a project's files under new metadata", async () => {
+ await db.create(meta("a", 1), { "main.py": bytes(1) });
+ await db.duplicate("a", meta("b", 2, "copy"));
+ expect(await db.get("b")).toEqual(meta("b", 2, "copy"));
+ expect(contents(await db.files("b"))).toEqual({ "main.py": [1] });
+ });
+
+ it("deletes a project and its files", async () => {
+ await db.create(meta("a", 1), { "main.py": bytes(1), "x.py": bytes(2) });
+ await db.create(meta("b", 2), { "main.py": bytes(3) });
+ await db.delete("a");
+ expect((await db.list()).map((p) => p.id)).toEqual(["b"]);
+ expect(contents(await db.files("a"))).toEqual({});
+ expect(contents(await db.files("b"))).toEqual({ "main.py": [3] });
+ });
+
+ it("rejects a database created by an incompatible version", async () => {
+ const name = uniqueName();
+ // Same version number, different stores: what a future schema change
+ // that forgot to bump the version would look like from an old build.
+ const other = await openDB(name, 1, {
+ upgrade(db) {
+ db.createObjectStore("something-else");
+ },
+ });
+ other.close();
+ await expect(ProjectsDatabase.open(name)).rejects.toMatchObject({
+ name: "VersionError",
+ });
+ });
+});
diff --git a/src/fs/projects-db.ts b/src/fs/projects-db.ts
new file mode 100644
index 000000000..98ed3223e
--- /dev/null
+++ b/src/fs/projects-db.ts
@@ -0,0 +1,220 @@
+/**
+ * The projects database in IndexedDB.
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { DBSchema, IDBPDatabase, openDB } from "idb";
+import { baseUrl } from "../base";
+import { Stage, stage as currentStage } from "../environment";
+
+/**
+ * Project metadata. Deliberately holds no file content so listing is cheap.
+ */
+export interface ProjectMeta {
+ id: string;
+ name: string | undefined;
+ /** Last modified or opened, for ordering by recency. */
+ timestamp: number;
+}
+
+/** A project as listed on the pages: its metadata plus what it contains. */
+export interface ProjectListEntry extends ProjectMeta {
+ fileNames: string[];
+}
+
+/**
+ * A file belonging to a project, keyed by [projectId, name]: a file's name
+ * is its identity within a project.
+ */
+export interface FileRecord {
+ projectId: string;
+ name: string;
+ data: Uint8Array;
+}
+
+/**
+ * A set of changes to one project, applied in a single transaction.
+ */
+export interface ProjectChanges {
+ meta?: Partial>;
+ writes?: Record;
+ deletes?: string[];
+}
+
+const PROJECTS = "projects";
+const FILES = "files";
+const stores = [PROJECTS, FILES] as const;
+
+interface Schema extends DBSchema {
+ [PROJECTS]: {
+ key: string;
+ value: ProjectMeta;
+ };
+ [FILES]: {
+ key: [string, string];
+ value: FileRecord;
+ indexes: { projectId: string };
+ };
+}
+
+const DB_VERSION = 1;
+
+/**
+ * Deployments share an origin, so the database name includes the base path
+ * to keep production's and beta's libraries apart. Review builds all share
+ * one: they are internal, and a project made on one branch is useful on the
+ * next. When a schema change breaks it, StorageVersionErrorPage offers to
+ * clear it.
+ */
+export const databaseName = (
+ stage: Stage = currentStage,
+ base: string = baseUrl
+): string => {
+ if (stage === "REVIEW") {
+ return "python-editor-review";
+ }
+ return base === "/"
+ ? "python-editor"
+ : `python-editor${base.replace(/\/$/, "")}`;
+};
+
+export class ProjectsDatabase {
+ private constructor(private db: IDBPDatabase) {}
+
+ /**
+ * Opens the database, creating it if needed.
+ *
+ * @throws a VersionError DOMException if the database exists but lacks the
+ * expected stores, which means a newer version of the app created it.
+ */
+ static async open(name: string = databaseName()): Promise {
+ const db = await openDB(name, DB_VERSION, {
+ upgrade(db) {
+ db.createObjectStore(PROJECTS, { keyPath: "id" });
+ const files = db.createObjectStore(FILES, {
+ keyPath: ["projectId", "name"],
+ });
+ files.createIndex("projectId", "projectId");
+ },
+ });
+ for (const store of stores) {
+ if (!db.objectStoreNames.contains(store)) {
+ db.close();
+ throw new DOMException(
+ `Database ${name} has no ${store} store; it was created by an incompatible version of the app`,
+ "VersionError"
+ );
+ }
+ }
+ return new ProjectsDatabase(db);
+ }
+
+ close(): void {
+ this.db.close();
+ }
+
+ /** All projects, most recent first. */
+ async list(): Promise {
+ const all = await this.db.getAll(PROJECTS);
+ return all.sort((a, b) => b.timestamp - a.timestamp);
+ }
+
+ /**
+ * All projects with their file names, most recent first. One transaction
+ * over both stores so the list is consistent and costs two reads, not one
+ * per project.
+ */
+ async listWithFileNames(): Promise {
+ const tx = this.db.transaction(stores, "readonly");
+ const [projects, fileKeys] = await Promise.all([
+ tx.objectStore(PROJECTS).getAll(),
+ tx.objectStore(FILES).getAllKeys(),
+ tx.done,
+ ]);
+ const filesByProject = new Map();
+ for (const [projectId, name] of fileKeys) {
+ const names = filesByProject.get(projectId) ?? [];
+ names.push(name);
+ filesByProject.set(projectId, names);
+ }
+ return projects
+ .sort((a, b) => b.timestamp - a.timestamp)
+ .map((p) => ({ ...p, fileNames: filesByProject.get(p.id) ?? [] }));
+ }
+
+ async get(id: string): Promise {
+ return this.db.get(PROJECTS, id);
+ }
+
+ async mostRecent(): Promise {
+ return (await this.list())[0];
+ }
+
+ async files(id: string): Promise> {
+ const records = await this.db.getAllFromIndex(FILES, "projectId", id);
+ return Object.fromEntries(records.map((r) => [r.name, r.data]));
+ }
+
+ async fileNames(id: string): Promise {
+ const keys = await this.db.getAllKeysFromIndex(FILES, "projectId", id);
+ return keys.map(([, name]) => name);
+ }
+
+ async file(id: string, name: string): Promise {
+ return (await this.db.get(FILES, [id, name]))?.data;
+ }
+
+ /** Creates a project and its files atomically. */
+ async create(
+ meta: ProjectMeta,
+ files: Record
+ ): Promise {
+ const tx = this.db.transaction(stores, "readwrite");
+ await tx.objectStore(PROJECTS).put(meta);
+ const fileStore = tx.objectStore(FILES);
+ for (const [name, data] of Object.entries(files)) {
+ await fileStore.put({ projectId: meta.id, name, data });
+ }
+ await tx.done;
+ }
+
+ /** Applies a set of changes to a project in one transaction. */
+ async apply(id: string, changes: ProjectChanges): Promise {
+ const tx = this.db.transaction(stores, "readwrite");
+ const projects = tx.objectStore(PROJECTS);
+ const existing = await projects.get(id);
+ if (!existing) {
+ throw new Error(`No such project ${id}`);
+ }
+ await projects.put({ ...existing, ...changes.meta, id });
+ const fileStore = tx.objectStore(FILES);
+ for (const [name, data] of Object.entries(changes.writes ?? {})) {
+ await fileStore.put({ projectId: id, name, data });
+ }
+ for (const name of changes.deletes ?? []) {
+ await fileStore.delete([id, name]);
+ }
+ await tx.done;
+ }
+
+ /** Marks a project as the most recent. */
+ async touch(id: string, timestamp: number = Date.now()): Promise {
+ await this.apply(id, { meta: { timestamp } });
+ }
+
+ async duplicate(sourceId: string, meta: ProjectMeta): Promise {
+ await this.create(meta, await this.files(sourceId));
+ }
+
+ async delete(id: string): Promise {
+ const tx = this.db.transaction(stores, "readwrite");
+ await tx.objectStore(PROJECTS).delete(id);
+ const fileStore = tx.objectStore(FILES);
+ for (const key of await fileStore.index("projectId").getAllKeys(id)) {
+ await fileStore.delete(key);
+ }
+ await tx.done;
+ }
+}
diff --git a/src/fs/storage-status.ts b/src/fs/storage-status.ts
new file mode 100644
index 000000000..37b301efd
--- /dev/null
+++ b/src/fs/storage-status.ts
@@ -0,0 +1,52 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { useSyncExternalStore } from "react";
+
+// The storage opens at module load, before React mounts, so the outcome is
+// held here for the UI to read rather than passed through props.
+let versionError: unknown;
+let projectsDatabaseActive = false;
+const listeners = new Set<() => void>();
+
+const notify = () => listeners.forEach((listener) => listener());
+
+/**
+ * Records that the projects database was created by an incompatible version
+ * of the app. Only used on non-public stages, where the fix is to clear it.
+ */
+export const reportStorageVersionError = (error: unknown): void => {
+ versionError = error;
+ notify();
+};
+
+/**
+ * Records that the open project is in the projects database, which outlives
+ * the tab. Not reported for the session-storage fallback or in iframe mode,
+ * where closing the tab still loses the work.
+ */
+export const reportProjectsDatabaseActive = (): void => {
+ projectsDatabaseActive = true;
+ notify();
+};
+
+const subscribe = (listener: () => void) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+};
+
+export const hasStorageVersionError = (): boolean => versionError !== undefined;
+
+export const useStorageVersionError = (): unknown =>
+ useSyncExternalStore(subscribe, () => versionError);
+
+export const useProjectsDatabaseActive = (): boolean =>
+ useSyncExternalStore(subscribe, () => projectsDatabaseActive);
+
+/** For tests. */
+export const resetStorageStatus = (): void => {
+ versionError = undefined;
+ projectsDatabaseActive = false;
+};
diff --git a/src/fs/storage-tests.ts b/src/fs/storage-tests.ts
new file mode 100644
index 000000000..99a8f3c98
--- /dev/null
+++ b/src/fs/storage-tests.ts
@@ -0,0 +1,71 @@
+/**
+ * Behaviour every FSStorage implementation must have. Not a test file itself;
+ * each implementation's test calls it.
+ *
+ * (c) 2021, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { FSStorage } from "./storage";
+
+export const commonStorageTests = (storage: () => FSStorage) => {
+ it("is empty", async () => {
+ expect(await storage().ls()).toEqual([]);
+ });
+
+ it("stores project name", async () => {
+ await storage().setProjectName("foo");
+ expect(await storage().projectName()).toEqual("foo");
+ });
+
+ it("stores files", async () => {
+ await storage().write("test1.py", new Uint8Array([1]));
+ await storage().write("test2.py", new Uint8Array([2]));
+
+ expect(await storage().ls()).toEqual(["test1.py", "test2.py"]);
+ expect(await storage().exists("test1.py")).toEqual(true);
+ expect(await storage().exists("testX.py")).toEqual(false);
+ expect(Array.from(await storage().read("test1.py"))).toEqual([1]);
+ expect(Array.from(await storage().read("test2.py"))).toEqual([2]);
+ });
+
+ it("throws trying to read a non-existent file", async () => {
+ await expect(() => storage().read("test1.py")).rejects.toThrow(
+ /No such file test1.py/
+ );
+ });
+
+ it("removes files", async () => {
+ await storage().write("test1.py", new Uint8Array([1]));
+ await storage().write("test2.py", new Uint8Array([2]));
+
+ await storage().remove("test1.py");
+
+ expect(await storage().exists("test1.py")).toEqual(false);
+ expect(await storage().ls()).toEqual(["test2.py"]);
+ });
+
+ it("clears", async () => {
+ await storage().write("test1.py", new Uint8Array([1]));
+ await storage().write("test2.py", new Uint8Array([2]));
+
+ await storage().clear();
+
+ expect(await storage().exists("test1.py")).toEqual(false);
+ expect(await storage().exists("test2.py")).toEqual(false);
+ expect(await storage().ls()).toEqual([]);
+ });
+};
+
+/**
+ * For storage that lives no longer than the tab and so tracks the dirty flag.
+ */
+export const dirtyFlagTests = (storage: () => FSStorage) => {
+ it("stores dirty flag", async () => {
+ expect(await storage().isDirty()).toEqual(false);
+ await storage().markDirty();
+ expect(await storage().isDirty()).toEqual(true);
+ await storage().clearDirty();
+ expect(await storage().isDirty()).toEqual(false);
+ });
+};
diff --git a/src/fs/storage.test.ts b/src/fs/storage.test.ts
index afbb52d1c..1d4c990cf 100644
--- a/src/fs/storage.test.ts
+++ b/src/fs/storage.test.ts
@@ -6,77 +6,21 @@
import { ConsoleLogging } from "../deployment/default/logging";
import { MockLogging } from "../logging/mock";
import {
- FSStorage,
InMemoryFSStorage,
SessionStorageFSStorage,
SplitStrategyStorage,
} from "./storage";
+import { commonStorageTests, dirtyFlagTests } from "./storage-tests";
const projectName = "projectName";
-const commonStorageTests = (storage: FSStorage) => {
- it("is empty", async () => {
- expect(await storage.ls()).toEqual([]);
- });
-
- it("stores project name", async () => {
- await storage.setProjectName("foo");
- expect(await storage.projectName()).toEqual("foo");
- });
-
- it("stores dirty flag", async () => {
- expect(await storage.isDirty()).toEqual(false);
- await storage.markDirty();
- expect(await storage.isDirty()).toEqual(true);
- await storage.clearDirty();
- expect(await storage.isDirty()).toEqual(false);
- });
-
- it("stores files", async () => {
- await storage.write("test1.py", new Uint8Array([1]));
- await storage.write("test2.py", new Uint8Array([2]));
-
- expect(await storage.ls()).toEqual(["test1.py", "test2.py"]);
- expect(await storage.exists("test1.py")).toEqual(true);
- expect(await storage.exists("testX.py")).toEqual(false);
- expect(await storage.read("test1.py")).toEqual(new Uint8Array([1]));
- expect(await storage.read("test2.py")).toEqual(new Uint8Array([2]));
- });
-
- it("throws trying to read a non-existent file", async () => {
- await expect(() => storage.read("test1.py")).rejects.toThrow(
- /No such file test1.py/
- );
- });
-
- it("removes files", async () => {
- await storage.write("test1.py", new Uint8Array([1]));
- await storage.write("test2.py", new Uint8Array([2]));
-
- await storage.remove("test1.py");
-
- expect(await storage.exists("test1.py")).toEqual(false);
- expect(await storage.ls()).toEqual(["test2.py"]);
- });
-
- it("clears", async () => {
- await storage.write("test1.py", new Uint8Array([1]));
- await storage.write("test2.py", new Uint8Array([2]));
-
- await storage.clear();
-
- expect(await storage.exists("test1.py")).toEqual(false);
- expect(await storage.exists("test2.py")).toEqual(false);
- expect(await storage.ls()).toEqual([]);
- });
-};
-
describe("SessionStorageFSStorage", () => {
const storage = new SessionStorageFSStorage(sessionStorage);
beforeEach(() => {
sessionStorage.clear();
});
- commonStorageTests(storage);
+ commonStorageTests(() => storage);
+ dirtyFlagTests(() => storage);
});
describe("InMemoryFSStorage", () => {
@@ -84,7 +28,8 @@ describe("InMemoryFSStorage", () => {
beforeEach(() => {
storage.clear();
});
- commonStorageTests(storage);
+ commonStorageTests(() => storage);
+ dirtyFlagTests(() => storage);
});
describe("SplitStrategyStorage", () => {
@@ -98,7 +43,8 @@ describe("SplitStrategyStorage", () => {
storage.clear();
sessionStorage.clear();
});
- commonStorageTests(storage);
+ commonStorageTests(() => storage);
+ dirtyFlagTests(() => storage);
it("initializes from session storage", async () => {
const memory = new InMemoryFSStorage(projectName);
diff --git a/src/fs/storage.ts b/src/fs/storage.ts
index 90ed81333..6ebc4a28c 100644
--- a/src/fs/storage.ts
+++ b/src/fs/storage.ts
@@ -23,8 +23,10 @@ export interface FSStorage {
projectName(): Promise;
clear(): Promise;
/**
- * We persist the dirty flag so that we know whether the user
- * had previously made changes after a restore from storage.
+ * Whether the user has changed the project since the last hex save, used
+ * to warn before their work is lost. Storage that lives no longer than the
+ * tab persists it so the warning survives a reload; storage that outlives
+ * the tab has nothing to warn about and always reports false.
*/
markDirty(): Promise;
clearDirty(): Promise;
@@ -169,6 +171,18 @@ export class SessionStorageFSStorage implements FSStorage {
this.storage.clear();
}
+ /**
+ * Removes the file system's keys and nothing else: session storage also
+ * holds session settings.
+ */
+ async removeAll(): Promise {
+ for (const key of Object.keys(this.storage)) {
+ if (key.startsWith(fsFilesPrefix) || key.startsWith(fsMetadataPrefix)) {
+ this.storage.removeItem(key);
+ }
+ }
+ }
+
async markDirty(): Promise {
this.storage.setItem(dirtyKey, "true");
}
@@ -189,17 +203,25 @@ export class SessionStorageFSStorage implements FSStorage {
*/
export class SplitStrategyStorage implements FSStorage {
private initialized: Promise;
+ private secondary: FSStorage | undefined;
+ /**
+ * @param secondary The persistent copy, or a promise of one for storage
+ * that takes time to open. Every operation waits for it.
+ */
constructor(
private primary: FSStorage,
- private secondary: FSStorage | undefined,
+ secondary: FSStorage | undefined | Promise,
private log: Logging
) {
- this.initialized = secondary
- ? this.secondaryErrorHandle(async () => {
- await initializeFromStorage(secondary, primary);
- })
- : Promise.resolve();
+ this.initialized = Promise.resolve(secondary).then((resolved) => {
+ this.secondary = resolved;
+ return resolved
+ ? this.secondaryErrorHandle(async () => {
+ await initializeFromStorage(resolved, primary);
+ })
+ : undefined;
+ });
}
async ls() {
diff --git a/src/iframe-mode-hooks.tsx b/src/iframe-mode-hooks.tsx
new file mode 100644
index 000000000..12c81a9d3
--- /dev/null
+++ b/src/iframe-mode-hooks.tsx
@@ -0,0 +1,16 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { createContext, useContext } from "react";
+
+const IframeModeContext = createContext(false);
+
+/**
+ * Whether the app is embedded in controller mode (classroom). The embedding
+ * page then owns the project and there are no pages besides the editor.
+ */
+export const IframeModeProvider = IframeModeContext.Provider;
+
+export const useIframeMode = (): boolean => useContext(IframeModeContext);
diff --git a/src/pages/ActionCard.tsx b/src/pages/ActionCard.tsx
new file mode 100644
index 000000000..7c3493e5a
--- /dev/null
+++ b/src/pages/ActionCard.tsx
@@ -0,0 +1,46 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ Card,
+ CardBody,
+ darkSurface,
+ Icon,
+ LinkBox,
+ LinkOverlayButton,
+ VStack,
+} from "@microbit/ui";
+import { ReactNode } from "react";
+import { IconType } from "react-icons";
+
+interface ActionCardProps {
+ onClick: () => void;
+ icon: IconType;
+ children: ReactNode;
+}
+
+/**
+ * A card-sized button among the project cards, for creating a project or
+ * seeing them all.
+ */
+const ActionCard = ({ onClick, icon, children }: ActionCardProps) => (
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+);
+
+export default ActionCard;
diff --git a/src/pages/BackArrow.tsx b/src/pages/BackArrow.tsx
new file mode 100644
index 000000000..ea974eaf2
--- /dev/null
+++ b/src/pages/BackArrow.tsx
@@ -0,0 +1,27 @@
+/**
+ * (c) 2024, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { css } from "@microbit/ui";
+
+/** Back arrow glyph, sized like an icon (1em, currentColor). */
+const BackArrow = () => (
+
+
+
+);
+
+export default BackArrow;
diff --git a/src/pages/DefaultPageLayout.tsx b/src/pages/DefaultPageLayout.tsx
new file mode 100644
index 000000000..3c3fd3ade
--- /dev/null
+++ b/src/pages/DefaultPageLayout.tsx
@@ -0,0 +1,123 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Box, Button, css, darkSurface, HStack, VStack } from "@microbit/ui";
+import { ReactNode } from "react";
+import { FormattedMessage } from "react-intl";
+import { Link as RouterLink, useNavigate } from "react-router";
+import { styled } from "styled-system/jsx";
+import { useDeployment } from "../deployment";
+import { flags } from "../flags";
+import SettingsMenu from "../settings/SettingsMenu";
+import { createHomePageUrl } from "../urls";
+import HelpMenu from "../workbench/HelpMenu";
+import BackArrow from "./BackArrow";
+import PageReleaseNotice from "./PageReleaseNotice";
+
+interface DefaultPageLayoutProps {
+ children: ReactNode;
+ /** A back-to-home button in place of the branding. */
+ backToHome?: boolean;
+}
+
+/**
+ * Organisation logo, divider, product wordmark, as ml-trainer's header.
+ * Sizes are the prototype's; the OSS build has only the wordmark.
+ */
+const Branding = () => {
+ const { AppLogo, OrgLogo } = useDeployment();
+ return (
+
+ {OrgLogo ? (
+ <>
+
+
+ >
+ ) : null}
+ {AppLogo ? : null}
+
+ );
+};
+
+// The pages copy ml-trainer's header, whose buttons are the base preset's
+// lg size with a 24px icon. This app's dense preset shrinks lg to 42px
+// and the icon to 18px, so the sizes are pinned here. See the dense preset
+// discussion in docs/multi-project-plan.md.
+const headerButtonCss = { h: "48px", minW: "48px", fontSize: "24px" };
+
+const BackToHomeButton = () => {
+ const navigate = useNavigate();
+ return (
+ }
+ onPress={() => void navigate(createHomePageUrl())}
+ >
+
+
+ );
+};
+
+/**
+ * Full-height page with the brand header and a scrolling body, for the
+ * pages outside the editor.
+ */
+const DefaultPageLayout = ({
+ children,
+ backToHome = false,
+}: DefaultPageLayoutProps) => (
+
+
+ {backToHome ? : }
+
+
+
+
+
+ {flags.betaNotice && }
+
+ {children}
+
+
+);
+
+export default DefaultPageLayout;
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
new file mode 100644
index 000000000..534952893
--- /dev/null
+++ b/src/pages/HomePage.tsx
@@ -0,0 +1,258 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ Button,
+ css,
+ Icon,
+ IconButton,
+ Text,
+ TooltipButton,
+ useBreakpointValue,
+ VStack,
+} from "@microbit/ui";
+import { CarouselRow } from "@microbit/ui-carousel";
+import { NameProjectDialog, ProjectCard } from "@microbit/ui-patterns";
+import { ChangeEvent, useCallback, useRef, useState } from "react";
+import {
+ RiAddLine,
+ RiFolderOpenLine,
+ RiInformationLine,
+ RiUpload2Line,
+} from "react-icons/ri";
+import { FormattedMessage, useIntl } from "react-intl";
+import { Link as RouterLink, useNavigate } from "react-router";
+import FileDropTarget from "../common/FileDropTarget";
+import { useDeployment } from "../deployment";
+import { useSettings } from "../settings/settings";
+import { createProjectsPageUrl } from "../urls";
+import ActionCard from "./ActionCard";
+import DefaultPageLayout from "./DefaultPageLayout";
+import ProjectIcon from "./ProjectIcon";
+import HomepageBanner from "./HomepageBanner";
+import HomepageFooter from "./HomepageFooter";
+import {
+ useImportProjectFiles,
+ usePageProjects,
+ useProjectPageActions,
+} from "./project-page-actions";
+import {
+ createHelpCards,
+ createLessonCards,
+ createProjectIdeaCards,
+} from "./resource-cards";
+
+const numCardsDisplayed = 10;
+
+const HomePage = () => {
+ const intl = useIntl();
+ const [{ languageId }] = useSettings();
+ const brand = useDeployment();
+ const importFiles = useImportProjectFiles();
+ const handleDrop = useCallback(
+ (files: File[]) => void importFiles(files, "drop"),
+ [importFiles]
+ );
+ return (
+
+
+
+
+ }
+ navigation
+ />
+ }
+ navigation
+ />
+ }
+ navigation
+ />
+
+
+
+ );
+};
+
+const ProjectsRow = () => {
+ const tooltipPlacement = useBreakpointValue<"bottom" | "right">({
+ base: "bottom",
+ sm: "right",
+ });
+ const projects = usePageProjects();
+ const { actions, open, create } = useProjectPageActions("home", projects);
+ const cards = [
+ ,
+ ...projects.slice(0, numCardsDisplayed).map((project) => (
+
+
+
+ )),
+ ...(projects.length > numCardsDisplayed
+ ? [ ]
+ : []),
+ ];
+ return (
+ <>
+ {actions.dialogs}
+ }
+ titleSuffix={
+
+
+
+
+
+ }
+ >
+
+
+ }
+ actions={[
+ ,
+ ,
+ ]}
+ navigation
+ />
+ >
+ );
+};
+
+const NewProjectCard = ({
+ onCreate,
+}: {
+ onCreate: (name: string) => Promise;
+}) => {
+ const intl = useIntl();
+ const [isOpen, setIsOpen] = useState(false);
+ const close = useCallback(() => setIsOpen(false), []);
+ const handleSave = useCallback(
+ (name: string) => {
+ setIsOpen(false);
+ void onCreate(name);
+ },
+ [onCreate]
+ );
+ return (
+ <>
+ }
+ />
+ setIsOpen(true)} icon={RiAddLine}>
+
+
+ >
+ );
+};
+
+/**
+ * Chooses files to import as a new project. Icon only where the row's
+ * heading leaves no room for the label.
+ */
+const ImportProjectButton = () => {
+ const intl = useIntl();
+ const importFiles = useImportProjectFiles();
+ const inputRef = useRef(null);
+ const choose = useCallback(() => inputRef.current?.click(), []);
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ const files = Array.from(e.target.files ?? []);
+ // Clear the input so choosing the same file again triggers a change.
+ e.target.value = "";
+ if (files.length > 0) {
+ void importFiles(files, "file_picker");
+ }
+ },
+ [importFiles]
+ );
+ const label = intl.formatMessage({ id: "import-file-action" });
+ return (
+ <>
+
+
+
+
+ }
+ onPress={choose}
+ css={{ display: { base: "none", sm: "inline-flex" } }}
+ >
+ {label}
+
+ >
+ );
+};
+
+const ViewAllProjectsCard = () => {
+ const navigate = useNavigate();
+ return (
+ void navigate(createProjectsPageUrl())}
+ icon={RiFolderOpenLine}
+ >
+
+
+ );
+};
+
+const ViewAllProjectsLink = () => (
+
+
+
+);
+
+export default HomePage;
diff --git a/src/pages/HomepageBanner.tsx b/src/pages/HomepageBanner.tsx
new file mode 100644
index 000000000..23ef268ac
--- /dev/null
+++ b/src/pages/HomepageBanner.tsx
@@ -0,0 +1,82 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ darkSurface,
+ Heading,
+ HStack,
+ LinkButton,
+ Text,
+ VStack,
+} from "@microbit/ui";
+import { FormattedMessage } from "react-intl";
+import bannerBackground from "theme-package/images/banner-background.svg";
+import { useDeployment } from "../deployment";
+
+// Landscape phones.
+const shortHeight = "@media (max-height: 700px)";
+
+const HomepageBanner = () => {
+ const { userGuideLink } = useDeployment();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {userGuideLink && (
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default HomepageBanner;
diff --git a/src/pages/HomepageFooter.tsx b/src/pages/HomepageFooter.tsx
new file mode 100644
index 000000000..ffbb16cab
--- /dev/null
+++ b/src/pages/HomepageFooter.tsx
@@ -0,0 +1,79 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Button, HStack, Link, Text } from "@microbit/ui";
+import { FormattedMessage } from "react-intl";
+import { styled } from "styled-system/jsx";
+import { useDeployment } from "../deployment";
+
+const fontSize = { base: "sm", sm: "md" };
+
+/**
+ * Copyright and legal links, as ml-trainer's footer without its app store
+ * badges.
+ */
+const HomepageFooter = () => {
+ const { copyrightHolder, privacyPolicyLink, termsOfUseLink, compliance } =
+ useDeployment();
+ return (
+
+
+ {copyrightHolder && (
+
+ © {copyrightHolder}
+
+ )}
+ {compliance.manageCookies && (
+
+
+
+ )}
+ {privacyPolicyLink && (
+
+
+
+ )}
+ {termsOfUseLink && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default HomepageFooter;
diff --git a/src/pages/PageReleaseNotice.tsx b/src/pages/PageReleaseNotice.tsx
new file mode 100644
index 000000000..e78258c76
--- /dev/null
+++ b/src/pages/PageReleaseNotice.tsx
@@ -0,0 +1,57 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Button, darkSurface, Text } from "@microbit/ui";
+import { useState } from "react";
+import { RiFeedbackFill } from "react-icons/ri";
+import { styled } from "styled-system/jsx";
+import FeedbackForm from "../workbench/FeedbackForm";
+
+/**
+ * The beta notice as a band under the page header, as ml-trainer shows it.
+ * The editor keeps its own, shorter one at the foot of the sidebar. Both are
+ * English only: they only show on non-public stages.
+ */
+const PageReleaseNotice = () => {
+ const [feedbackOpen, setFeedbackOpen] = useState(false);
+ return (
+ <>
+ setFeedbackOpen(false)}
+ />
+
+
+ This is a beta version and is subject to change without notice
+
+ }
+ variant="link"
+ size="xs"
+ css={{ color: "white", fontWeight: "bold", p: "1" }}
+ onPress={() => setFeedbackOpen(true)}
+ >
+ Feedback
+
+
+ >
+ );
+};
+
+export default PageReleaseNotice;
diff --git a/src/pages/ProjectIcon.tsx b/src/pages/ProjectIcon.tsx
new file mode 100644
index 000000000..220a8a8cd
--- /dev/null
+++ b/src/pages/ProjectIcon.tsx
@@ -0,0 +1,34 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Box } from "@microbit/ui";
+import PythonLogo from "../common/PythonLogo";
+
+interface ProjectIconProps {
+ /**
+ * The projects page cards carry a selection checkbox in the top-left
+ * corner, so the glyph drops below it rather than sharing the corner.
+ */
+ hasCheckbox?: boolean;
+}
+
+/** The picture on a project card: the Python logo in the top-left corner. */
+const ProjectIcon = ({ hasCheckbox = false }: ProjectIconProps) => (
+
+
+
+);
+
+export default ProjectIcon;
diff --git a/src/pages/ProjectsPage.tsx b/src/pages/ProjectsPage.tsx
new file mode 100644
index 000000000..46189ef17
--- /dev/null
+++ b/src/pages/ProjectsPage.tsx
@@ -0,0 +1,258 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ Box,
+ css,
+ cx,
+ Flex,
+ Grid,
+ Heading,
+ HStack,
+ Slide,
+ Stack,
+ Text,
+ useBreakpointValue,
+ VStack,
+} from "@microbit/ui";
+import {
+ defaultSortDirection,
+ ProjectCard,
+ ProjectSearchInput,
+ ProjectSortDirection,
+ ProjectSortField,
+ ProjectSortInput,
+ ProjectsToolbar,
+ ProjectsToolbarHandle,
+ rankProjects,
+ sortProjects,
+ useProjectSelection,
+} from "@microbit/ui-patterns";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { FormattedMessage, useIntl } from "react-intl";
+import FileDropTarget from "../common/FileDropTarget";
+import { useLogging } from "../logging/logging-hooks";
+import DefaultPageLayout from "./DefaultPageLayout";
+import ProjectIcon from "./ProjectIcon";
+import {
+ PageProject,
+ usePageProjects,
+ useProjectPageActions,
+ useImportProjectFiles,
+} from "./project-page-actions";
+
+const fileNames = (project: PageProject) => project.fileNames;
+
+const ProjectsPage = () => {
+ const projects = usePageProjects();
+ const logging = useLogging();
+ const intl = useIntl();
+ const mobileIconOnly = useBreakpointValue({ base: true, md: false });
+
+ const selection = useProjectSelection(projects);
+ const { selectedIds } = selection;
+ const { actions, open } = useProjectPageActions(
+ "projects",
+ projects,
+ selectedIds
+ );
+
+ const [field, setField] = useState("timestamp");
+ const [direction, setDirection] = useState("desc");
+ const handleFieldChange = (next: ProjectSortField) => {
+ const nextDirection = defaultSortDirection(next);
+ setField(next);
+ setDirection(nextDirection);
+ logging.event({
+ type: "project_sort",
+ detail: { field: next, direction: nextDirection },
+ });
+ };
+ const toggleDirection = () => {
+ const next = direction === "asc" ? "desc" : "asc";
+ setDirection(next);
+ logging.event({ type: "project_sort", detail: { field, direction: next } });
+ };
+
+ const importFiles = useImportProjectFiles();
+ const handleDrop = useCallback(
+ (files: File[]) => void importFiles(files, "drop"),
+ [importFiles]
+ );
+
+ const [query, setQuery] = useState("");
+ const handleQueryChange = useCallback(
+ (value: string) => {
+ if (value.trim()) {
+ selection.clear();
+ }
+ setQuery(value);
+ },
+ [selection]
+ );
+ // One project_search per intentional search, not per keystroke.
+ useEffect(() => {
+ if (!query.trim()) {
+ return;
+ }
+ const handle = setTimeout(() => {
+ logging.event({ type: "project_search" });
+ }, 400);
+ return () => clearTimeout(handle);
+ }, [logging, query]);
+
+ const shown = useMemo(
+ () =>
+ query.trim()
+ ? rankProjects(projects, query, fileNames)
+ : sortProjects(projects, field, direction, intl.locale),
+ [direction, field, intl.locale, projects, query]
+ );
+
+ const desktopToolbarRef = useRef(null);
+ const mobileToolbarRef = useRef(null);
+ const handleSkipToToolbar = useCallback(() => {
+ if (!desktopToolbarRef.current?.focus()) {
+ mobileToolbarRef.current?.focus();
+ }
+ }, []);
+
+ return (
+ <>
+ {actions.dialogs}
+
+
+
+
+
+
+
+
+
+ {selection.hasSelection && (
+
+
+
+ )}
+
+
+ {shown.length > 0 ? (
+
+ {shown.map((project) => (
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ProjectsPage;
diff --git a/src/pages/ResourceCard.tsx b/src/pages/ResourceCard.tsx
new file mode 100644
index 000000000..0137bf238
--- /dev/null
+++ b/src/pages/ResourceCard.tsx
@@ -0,0 +1,72 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ AspectRatio,
+ Box,
+ Heading,
+ Image,
+ LinkBox,
+ LinkOverlay,
+ VStack,
+} from "@microbit/ui";
+import { ReactNode } from "react";
+
+interface ResourceCardProps {
+ aspectRatio?: number;
+ /** Spacing scale units around the image, for artwork without a margin. */
+ imagePadding?: number;
+ url: string;
+ imgSrc: string;
+ title: ReactNode;
+}
+
+/**
+ * A card linking out to a resource on another site.
+ */
+const ResourceCard = ({
+ aspectRatio = 4 / 3,
+ imagePadding,
+ imgSrc,
+ url,
+ title,
+}: ResourceCardProps) => (
+
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+);
+
+export default ResourceCard;
diff --git a/src/pages/project-page-actions.tsx b/src/pages/project-page-actions.tsx
new file mode 100644
index 000000000..49fc5912b
--- /dev/null
+++ b/src/pages/project-page-actions.tsx
@@ -0,0 +1,133 @@
+/**
+ * What the project cards and toolbar do on the home and projects pages.
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { ProjectSummary, useProjectActions } from "@microbit/ui-patterns";
+import { useCallback, useMemo } from "react";
+import { useIntl } from "react-intl";
+import { useNavigate } from "react-router";
+import useActionFeedback from "../common/use-action-feedback";
+import { useLogging } from "../logging/logging-hooks";
+import { useProjectImporter } from "../project/project-hooks";
+import { ImportSource } from "../project/project-import";
+import { useProjectList, useProjects } from "../project/projects-hooks";
+import {
+ isQuotaExceededError,
+ useShowStorageError,
+} from "../project/storage-error-toast";
+import { createEditorUrl } from "../urls";
+
+/** Which page an action happened on, for analytics. */
+export type ProjectSurface = "home" | "projects";
+
+export interface PageProject extends ProjectSummary {
+ fileNames: string[];
+}
+
+/**
+ * The projects as the shared components want them: every one has a name.
+ */
+export const usePageProjects = (): PageProject[] => {
+ const list = useProjectList();
+ const intl = useIntl();
+ const untitled = intl.formatMessage({ id: "untitled-project" });
+ return useMemo(
+ () => list.map((p) => ({ ...p, name: p.name ?? untitled })),
+ [list, untitled]
+ );
+};
+
+export const useProjectPageActions = (
+ surface: ProjectSurface,
+ projects: PageProject[],
+ selectedIds?: string[]
+) => {
+ const store = useProjects();
+ const logging = useLogging();
+ const navigate = useNavigate();
+ const actionFeedback = useActionFeedback();
+
+ const showStorageError = useShowStorageError();
+ const attempt = useCallback(
+ async (action: () => Promise) => {
+ try {
+ await action();
+ } catch (e) {
+ if (isQuotaExceededError(e)) {
+ showStorageError(e);
+ } else {
+ actionFeedback.unexpectedError(e);
+ }
+ }
+ },
+ [actionFeedback, showStorageError]
+ );
+
+ const actions = useProjectActions({
+ projects,
+ selectedIds,
+ onRename: (id, name) =>
+ attempt(async () => {
+ logging.event({ type: "project_rename", detail: { surface } });
+ await store.rename(id, name);
+ }),
+ onDuplicate: (id, name) =>
+ attempt(async () => {
+ logging.event({ type: "project_duplicate", detail: { surface } });
+ await store.duplicate(id, name);
+ }),
+ onDelete: (ids) =>
+ attempt(async () => {
+ logging.event({
+ type: "project_delete",
+ detail: { surface, count: ids.length },
+ });
+ await store.delete(ids);
+ }),
+ });
+
+ const open = useCallback(
+ (id: string) =>
+ attempt(async () => {
+ logging.event({ type: "project_open", detail: { surface } });
+ await store.open(id);
+ await navigate(createEditorUrl());
+ }),
+ [attempt, logging, navigate, store, surface]
+ );
+
+ const create = useCallback(
+ (name: string) =>
+ attempt(async () => {
+ logging.event({ type: "project_create", detail: { surface } });
+ await store.create(name);
+ await navigate(createEditorUrl());
+ }),
+ [attempt, logging, navigate, store, surface]
+ );
+
+ return { actions, open, create };
+};
+
+/**
+ * Imports files from the home page as a new project and opens the editor on
+ * it. Errors are reported as toasts by the importer.
+ */
+export const useImportProjectFiles = (): ((
+ files: File[],
+ source: ImportSource
+) => Promise) => {
+ const importer = useProjectImporter();
+ const navigate = useNavigate();
+ return useCallback(
+ async (files: File[], source: ImportSource) => {
+ if (await importer.importAsNewProject(files, source)) {
+ await navigate(createEditorUrl());
+ }
+ },
+ [importer, navigate]
+ );
+};
diff --git a/src/pages/resource-cards.tsx b/src/pages/resource-cards.tsx
new file mode 100644
index 000000000..169542b65
--- /dev/null
+++ b/src/pages/resource-cards.tsx
@@ -0,0 +1,115 @@
+/**
+ * The cards linking to microbit.org and the support site from the home page.
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { IntlShape } from "react-intl";
+import accessibilityImage from "theme-package/images/accessibility.svg";
+import animatedAnimals from "theme-package/images/animated-animals.jpg";
+import beatingHeart from "theme-package/images/beating-heart.jpg";
+import emotionBadge from "theme-package/images/emotion-badge.png";
+import firstLessonsImage from "theme-package/images/first-lessons-python.svg";
+import flashingEmotions from "theme-package/images/flashing-emotions.jpg";
+import getSilly from "theme-package/images/get-silly.png";
+import heart from "theme-package/images/heart.png";
+import troubleshootingImage from "theme-package/images/troubleshooting.svg";
+import userGuideImage from "theme-package/images/user-guide.svg";
+import { BrandConfig } from "../deployment";
+import { microbitOrgLessonUrl, microbitOrgProjectUrl } from "../external-links";
+import ResourceCard from "./ResourceCard";
+
+interface ProjectIdea {
+ titleId: string;
+ /** The "make it: code it" project's slug on microbit.org. */
+ slug: string;
+ imgSrc: string;
+}
+
+const projectIdeas: ProjectIdea[] = [
+ { titleId: "project-idea-heart-title", slug: "heart", imgSrc: heart },
+ {
+ titleId: "project-idea-beating-heart-title",
+ slug: "beating-heart",
+ imgSrc: beatingHeart,
+ },
+ {
+ titleId: "project-idea-animated-animals-title",
+ slug: "animated-animals",
+ imgSrc: animatedAnimals,
+ },
+ {
+ titleId: "project-idea-emotion-badge-title",
+ slug: "emotion-badge",
+ imgSrc: emotionBadge,
+ },
+ {
+ titleId: "project-idea-get-silly-title",
+ slug: "get-silly",
+ imgSrc: getSilly,
+ },
+ {
+ titleId: "project-idea-flashing-emotions-title",
+ slug: "flashing-emotions",
+ imgSrc: flashingEmotions,
+ },
+];
+
+export const createProjectIdeaCards = (intl: IntlShape, languageId: string) =>
+ projectIdeas.map((idea) => (
+
+ ));
+
+export const createLessonCards = (intl: IntlShape) => [
+ ,
+];
+
+type HelpLinks = Pick<
+ BrandConfig,
+ "userGuideLink" | "supportLink" | "accessibilityLink"
+>;
+
+/**
+ * Help cards for the links the brand supplies; a deployment without one
+ * has no card for it.
+ */
+export const createHelpCards = (
+ intl: IntlShape,
+ { userGuideLink, supportLink, accessibilityLink }: HelpLinks
+) =>
+ [
+ { titleId: "user-guide", url: userGuideLink, imgSrc: userGuideImage },
+ {
+ titleId: "troubleshooting-resource-title",
+ url: supportLink,
+ imgSrc: troubleshootingImage,
+ },
+ {
+ titleId: "accessibility-resource-title",
+ url: accessibilityLink,
+ imgSrc: accessibilityImage,
+ },
+ ]
+ .filter((help): help is typeof help & { url: string } => !!help.url)
+ .map((help) => (
+
+ ));
diff --git a/src/project/OpenButton.tsx b/src/project/AddFilesButton.tsx
similarity index 59%
rename from src/project/OpenButton.tsx
rename to src/project/AddFilesButton.tsx
index 1287dd93b..faa928492 100644
--- a/src/project/OpenButton.tsx
+++ b/src/project/AddFilesButton.tsx
@@ -3,35 +3,39 @@
*
* SPDX-License-Identifier: MIT
*/
-import { RiFolderOpenLine } from "react-icons/ri";
+import { RiFileAddLine } from "react-icons/ri";
import { useIntl } from "react-intl";
import { CollapsibleButtonComposableProps } from "../common/CollapsibleButton";
import FileInputButton from "../common/FileInputButton";
import { useProjectActions } from "./project-hooks";
-interface OpenButtonProps extends CollapsibleButtonComposableProps {}
+interface AddFilesButtonProps extends CollapsibleButtonComposableProps {}
/**
* Open HEX button, with an associated input field.
*/
-const OpenButton = (props: OpenButtonProps) => {
+/**
+ * Adds files to the open project from the Files tab. A hex file becomes a
+ * new project instead.
+ */
+const AddFilesButton = (props: AddFilesButtonProps) => {
const actions = useProjectActions();
const intl = useIntl();
return (
}
+ icon={ }
tooltip={intl.formatMessage({
- id: "open-hover",
+ id: "add-files-hover",
})}
/>
);
};
-export default OpenButton;
+export default AddFilesButton;
diff --git a/src/project/ChooseMainScriptQuestion.test.tsx b/src/project/ChooseMainScriptQuestion.test.tsx
deleted file mode 100644
index 78278d0e7..000000000
--- a/src/project/ChooseMainScriptQuestion.test.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-/**
- * (c) 2021, Micro:bit Educational Foundation and contributors
- *
- * SPDX-License-Identifier: MIT
- */
-import { render, screen } from "@testing-library/react";
-import { ClassifiedFileInput, FileOperation } from "./changes";
-import ChooseMainScriptQuestion, {
- summarizeChange,
-} from "./ChooseMainScriptQuestion";
-import { stubIntl as intl } from "../messages/testing";
-import FixedTranslationProvider from "../messages/FixedTranslationProvider";
-import { vi } from "vitest";
-
-describe("ChooseMainScriptQuestion", () => {
- const data = () => Promise.resolve(new Uint8Array([0]));
-
- describe("component", () => {
- const setValue = vi.fn();
- const setValidationResult = vi.fn();
- const currentFiles = new Set(["main.py", "magic.py"]);
-
- afterEach(() => {
- setValidationResult.mockClear();
- setValue.mockClear();
- });
-
- const renderComponent = (
- inputs: ClassifiedFileInput[],
- choice: string | undefined
- ) => {
- return render(
-
- ({ ok: true })}
- />
-
- );
- };
-
- it("main.py replacement", async () => {
- const inputs: ClassifiedFileInput[] = [
- {
- data,
- module: false,
- script: true,
- name: "main.py",
- },
- ];
- renderComponent(inputs, "samplefile.py");
- const items = (await screen.findAllByTestId("change")).map(
- (x) => x.textContent
- );
-
- expect(items).toEqual(["Replace main code with main.py"]);
- // We don't use a list for simple cases.
- expect(screen.queryAllByRole("listitem")).toEqual([]);
- });
-
- it("two options for main.py case", async () => {
- const inputs: ClassifiedFileInput[] = [
- {
- data,
- module: false,
- script: true,
- name: "a.py",
- },
- {
- data,
- module: false,
- script: true,
- name: "b.py",
- },
- ];
- const result = renderComponent(inputs, "a.py");
- const findAllListItems = async () =>
- Array.from((await result.findAllByRole("list"))[0].childNodes).map(
- (x) => x.firstChild!.firstChild!.firstChild?.textContent
- );
- expect(await findAllListItems()).toEqual([
- "Replace main code with a.py",
- "Add file b.py",
- ]);
- });
- });
-
- describe("summarizeChange", () => {
- it("most common scenario is special cased to refer to main code", () => {
- expect(
- summarizeChange(intl, {
- operation: FileOperation.REPLACE,
- module: false,
- script: true,
- data,
- source: "somefile.py",
- target: "main.py",
- })
- ).toEqual("choose-main-source-replace-main-code");
- });
-
- it("names modules as such", () => {
- expect(
- summarizeChange(intl, {
- operation: FileOperation.ADD,
- module: true,
- script: false,
- data,
- source: "module.py",
- target: "module.py",
- })
- ).toEqual("choose-main-add-module");
- });
-
- it("non-main non-module replace", () => {
- expect(
- summarizeChange(intl, {
- operation: FileOperation.REPLACE,
- module: false,
- script: false,
- data,
- source: "dave.py",
- target: "dave.py",
- })
- ).toEqual("choose-main-replace-file");
- });
-
- it("non-python add", () => {
- expect(
- summarizeChange(intl, {
- operation: FileOperation.ADD,
- module: false,
- script: false,
- data,
- source: "data.dat",
- target: "data.dat",
- })
- ).toEqual("choose-main-add-file");
- });
- });
-});
diff --git a/src/project/ChooseMainScriptQuestion.tsx b/src/project/ChooseMainScriptQuestion.tsx
deleted file mode 100644
index 2a3ea98c8..000000000
--- a/src/project/ChooseMainScriptQuestion.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-/**
- * (c) 2021, Micro:bit Educational Foundation and contributors
- *
- * SPDX-License-Identifier: MIT
- */
-import {
- IconButton,
- ListItem,
- MenuItemOption,
- MenuList,
- MenuOptionGroup,
- MenuTrigger,
- Text,
- UnorderedList,
-} from "@microbit/ui";
-import { ReactNode } from "react";
-import { sortBy } from "../common/sort-util";
-import { RiFileSettingsLine } from "react-icons/ri";
-import { IntlShape, useIntl } from "react-intl";
-import { HStack } from "styled-system/jsx";
-import { InputDialogBody } from "../common/InputDialog";
-import { MAIN_FILE } from "../fs/fs";
-import { ClassifiedFileInput, FileOperation } from "./changes";
-import { MainScriptChoice } from "./project-actions";
-
-interface ChooseMainScriptQuestionProps
- extends InputDialogBody {
- currentFiles: Set;
- inputs: ClassifiedFileInput[];
-}
-
-const ChooseMainScriptQuestion = ({
- currentFiles,
- inputs,
- value,
- setValue,
-}: ChooseMainScriptQuestionProps) => {
- const changes = sortBy(
- findProposedChanges(currentFiles, inputs, value.main),
- (c) => c.target !== MAIN_FILE,
- (c) => c.source
- );
- return changes.length > 1 ? (
-
- {changes.map((c) => (
-
-
-
- ))}
-
- ) : (
-
- );
-};
-
-interface ProposedChange {
- source: string;
- target: string;
- script: boolean;
- module: boolean;
- operation: FileOperation;
- data: () => Promise | Promise;
-}
-
-const findProposedChanges = (
- currentFiles: Set,
- inputs: ClassifiedFileInput[],
- main: string | undefined
-): ProposedChange[] => {
- return inputs.map((f) => {
- const target = f.name === main ? "main.py" : f.name;
- return {
- source: f.name,
- data: f.data,
- target,
- module: f.module,
- script: f.script,
- operation: currentFiles.has(target)
- ? FileOperation.REPLACE
- : FileOperation.ADD,
- };
- });
-};
-
-// Exposed for testing.
-export const summarizeChange = (
- intl: IntlShape,
- change: ProposedChange
-): string => {
- const changeType =
- change.operation === FileOperation.REPLACE ? "replace" : "add";
- const moduleNature = change.module ? "module" : "file";
- if (change.source === change.target && change.target !== MAIN_FILE) {
- return intl.formatMessage(
- { id: `choose-main-${changeType}-${moduleNature}` },
- { name: change.target }
- );
- }
- const targetType = change.target === MAIN_FILE ? "main-code" : `file`;
- const id = `choose-main-source-${changeType}-${targetType}`;
- return intl.formatMessage(
- { id },
- {
- source: change.source,
- target: change.target,
- }
- );
-};
-
-interface FileChangeRowProps {
- change: ProposedChange;
- setValue: (value: MainScriptChoice) => void;
- currentFiles: Set;
-}
-
-const FileChangeRow = ({
- change,
- setValue,
- currentFiles,
-}: FileChangeRowProps) => {
- const isMainScript = change.target === MAIN_FILE;
- const intl = useIntl();
- return (
-
-
- {summarizeChange(intl, change)}
-
- {change.script && change.source !== MAIN_FILE && (
-
-
- setValue({ main: value === "main" ? change.source : undefined })
- }
- >
-
- {summarizeChange(intl, {
- ...change,
- target: MAIN_FILE,
- operation: currentFiles.has(MAIN_FILE)
- ? FileOperation.REPLACE
- : FileOperation.ADD,
- })}
-
-
- {summarizeChange(intl, {
- ...change,
- target: change.source,
- operation: currentFiles.has(change.source)
- ? FileOperation.REPLACE
- : FileOperation.ADD,
- })}
-
-
-
- )}
-
- );
-};
-
-const OptionsMenu = ({ children }: { children: ReactNode }) => {
- const intl = useIntl();
- return (
-
-
-
-
- {children}
-
- );
-};
-
-export default ChooseMainScriptQuestion;
diff --git a/src/project/ProjectActionBar.tsx b/src/project/ProjectActionBar.tsx
index 4a21842f3..20b680ace 100644
--- a/src/project/ProjectActionBar.tsx
+++ b/src/project/ProjectActionBar.tsx
@@ -6,10 +6,9 @@
import { useMediaQuery } from "@microbit/ui";
import SendButton from "./SendButton";
import SaveMenuButton from "./SaveMenuButton";
-import OpenButton from "./OpenButton";
import { widthXl } from "../common/media-queries";
import React, { ForwardedRef } from "react";
-import { HStack, styled } from "styled-system/jsx";
+import { styled } from "styled-system/jsx";
import { SystemStyleObject } from "styled-system/types";
interface ProjectActionBarProps {
@@ -36,11 +35,7 @@ const ProjectActionBar = React.forwardRef(
css={cssProp}
>
-
-
- {/* Min-width to avoid collapsing when out of space. Needs some work on responsiveness of the action bar. */}
-
-
+
);
}
diff --git a/src/project/ProjectArea.tsx b/src/project/ProjectArea.tsx
index 7e0ac3b37..b2cefeedb 100644
--- a/src/project/ProjectArea.tsx
+++ b/src/project/ProjectArea.tsx
@@ -3,14 +3,14 @@
*
* SPDX-License-Identifier: MIT
*/
-import { List, ListItem, Text } from "@microbit/ui";
-import { FormattedMessage } from "react-intl";
+import { Divider, List, ListItem } from "@microbit/ui";
+import { useIntl } from "react-intl";
import { Box, VStack } from "styled-system/jsx";
+import AreaHeading from "../common/AreaHeading";
import FileRow from "./FileRow";
import { useProject } from "./project-hooks";
import { isEditableFile } from "./project-utils";
import ProjectAreaNav from "./ProjectAreaNav";
-import ProjectNameEditable from "./ProjectNameEditable";
interface ProjectAreaProps {
selectedFile: string | undefined;
@@ -25,22 +25,15 @@ const ProjectArea = ({
onSelectedFileChanged,
}: ProjectAreaProps) => {
const { files } = useProject();
+ const intl = useIntl();
return (
-
-
-
-
-
-
+
+
+
diff --git a/src/project/ProjectAreaNav.tsx b/src/project/ProjectAreaNav.tsx
index 5a50a3f9e..1e963c4fe 100644
--- a/src/project/ProjectAreaNav.tsx
+++ b/src/project/ProjectAreaNav.tsx
@@ -3,11 +3,10 @@
*
* SPDX-License-Identifier: MIT
*/
-import { Box, Flex, VStack } from "styled-system/jsx";
+import { Flex, VStack } from "styled-system/jsx";
import { SystemStyleObject } from "styled-system/types";
+import AddFilesButton from "./AddFilesButton";
import NewButton from "./NewButton";
-import OpenButton from "./OpenButton";
-import ResetButton from "./ResetButton";
interface ProjectAreaNavProps {
css?: SystemStyleObject;
@@ -18,15 +17,7 @@ const ProjectAreaNav = ({ css: cssProp }: ProjectAreaNavProps) => {
-
-
-
-
+
);
diff --git a/src/project/ProjectDropTarget.tsx b/src/project/ProjectDropTarget.tsx
index 60a7f797c..13b64d2e1 100644
--- a/src/project/ProjectDropTarget.tsx
+++ b/src/project/ProjectDropTarget.tsx
@@ -15,12 +15,16 @@ const ProjectDropTarget = ({ children }: ProjectDropTargetProps) => {
const actions = useProjectActions();
const handleDrop = useCallback(
(files: File[]) => {
- actions.load(files, "drop-load");
+ actions.load(files, "drop");
},
[actions]
);
return (
-
+
{children}
);
diff --git a/src/project/ResetButton.tsx b/src/project/ResetButton.tsx
deleted file mode 100644
index 0e6224638..000000000
--- a/src/project/ResetButton.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * (c) 2021, Micro:bit Educational Foundation and contributors
- *
- * SPDX-License-Identifier: MIT
- */
-import { Tooltip } from "@microbit/ui";
-import { RiRestartLine } from "react-icons/ri";
-import { useIntl } from "react-intl";
-import CollapsibleButton, {
- CollapsibleButtonComposableProps,
-} from "../common/CollapsibleButton";
-import { useProjectActions } from "./project-hooks";
-
-interface ResetButtonProps extends CollapsibleButtonComposableProps {}
-
-/**
- * Resets the project to the default.
- */
-const ResetButton = (props: ResetButtonProps) => {
- const actions = useProjectActions();
- const intl = useIntl();
- return (
-
- }
- />
-
- );
-};
-
-export default ResetButton;
diff --git a/src/project/project-actions.tsx b/src/project/project-actions.tsx
index 958a5f69f..9b8ecdbce 100644
--- a/src/project/project-actions.tsx
+++ b/src/project/project-actions.tsx
@@ -3,14 +3,13 @@
*
* SPDX-License-Identifier: MIT
*/
-import { Link, List, ListItem, Text, UnorderedList } from "@microbit/ui";
-import { Box, HStack, Stack, VStack } from "styled-system/jsx";
-import { isMakeCodeForV1Hex as isMakeCodeForV1HexNoErrorHandling } from "@microbit/microbit-universal-hex";
+import { ListItem, Text, UnorderedList } from "@microbit/ui";
+import { Box, HStack, VStack } from "styled-system/jsx";
import { saveAs } from "file-saver";
import { ReactNode } from "react";
import { FormattedMessage, IntlShape } from "react-intl";
import { ConfirmDialog } from "../common/ConfirmDialog";
-import { InputDialog, InputDialogBody } from "../common/InputDialog";
+import { InputDialog } from "../common/InputDialog";
import MultipleFilesDialog, {
MultipleFilesChoice,
} from "../common/MultipleFilesDialog";
@@ -26,22 +25,10 @@ import {
} from "@microbit/microbit-connection";
import { MicrobitUSBConnection } from "@microbit/microbit-connection/usb";
import { FileSystem, MAIN_FILE, Statistics, VersionAction } from "../fs/fs";
-import {
- getLowercaseFileExtension,
- isPythonMicrobitModule,
- readFileAsText,
- readFileAsUint8Array,
-} from "../fs/fs-util";
-import {
- defaultInitialProject,
- projectFilesToBase64,
- PythonProject,
-} from "../fs/initial-project";
import { LanguageServerClient } from "../language-server/client";
import {
AnalyticsTask,
deviceFailureCode,
- importFormat,
markUserDisconnect,
transport,
} from "../logging/analytics";
@@ -60,36 +47,17 @@ import TransferHexDialog, {
} from "../workbench/connect-dialogs/TransferHexDialog";
import WebUSBDialog from "../workbench/connect-dialogs/WebUSBDialog";
import { WorkbenchSelection } from "../workbench/use-selection";
-import {
- ClassifiedFileInput,
- FileChange,
- FileInput,
- FileOperation,
-} from "./changes";
-import ChooseMainScriptQuestion from "./ChooseMainScriptQuestion";
import NewFileNameQuestion from "./NewFileNameQuestion";
import { DefaultedProject } from "./project-hooks";
-import {
- ensurePythonExtension,
- isPythonFile,
- validateNewFilename,
-} from "./project-utils";
+import { ImportSource, ProjectImporter } from "./project-import";
+import { ensurePythonExtension, validateNewFilename } from "./project-utils";
import ProjectNameQuestion from "./ProjectNameQuestion";
import WebUSBErrorDialog from "../workbench/connect-dialogs/WebUSBErrorDialog";
import reconnectWebm from "../workbench/connect-dialogs/reconnect.webm";
import reconnectMp4 from "../workbench/connect-dialogs/reconnect.mp4";
-/**
- * Distinguishes the different ways to trigger the load action.
- */
-export type LoadType = "drop-load" | "file-upload";
-
export type FinalFocusRef = React.RefObject | undefined;
-export interface MainScriptChoice {
- main: string | undefined;
-}
-
interface ProjectStatistics extends Statistics {
errorCount: number;
}
@@ -138,7 +106,8 @@ export class ProjectActions {
},
private intl: IntlShape,
private logging: Logging,
- private client: LanguageServerClient | undefined
+ private client: LanguageServerClient | undefined,
+ private importer: ProjectImporter
) {}
private get project(): DefaultedProject {
@@ -269,278 +238,48 @@ export class ProjectActions {
}
};
- private async confirmReplace(customConfirmPrompt?: string): Promise {
- if (!this.fs.dirty) {
- // No need to ask.
- return true;
- }
- return this.dialogs.show((callback) => (
-
-
- {customConfirmPrompt ??
- this.intl.formatMessage({ id: "confirm-replace-body" })}
-
-
-
-
-
- }
- actionLabel={this.intl.formatMessage({
- id: "replace-action-label",
- })}
- />
- ));
- }
-
/**
- * Loads files
- *
- * Replaces the open project if a hex file is opened.
- * No other files may be opened in the same call as a hex file.
+ * Brings files into the editor.
*
- * Uses module marker comments to determine if a Python file
- * is a script or a module. At most one script and any number
- * of modules may be opened together. The existing project is
- * updated.
+ * A hex becomes a new project. Other files are added to the open project,
+ * replacing any with the same names.
*
* @param files the files from drag and drop or an input element.
- * @param the type of user event that triggered the load.
+ * @param source how the user brought them in.
*/
- load = async (
- files: File[],
- type: LoadType = "file-upload"
- ): Promise => {
- if (files.length === 0) {
- throw new Error("Expected to be called with at least one file");
- }
- this.logging.event({
- type: "project_import",
- detail: {
- source: type === "drop-load" ? "drop" : "file_picker",
- format: importFormat(files),
- },
- });
-
- // Avoid lingering messages related to the previous project.
- // Also makes e2e testing easier.
- this.actionFeedback.closeAll();
-
- const errorTitle = this.intl.formatMessage(
- { id: "load-error-title" },
- {
- fileCount: files.length,
- }
- );
- const extensions = new Set(
- files.map((f) => getLowercaseFileExtension(f.name))
- );
- if (extensions.has("mpy")) {
- this.actionFeedback.expectedError({
- title: errorTitle,
- description: this.intl.formatMessage({ id: "load-error-mpy" }),
- });
- } else if (extensions.has("hex")) {
- if (files.length > 1) {
- this.actionFeedback.expectedError({
- title: errorTitle,
- description: this.intl.formatMessage({ id: "load-error-mixed" }),
- });
- } else {
- if (await this.confirmReplace()) {
- const file = files[0];
- const projectName = file.name.replace(/\.hex$/i, "");
- const hex = await readFileAsText(file);
- try {
- await this.fs.replaceWithHexContents(projectName, hex);
- this.actionFeedback.success({
- title: this.intl.formatMessage(
- { id: "loaded-file-feedback" },
- { filename: file.name }
- ),
- });
- } catch (e: any) {
- const isMakeCodeHex = isMakeCodeForV1Hex(hex);
- // Ideally we'd make FormattedMessage work in toasts, but it does not so using intl.
- this.actionFeedback.expectedError({
- title: errorTitle,
- description: isMakeCodeHex ? (
-
-
- {this.intl.formatMessage({
- id: "load-error-makecode-info",
- })}
-
-
- {this.intl.formatMessage(
- { id: "load-error-makecode-link" },
- {
- link: (chunks: ReactNode) => (
-
- {chunks}
-
- ),
- }
- )}
-
-
- ) : (
- e.message
- ),
- });
- }
- }
- }
- } else {
- const classifiedInputs: ClassifiedFileInput[] = [];
- const hasMainPyFile = files.some((x) => x.name === MAIN_FILE);
- for (const f of files) {
- const content = await readFileAsUint8Array(f);
- const python = isPythonFile(f.name);
- const module = python && isPythonMicrobitModule(content);
- const script = hasMainPyFile ? f.name === MAIN_FILE : python && !module;
- classifiedInputs.push({
- name: f.name,
- script,
- module,
- data: () => Promise.resolve(content),
- });
- }
-
- const inputs = await this.chooseScriptForMain(classifiedInputs);
- if (inputs) {
- return this.uploadInternal(inputs);
- }
- }
- };
+ load = (files: File[], source: ImportSource = "file_picker"): Promise =>
+ this.importer.importIntoEditor(files, source);
/**
- * Open a project, asking for confirmation if required.
- *
- * @param project The project.
- * @param confirmPrompt Optional custom confirmation prompt.
- * @returns True if we opened the project, false if the user cancelled.
+ * Opens an idea from the documentation as a new project.
*/
- private openProject = async (
- project: PythonProject,
- confirmPrompt?: string
- ): Promise => {
- const confirmed = await this.confirmReplace(confirmPrompt);
- if (confirmed) {
- await this.fs.replaceWithMultipleFiles(project);
- }
- return confirmed;
- };
-
openIdea = async (slug: string | undefined, code: string, title: string) => {
this.logging.event({
type: "idea_open",
detail: { id: slug },
});
- const pythonProject: PythonProject = {
- files: projectFilesToBase64({
- [MAIN_FILE]: code,
- }),
- projectName: title,
- };
- const confirmPrompt = this.intl.formatMessage(
- { id: "confirm-replace-with-idea" },
- { ideaName: pythonProject.projectName }
- );
- if (await this.openProject(pythonProject, confirmPrompt)) {
+ try {
+ const opened = await this.importer.newProject(
+ title,
+ { [MAIN_FILE]: new TextEncoder().encode(code) },
+ this.intl.formatMessage(
+ { id: "confirm-replace-with-idea" },
+ { ideaName: title }
+ )
+ );
+ if (!opened) {
+ return;
+ }
this.actionFeedback.success({
title: this.intl.formatMessage(
{ id: "loaded-file-feedback" },
{ filename: title }
),
});
- }
- };
-
- reset = async () => {
- this.logging.event({
- type: "project_reset",
- });
- const confirmPrompt = this.intl.formatMessage({
- id: "confirm-replace-reset",
- });
- if (await this.openProject(defaultInitialProject, confirmPrompt)) {
- this.actionFeedback.success({
- title: this.intl.formatMessage({ id: "reset-project-feedback" }),
- });
- }
- };
-
- private async uploadInternal(inputs: ClassifiedFileInput[]) {
- const changes = this.findChanges(inputs);
- try {
- for (const change of changes) {
- const data = await change.data();
- await this.fs.write(change.name, data, VersionAction.INCREMENT);
- }
- this.actionFeedback.success(this.summarizeChanges(changes));
- } catch (e: any) {
+ } catch (e) {
this.actionFeedback.unexpectedError(e);
}
- }
-
- private findChanges(files: FileInput[]): FileChange[] {
- const currentFiles = this.project.files.map((f) => f.name);
- const current = new Set(currentFiles);
- return files.map((f) => ({
- ...f,
- operation: current.has(f.name)
- ? FileOperation.REPLACE
- : FileOperation.ADD,
- }));
- }
-
- private async chooseScriptForMain(
- inputs: ClassifiedFileInput[]
- ): Promise {
- const defaultScript = inputs.find((x) => x.script);
- const chosenScript = await this.dialogs.show(
- (callback) => (
- ) => (
- f.name))}
- inputs={inputs}
- />
- )}
- actionLabel={this.intl.formatMessage({ id: "confirm-action" })}
- size="lg"
- />
- )
- );
- if (!chosenScript) {
- // User cancelled.
- return undefined;
- }
-
- return inputs.map((input) => {
- if (chosenScript && chosenScript.main === input.name) {
- return {
- ...input,
- name: "main.py",
- };
- }
- return input;
- });
- }
+ };
/**
* Flash the device, reporting progress via a dialog.
@@ -1057,48 +796,8 @@ export class ProjectActions {
errorCount: this.client?.errorCount() ?? 0,
};
}
-
- summarizeChanges = (changes: FileChange[]) => {
- if (changes.length === 1) {
- return { title: this.summarizeChange(changes[0]) };
- }
- return {
- title: `${changes.length} changes`,
- description: (
-
- {changes.map((c) => (
- {this.summarizeChange(c)}
- ))}
-
- ),
- };
- };
-
- idForChangeType = (changeType: FileOperation): string => {
- return changeType === FileOperation.REPLACE
- ? "updated-change"
- : "added-change";
- };
-
- summarizeChange = (change: FileChange): string => {
- const translationID = this.idForChangeType(change.operation);
- return this.intl.formatMessage(
- { id: translationID },
- { changeName: change.name }
- );
- };
}
-const isMakeCodeForV1Hex = (hexStr: string) => {
- try {
- return isMakeCodeForV1HexNoErrorHandling(hexStr);
- } catch {
- // We just use this to give a better message in error scenarios so we don't
- // care if we failed to parse it etc.
- return false;
- }
-};
-
export const defaultedProject = (
fs: FileSystem,
intl: IntlShape
diff --git a/src/project/project-hooks.tsx b/src/project/project-hooks.tsx
index a528cf352..4520fbfc6 100644
--- a/src/project/project-hooks.tsx
+++ b/src/project/project-hooks.tsx
@@ -22,6 +22,26 @@ import { useSessionSettings } from "../settings/session-settings";
import { useSettings } from "../settings/settings";
import { useSelection } from "../workbench/use-selection";
import { defaultedProject, ProjectActions } from "./project-actions";
+import { ProjectImporter } from "./project-import";
+import { useProjectsIfAvailable } from "./projects-hooks";
+
+/**
+ * Imports files as new projects or into the open one. Usable from the pages
+ * as well as the editor.
+ */
+export const useProjectImporter = (): ProjectImporter => {
+ const fs = useFileSystem();
+ const projects = useProjectsIfAvailable();
+ const actionFeedback = useActionFeedback();
+ const dialogs = useDialogs();
+ const intl = useIntl();
+ const logging = useLogging();
+ return useMemo(
+ () =>
+ new ProjectImporter(fs, projects, actionFeedback, dialogs, intl, logging),
+ [fs, projects, actionFeedback, dialogs, intl, logging]
+ );
+};
/**
* Hook exposing the main UI actions.
@@ -37,6 +57,7 @@ export const useProjectActions = (): ProjectActions => {
const client = useLanguageServerClient();
const [settings, setSettings] = useSettings();
const [sessionSettings, setSessionSettings] = useSessionSettings();
+ const importer = useProjectImporter();
const actions = useMemo(
() =>
new ProjectActions(
@@ -49,7 +70,8 @@ export const useProjectActions = (): ProjectActions => {
{ values: sessionSettings, setValues: setSessionSettings },
intl,
logging,
- client
+ client,
+ importer
),
[
fs,
@@ -64,6 +86,7 @@ export const useProjectActions = (): ProjectActions => {
setSettings,
sessionSettings,
setSessionSettings,
+ importer,
]
);
return actions;
diff --git a/src/project/project-import.test.ts b/src/project/project-import.test.ts
new file mode 100644
index 000000000..930e2b501
--- /dev/null
+++ b/src/project/project-import.test.ts
@@ -0,0 +1,56 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { MAIN_FILE } from "../fs/fs";
+import { filesForNewProject } from "./project-import";
+
+const text = (s: string) => new TextEncoder().encode(s);
+const decode = (b: Uint8Array) => new TextDecoder().decode(b);
+
+describe("filesForNewProject", () => {
+ it("makes a single script main.py and names the project after it", () => {
+ const { name, files } = filesForNewProject([
+ { name: "dice.py", data: text("print('dice')") },
+ ]);
+ expect(name).toEqual("dice");
+ expect(Object.keys(files)).toEqual([MAIN_FILE]);
+ expect(decode(files[MAIN_FILE])).toEqual("print('dice')");
+ });
+
+ it("keeps a module under its own name and adds the starter main.py", () => {
+ const { name, files } = filesForNewProject([
+ { name: "module.py", data: text("# microbit-module: a@1.0.0\n") },
+ ]);
+ expect(name).toBeUndefined();
+ expect(Object.keys(files).sort()).toEqual([MAIN_FILE, "module.py"]);
+ expect(decode(files[MAIN_FILE])).toMatch(/from microbit import/);
+ });
+
+ it("keeps main.py when given one, with the other files alongside", () => {
+ const { name, files } = filesForNewProject([
+ { name: "helper.py", data: text("x = 1") },
+ { name: MAIN_FILE, data: text("import helper") },
+ ]);
+ expect(name).toBeUndefined();
+ expect(Object.keys(files).sort()).toEqual(["helper.py", MAIN_FILE]);
+ expect(decode(files[MAIN_FILE])).toEqual("import helper");
+ });
+
+ it("does not guess between several scripts", () => {
+ const { name, files } = filesForNewProject([
+ { name: "a.py", data: text("a") },
+ { name: "b.py", data: text("b") },
+ ]);
+ expect(name).toBeUndefined();
+ expect(Object.keys(files).sort()).toEqual(["a.py", "b.py", MAIN_FILE]);
+ });
+
+ it("adds the starter main.py to a lone data file", () => {
+ const { files } = filesForNewProject([
+ { name: "data.txt", data: text("1,2,3") },
+ ]);
+ expect(Object.keys(files).sort()).toEqual(["data.txt", MAIN_FILE]);
+ });
+});
diff --git a/src/project/project-import.tsx b/src/project/project-import.tsx
new file mode 100644
index 000000000..812bafe64
--- /dev/null
+++ b/src/project/project-import.tsx
@@ -0,0 +1,416 @@
+/**
+ * Bringing files into the editor.
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { isMakeCodeForV1Hex as isMakeCodeForV1HexNoErrorHandling } from "@microbit/microbit-universal-hex";
+import { Link, List, ListItem, Text } from "@microbit/ui";
+import { ReactNode } from "react";
+import { IntlShape } from "react-intl";
+import { Stack } from "styled-system/jsx";
+import { ConfirmDialog } from "../common/ConfirmDialog";
+import { ActionFeedback } from "../common/use-action-feedback";
+import { Dialogs } from "../common/use-dialogs";
+import { defaultProjectFiles } from "../fs/current-project";
+import { FileSystem, MAIN_FILE, VersionAction } from "../fs/fs";
+import {
+ getLowercaseFileExtension,
+ isPythonMicrobitModule,
+ readFileAsText,
+ readFileAsUint8Array,
+} from "../fs/fs-util";
+import { importFormat } from "../logging/analytics";
+import { Logging } from "../logging/logging";
+import { FileChange, FileOperation } from "./changes";
+import { isPythonFile } from "./project-utils";
+import { Projects } from "./projects";
+
+export type ImportSource = "drop" | "file_picker";
+export type ImportSurface = "editor" | "home";
+
+export interface ImportedFile {
+ name: string;
+ data: Uint8Array;
+}
+
+export interface NewProjectFiles {
+ /** A name taken from the file, or undefined for the untitled default. */
+ name: string | undefined;
+ files: Record;
+}
+
+type ImportKind =
+ | { kind: "hex"; file: File }
+ | { kind: "files" }
+ | { kind: "error"; messageId: "load-error-mpy" | "load-error-mixed" };
+
+const classify = (files: File[]): ImportKind => {
+ const extensions = new Set(
+ files.map((f) => getLowercaseFileExtension(f.name))
+ );
+ if (extensions.has("mpy")) {
+ return { kind: "error", messageId: "load-error-mpy" };
+ }
+ if (extensions.has("hex")) {
+ return files.length > 1
+ ? { kind: "error", messageId: "load-error-mixed" }
+ : { kind: "hex", file: files[0] };
+ }
+ return { kind: "files" };
+};
+
+/**
+ * Arranges files into a new project.
+ *
+ * A single Python script (not a marked module) becomes main.py and names the
+ * project, which is what opening a saved program wants. Otherwise files keep
+ * their names and the starter main.py is added if there is none.
+ */
+export const filesForNewProject = (inputs: ImportedFile[]): NewProjectFiles => {
+ const files: Record = Object.fromEntries(
+ inputs.map((f) => [f.name, f.data])
+ );
+ let name: string | undefined;
+ if (!(MAIN_FILE in files)) {
+ const scripts = inputs.filter(
+ (f) =>
+ isPythonFile(f.name) &&
+ !isPythonMicrobitModule(new TextDecoder().decode(f.data))
+ );
+ if (scripts.length === 1) {
+ const [script] = scripts;
+ delete files[script.name];
+ files[MAIN_FILE] = script.data;
+ name = script.name.replace(/\.py$/i, "");
+ } else {
+ Object.assign(files, defaultProjectFiles());
+ }
+ }
+ return { name, files };
+};
+
+/**
+ * Imports files from the editor or the home page.
+ *
+ * A hex is always a whole program, so it becomes a new project; with the
+ * projects database unavailable it replaces the single implicit project
+ * instead. Other files join the open project when imported from the editor,
+ * and make a new project from the home page.
+ */
+export class ProjectImporter {
+ constructor(
+ private fs: FileSystem,
+ private projects: Projects | undefined,
+ private actionFeedback: ActionFeedback,
+ private dialogs: Dialogs,
+ private intl: IntlShape,
+ private logging: Logging
+ ) {}
+
+ /**
+ * A hex becomes a new project; other files are added to the open project,
+ * replacing any with the same names.
+ */
+ importIntoEditor = async (
+ files: File[],
+ source: ImportSource
+ ): Promise => {
+ const kind = this.begin(files, source, "editor");
+ switch (kind.kind) {
+ case "error":
+ return this.loadError(files, kind.messageId);
+ case "hex":
+ await this.newProjectFromHex(kind.file);
+ return;
+ case "files":
+ return this.addToProject(await this.readAll(files));
+ }
+ };
+
+ /**
+ * The files become a new project, open in the editor.
+ *
+ * @returns True if a project was created.
+ */
+ importAsNewProject = async (
+ files: File[],
+ source: ImportSource
+ ): Promise => {
+ const kind = this.begin(files, source, "home");
+ switch (kind.kind) {
+ case "error":
+ this.loadError(files, kind.messageId);
+ return false;
+ case "hex":
+ return this.newProjectFromHex(kind.file);
+ case "files": {
+ const inputs = await this.readAll(files);
+ const project = filesForNewProject(inputs);
+ try {
+ if (!(await this.newProject(project.name, project.files))) {
+ return false;
+ }
+ } catch (e) {
+ this.actionFeedback.unexpectedError(e);
+ return false;
+ }
+ this.actionFeedback.success(
+ inputs.length === 1
+ ? this.loadedFeedback(inputs[0].name)
+ : this.summarizeChanges(
+ inputs.map((f) => ({
+ name: f.name,
+ data: () => Promise.resolve(f.data),
+ operation: FileOperation.ADD,
+ }))
+ )
+ );
+ return true;
+ }
+ }
+ };
+
+ /**
+ * Opens a new project with these files, or replaces the implicit project
+ * where there is no projects database, asking first if it has unsaved
+ * edits.
+ *
+ * @param confirmPrompt What replacing means, for the confirmation.
+ * @returns False if the user kept their project.
+ */
+ newProject = async (
+ name: string | undefined,
+ files: Record,
+ confirmPrompt?: string
+ ): Promise => {
+ if (this.projects && (await this.projects.isAvailable())) {
+ await this.projects.createFromFiles(name, files);
+ return true;
+ }
+ if (!(await this.confirmReplace(confirmPrompt))) {
+ return false;
+ }
+ await this.fs.replaceWithFiles(name, files);
+ return true;
+ };
+
+ /**
+ * Without the projects database a replaced project is gone, so ask when
+ * there are edits since the last save. Nothing to ask about otherwise.
+ */
+ private async confirmReplace(customPrompt?: string): Promise {
+ if (!this.fs.dirty) {
+ return true;
+ }
+ return this.dialogs.show((callback) => (
+
+
+ {customPrompt ??
+ this.intl.formatMessage({ id: "confirm-replace-body" })}
+
+ {this.intl.formatMessage({ id: "confirm-save-hint" })}
+
+ }
+ actionLabel={this.intl.formatMessage({ id: "replace-action-label" })}
+ />
+ ));
+ }
+
+ /** Files that would overwrite existing ones need a yes first. */
+ private async confirmReplaceFiles(names: string[]): Promise {
+ if (names.length === 0) {
+ return true;
+ }
+ return this.dialogs.show((callback) => (
+
+ ));
+ }
+
+ private begin(
+ files: File[],
+ source: ImportSource,
+ surface: ImportSurface
+ ): ImportKind {
+ if (files.length === 0) {
+ throw new Error("Expected to be called with at least one file");
+ }
+ this.logging.event({
+ type: "project_import",
+ detail: { source, format: importFormat(files), surface },
+ });
+ // Avoid lingering messages related to the previous project.
+ // Also makes e2e testing easier.
+ this.actionFeedback.closeAll();
+ return classify(files);
+ }
+
+ private async newProjectFromHex(file: File): Promise {
+ const name = file.name.replace(/\.hex$/i, "");
+ const hex = await readFileAsText(file);
+ let files: Record;
+ try {
+ files = await this.fs.filesFromHex(hex);
+ } catch (e: any) {
+ this.hexError(file, hex, e);
+ return false;
+ }
+ try {
+ if (!(await this.newProject(name, files))) {
+ return false;
+ }
+ } catch (e) {
+ this.actionFeedback.unexpectedError(e);
+ return false;
+ }
+ this.actionFeedback.success(this.loadedFeedback(file.name));
+ return true;
+ }
+
+ private async addToProject(inputs: ImportedFile[]): Promise {
+ const current = new Set(this.fs.project.files.map((f) => f.name));
+ const changes: FileChange[] = inputs.map((f) => ({
+ name: f.name,
+ data: () => Promise.resolve(f.data),
+ operation: current.has(f.name)
+ ? FileOperation.REPLACE
+ : FileOperation.ADD,
+ }));
+ const replaced = changes
+ .filter((c) => c.operation === FileOperation.REPLACE)
+ .map((c) => c.name);
+ if (!(await this.confirmReplaceFiles(replaced))) {
+ return;
+ }
+ try {
+ for (const change of changes) {
+ await this.fs.write(
+ change.name,
+ await change.data(),
+ VersionAction.INCREMENT
+ );
+ }
+ this.actionFeedback.success(this.summarizeChanges(changes));
+ } catch (e: any) {
+ this.actionFeedback.unexpectedError(e);
+ }
+ }
+
+ private async readAll(files: File[]): Promise {
+ return Promise.all(
+ files.map(async (f) => ({
+ name: f.name,
+ data: await readFileAsUint8Array(f),
+ }))
+ );
+ }
+
+ private loadErrorTitle(files: File[]): string {
+ return this.intl.formatMessage(
+ { id: "load-error-title" },
+ { fileCount: files.length }
+ );
+ }
+
+ private loadError(files: File[], messageId: string): void {
+ this.actionFeedback.expectedError({
+ title: this.loadErrorTitle(files),
+ description: this.intl.formatMessage({ id: messageId }),
+ });
+ }
+
+ private hexError(file: File, hex: string, e: any): void {
+ const isMakeCodeHex = isMakeCodeForV1Hex(hex);
+ // Ideally we'd make FormattedMessage work in toasts, but it does not so using intl.
+ this.actionFeedback.expectedError({
+ title: this.loadErrorTitle([file]),
+ description: isMakeCodeHex ? (
+
+
+ {this.intl.formatMessage({
+ id: "load-error-makecode-info",
+ })}
+
+
+ {this.intl.formatMessage(
+ { id: "load-error-makecode-link" },
+ {
+ link: (chunks: ReactNode) => (
+
+ {chunks}
+
+ ),
+ }
+ )}
+
+
+ ) : (
+ e.message
+ ),
+ });
+ }
+
+ private loadedFeedback(filename: string) {
+ return {
+ title: this.intl.formatMessage(
+ { id: "loaded-file-feedback" },
+ { filename }
+ ),
+ };
+ }
+
+ private summarizeChanges(changes: FileChange[]) {
+ if (changes.length === 1) {
+ return { title: this.summarizeChange(changes[0]) };
+ }
+ return {
+ title: `${changes.length} changes`,
+ description: (
+
+ {changes.map((c) => (
+ {this.summarizeChange(c)}
+ ))}
+
+ ),
+ };
+ }
+
+ private summarizeChange(change: FileChange): string {
+ return this.intl.formatMessage(
+ {
+ id:
+ change.operation === FileOperation.REPLACE
+ ? "updated-change"
+ : "added-change",
+ },
+ { changeName: change.name }
+ );
+ }
+}
+
+const isMakeCodeForV1Hex = (hexStr: string) => {
+ try {
+ return isMakeCodeForV1HexNoErrorHandling(hexStr);
+ } catch {
+ // We just use this to give a better message in error scenarios so we don't
+ // care if we failed to parse it etc.
+ return false;
+ }
+};
diff --git a/src/project/projects-hooks.tsx b/src/project/projects-hooks.tsx
new file mode 100644
index 000000000..11be741e9
--- /dev/null
+++ b/src/project/projects-hooks.tsx
@@ -0,0 +1,51 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useSyncExternalStore,
+} from "react";
+import { ProjectListEntry } from "../fs/projects-db";
+import { Projects } from "./projects";
+
+const ProjectsContext = createContext(undefined);
+
+export const ProjectsProvider = ProjectsContext.Provider;
+
+/**
+ * The projects in this browser. Throws in iframe controller mode, where the
+ * embedding page owns the project; use useProjectsIfAvailable there.
+ */
+export const useProjects = (): Projects => {
+ const projects = useContext(ProjectsContext);
+ if (!projects) {
+ throw new Error("Missing ProjectsProvider");
+ }
+ return projects;
+};
+
+/**
+ * The projects in this browser, or undefined in iframe controller mode.
+ */
+export const useProjectsIfAvailable = (): Projects | undefined =>
+ useContext(ProjectsContext);
+
+/**
+ * The project list, kept up to date as projects change in this tab or
+ * another. The route loader refreshes it before the page renders.
+ */
+export const useProjectList = (): ProjectListEntry[] => {
+ const projects = useProjects();
+ const subscribe = useCallback(
+ (listener: () => void) => {
+ projects.addEventListener("change", listener);
+ return () => projects.removeEventListener("change", listener);
+ },
+ [projects]
+ );
+ return useSyncExternalStore(subscribe, () => projects.projects);
+};
diff --git a/src/project/projects.test.ts b/src/project/projects.test.ts
new file mode 100644
index 000000000..9fe3d9045
--- /dev/null
+++ b/src/project/projects.test.ts
@@ -0,0 +1,196 @@
+/**
+ * @vitest-environment node
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import "fake-indexeddb/auto";
+import * as nodeFs from "fs";
+import { vi } from "vitest";
+import { deferred } from "../common/deferred";
+import { ConsoleLogging } from "../deployment/default/logging";
+import { FileSystem, MAIN_FILE, VersionAction } from "../fs/fs";
+import { DefaultHost } from "../fs/host";
+import { ProjectsDatabase } from "../fs/projects-db";
+import { FSStorage } from "../fs/storage";
+import { MicroPythonSource } from "../micropython/micropython";
+import { Projects, ProjectSaveErrorEvent } from "./projects";
+
+const hexes = [
+ nodeFs.readFileSync("src/micropython/microbit-micropython-v1.hex", {
+ encoding: "ascii",
+ }),
+ nodeFs.readFileSync("src/micropython/main/microbit-micropython-v2.hex", {
+ encoding: "ascii",
+ }),
+];
+const microPythonSource: MicroPythonSource = async () => [
+ { boardId: 0x9900, hex: hexes[0] },
+ { boardId: 0x9903, hex: hexes[1] },
+];
+
+let counter = 0;
+const logging = new ConsoleLogging();
+
+// Recency ordering is by timestamp; make each one distinct so the tests do
+// not depend on how much happens within a millisecond.
+beforeEach(() => {
+ let now = 1_000_000;
+ vi.spyOn(Date, "now").mockImplementation(() => now++);
+});
+
+const setup = async (dbAvailable = true) => {
+ const db = dbAvailable
+ ? await ProjectsDatabase.open(`projects-${Date.now()}-${counter++}`)
+ : undefined;
+ const storage = deferred();
+ const host = new DefaultHost(undefined, storage.promise);
+ const fs = new FileSystem(logging, host, microPythonSource);
+ const projects = new Projects(fs, logging, Promise.resolve(db), storage);
+ return { db, fs, projects };
+};
+
+const fileNames = (fs: FileSystem) => fs.project.files.map((f) => f.name);
+
+describe("Projects", () => {
+ it("openCurrent creates a project and gives the file system its storage", async () => {
+ const { db, fs, projects } = await setup();
+ await projects.openCurrent();
+ await fs.initialize();
+
+ expect(projects.currentId).toBeDefined();
+ expect(fileNames(fs)).toEqual([MAIN_FILE]);
+ expect((await projects.refresh()).map((p) => p.id)).toEqual([
+ projects.currentId,
+ ]);
+ // Written through to the database, not just the in-memory copy.
+ await fs.write(MAIN_FILE, "# edited", VersionAction.MAINTAIN);
+ await projects.rename(projects.currentId!, "Named");
+ expect(
+ new TextDecoder().decode(await db!.file(projects.currentId!, MAIN_FILE))
+ ).toEqual("# edited");
+ });
+
+ it("concurrent openCurrent calls choose one project", async () => {
+ const { projects } = await setup();
+ await Promise.all([projects.openCurrent(), projects.openCurrent()]);
+ expect((await projects.refresh()).map((p) => p.id)).toEqual([
+ projects.currentId,
+ ]);
+ });
+
+ it("openCurrent keeps the open project", async () => {
+ const { projects } = await setup();
+ await projects.openCurrent();
+ const first = projects.currentId;
+ await projects.openCurrent();
+ expect(projects.currentId).toEqual(first);
+ });
+
+ it("create switches the editor to the new project, and open switches back", async () => {
+ const { fs, projects } = await setup();
+ await projects.openCurrent();
+ await fs.initialize();
+ const first = projects.currentId!;
+ await fs.write("helper.py", "# helper", VersionAction.INCREMENT);
+
+ const second = await projects.create("Second");
+
+ expect(projects.currentId).toEqual(second);
+ expect(fs.project.name).toEqual("Second");
+ expect(fileNames(fs)).toEqual([MAIN_FILE]);
+ expect(projects.projects.map((p) => p.id)).toEqual([second, first]);
+
+ await projects.open(first);
+ expect(fs.project.name).toBeUndefined();
+ expect(fileNames(fs)).toEqual([MAIN_FILE, "helper.py"]);
+ });
+
+ it("renames the open project through the file system and others directly", async () => {
+ const { db, fs, projects } = await setup();
+ await projects.openCurrent();
+ await fs.initialize();
+ const open = projects.currentId!;
+ await db!.create({ id: "other", name: "Other", timestamp: 1 }, {});
+
+ await projects.rename(open, "Renamed open");
+ await projects.rename("other", "Renamed other");
+
+ expect(fs.project.name).toEqual("Renamed open");
+ expect((await db!.get(open))?.name).toEqual("Renamed open");
+ expect((await db!.get("other"))?.name).toEqual("Renamed other");
+ expect(projects.projects.map((p) => p.name).sort()).toEqual([
+ "Renamed open",
+ "Renamed other",
+ ]);
+ });
+
+ it("duplicates the open project including unsaved edits", async () => {
+ const { db, fs, projects } = await setup();
+ await projects.openCurrent();
+ await fs.initialize();
+ await fs.write(MAIN_FILE, "# latest", VersionAction.MAINTAIN);
+
+ const copy = await projects.duplicate(projects.currentId!, "Copy");
+
+ expect((await db!.get(copy))?.name).toEqual("Copy");
+ expect(new TextDecoder().decode(await db!.file(copy, MAIN_FILE))).toEqual(
+ "# latest"
+ );
+ expect(projects.currentId).not.toEqual(copy);
+ });
+
+ it("deleting the open project makes openCurrent choose another", async () => {
+ const { db, fs, projects } = await setup();
+ await projects.openCurrent();
+ await fs.initialize();
+ const deleted = projects.currentId!;
+ await db!.create({ id: "other", name: "Other", timestamp: 1 }, {});
+
+ await projects.delete([deleted]);
+ expect(projects.currentId).toBeUndefined();
+ expect(projects.projects.map((p) => p.id)).toEqual(["other"]);
+
+ await projects.openCurrent();
+ expect(projects.currentId).toEqual("other");
+ expect(fs.project.name).toEqual("Other");
+ });
+
+ it("a failed save raises saveerror each time but is logged once", async () => {
+ const { db, fs, projects } = await setup();
+ await projects.openCurrent();
+ await fs.initialize();
+ const error = new DOMException("Simulated", "QuotaExceededError");
+ vi.spyOn(db!, "apply").mockRejectedValue(error);
+ const logged = vi
+ .spyOn(logging, "error")
+ .mockImplementation(() => undefined);
+ const errors: unknown[] = [];
+ projects.addEventListener("saveerror", (e: ProjectSaveErrorEvent) => {
+ errors.push(e.error);
+ });
+
+ await fs.write(MAIN_FILE, "# one", VersionAction.MAINTAIN);
+ await vi.waitFor(() => expect(errors).toHaveLength(1));
+ await fs.write(MAIN_FILE, "# two", VersionAction.MAINTAIN);
+ await vi.waitFor(() => expect(errors).toHaveLength(2));
+
+ expect(errors).toEqual([error, error]);
+ expect(logged).toHaveBeenCalledTimes(1);
+ // The in-memory copy still has the edit.
+ expect((await fs.read(MAIN_FILE)).data).toEqual(
+ new TextEncoder().encode("# two")
+ );
+ });
+
+ it("without the database there is nothing to manage and the editor still works", async () => {
+ const { fs, projects } = await setup(false);
+ expect(await projects.isAvailable()).toEqual(false);
+ await projects.openCurrent();
+ await fs.initialize();
+ expect(fileNames(fs)).toEqual([MAIN_FILE]);
+ expect(await projects.refresh()).toEqual([]);
+ await expect(projects.create("x")).rejects.toThrow(/not available/);
+ });
+});
diff --git a/src/project/projects.ts b/src/project/projects.ts
new file mode 100644
index 000000000..c89c8c842
--- /dev/null
+++ b/src/project/projects.ts
@@ -0,0 +1,325 @@
+/**
+ * The projects in this browser and which one the editor has open.
+ *
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Deferred } from "../common/deferred";
+import { TypedEventTarget } from "../common/events";
+import {
+ chooseProject,
+ defaultProjectFiles,
+ getCurrentProjectId,
+ sessionStorageIfPossible,
+ setCurrentProjectId,
+} from "../fs/current-project";
+import { FileSystem } from "../fs/fs";
+import { generateId } from "../fs/fs-util";
+import { IndexedDBFSStorage } from "../fs/indexeddb-storage";
+import { PendingMigration } from "../fs/migration";
+import { ProjectListEntry, ProjectsDatabase } from "../fs/projects-db";
+import {
+ FSStorage,
+ InMemoryFSStorage,
+ SessionStorageFSStorage,
+ SplitStrategyStorage,
+} from "../fs/storage";
+import { hasStorageVersionError } from "../fs/storage-status";
+import { Logging } from "../logging/logging";
+
+export class ProjectsChangedEvent extends Event {
+ constructor() {
+ super("change");
+ }
+}
+
+/** A write to the open project's storage failed; its changes were dropped. */
+export class ProjectSaveErrorEvent extends Event {
+ constructor(public readonly error: unknown) {
+ super("saveerror");
+ }
+}
+
+interface EventMap {
+ change: ProjectsChangedEvent;
+ saveerror: ProjectSaveErrorEvent;
+}
+
+/**
+ * What other tabs are told. Ids name the projects affected; a tab with one of
+ * them open reloads or lets go of it.
+ */
+interface SyncMessage {
+ type: "changed" | "deleted";
+ projectIds: string[];
+}
+
+const syncChannelName = "python-editor-projects";
+
+/**
+ * Lists, creates, opens, renames, duplicates and deletes projects, and keeps
+ * the file system on the open one.
+ *
+ * The host waits on the file system's first storage, which this resolves
+ * when a project is first opened, so the pages can show without choosing a
+ * project. Later opens switch the file system's storage. Without the
+ * projects database the editor falls back to session storage and there is
+ * nothing to list; pages redirect to the editor.
+ */
+export class Projects extends TypedEventTarget {
+ private db: ProjectsDatabase | undefined;
+ private readonly ready: Promise;
+ private firstStorageResolved = false;
+ private openId: string | undefined;
+ private openStorage: IndexedDBFSStorage | undefined;
+ private opening: Promise | undefined;
+ private cachedList: ProjectListEntry[] = [];
+ private saveErrorReported = false;
+ private readonly channel: BroadcastChannel | undefined;
+ private readonly session = sessionStorageIfPossible();
+
+ /**
+ * @param firstStorage Resolved with the file system's persistent storage
+ * when a project is first opened; the host's DefaultHost waits on it.
+ * @param migration The #project: link at boot, made a new project when
+ * the editor first chooses one. Left for the host without the database.
+ */
+ constructor(
+ private fs: FileSystem,
+ private logging: Logging,
+ db: Promise,
+ private firstStorage: Deferred,
+ private migration: PendingMigration = new PendingMigration("")
+ ) {
+ super();
+ this.ready = db.then((resolved) => {
+ this.db = resolved;
+ });
+ this.channel =
+ typeof BroadcastChannel !== "undefined"
+ ? new BroadcastChannel(syncChannelName)
+ : undefined;
+ this.channel?.addEventListener("message", this.handleSyncMessage);
+ }
+
+ /**
+ * False without the projects database, in which case there is one
+ * implicit project and no project management.
+ */
+ async isAvailable(): Promise {
+ await this.ready;
+ return this.db !== undefined;
+ }
+
+ /**
+ * The projects as of the last refresh, most recent first. Route loaders
+ * refresh before the pages render; the pages then subscribe to changes.
+ */
+ get projects(): ProjectListEntry[] {
+ return this.cachedList;
+ }
+
+ /** The project the editor has open in this tab, if any. */
+ get currentId(): string | undefined {
+ return this.openId;
+ }
+
+ async refresh(): Promise {
+ await this.ready;
+ this.cachedList = this.db ? await this.db.listWithFileNames() : [];
+ this.dispatchTypedEvent("change", new ProjectsChangedEvent());
+ return this.cachedList;
+ }
+
+ /**
+ * Makes sure the editor has a project: one from the #project: link the
+ * app booted with, else the one already open, the tab's, the most recent,
+ * or a new one. For the editor route's loader. Concurrent calls share one
+ * choice so they cannot each create a project.
+ *
+ * @returns True if a #project: link became a project, so the caller can
+ * drop the hash from the URL.
+ */
+ openCurrent(): Promise {
+ this.opening ??= this.chooseAndOpen().finally(() => {
+ this.opening = undefined;
+ });
+ return this.opening;
+ }
+
+ private async chooseAndOpen(): Promise {
+ await this.ready;
+ if (!this.db) {
+ this.resolveFirstStorage(
+ hasStorageVersionError() ? undefined : SessionStorageFSStorage.create()
+ );
+ return false;
+ }
+ const migration = this.migration.take();
+ if (!migration && this.openId && (await this.db.get(this.openId))) {
+ return false;
+ }
+ const id = await chooseProject(this.db, this.session, migration);
+ await this.switchTo(id);
+ if (migration) {
+ await this.changed([id]);
+ }
+ return migration !== undefined;
+ }
+
+ async open(id: string): Promise {
+ const db = await this.requireDb();
+ if (!(await db.get(id))) {
+ throw new Error(`No such project ${id}`);
+ }
+ await db.touch(id);
+ await this.switchTo(id);
+ }
+
+ async create(name: string): Promise {
+ return this.createFromFiles(name, defaultProjectFiles());
+ }
+
+ /**
+ * Creates a project with the given files, e.g. from an imported hex, and
+ * opens it in the editor.
+ */
+ async createFromFiles(
+ name: string | undefined,
+ files: Record
+ ): Promise {
+ const db = await this.requireDb();
+ const id = generateId();
+ await db.create({ id, name, timestamp: Date.now() }, files);
+ await this.switchTo(id);
+ await this.changed([id]);
+ return id;
+ }
+
+ async rename(id: string, name: string): Promise {
+ const db = await this.requireDb();
+ if (id === this.openId) {
+ // Through the file system so its copy of the name changes too.
+ await this.fs.setProjectName(name);
+ await this.openStorage?.flush();
+ } else {
+ await db.apply(id, { meta: { name, timestamp: Date.now() } });
+ }
+ await this.changed([id]);
+ }
+
+ async duplicate(id: string, name: string): Promise {
+ const db = await this.requireDb();
+ if (id === this.openId) {
+ await this.openStorage?.flush();
+ }
+ const newId = generateId();
+ await db.duplicate(id, { id: newId, name, timestamp: Date.now() });
+ await this.changed([newId]);
+ return newId;
+ }
+
+ async delete(ids: string[]): Promise {
+ const db = await this.requireDb();
+ for (const id of ids) {
+ if (id === this.openId) {
+ this.letGoOfOpenProject();
+ }
+ await db.delete(id);
+ }
+ await this.refresh();
+ this.channel?.postMessage({ type: "deleted", projectIds: ids });
+ }
+
+ private async requireDb(): Promise {
+ await this.ready;
+ if (!this.db) {
+ throw new Error("The projects database is not available");
+ }
+ return this.db;
+ }
+
+ private resolveFirstStorage(storage: FSStorage | undefined): void {
+ if (!this.firstStorageResolved) {
+ this.firstStorageResolved = true;
+ this.firstStorage.resolve(storage);
+ }
+ }
+
+ /**
+ * Points the file system at a project. The first time this supplies the
+ * storage the host is waiting for; afterwards it switches.
+ */
+ private async switchTo(id: string): Promise {
+ const db = await this.requireDb();
+ const previous = this.openStorage;
+ const indexed = new IndexedDBFSStorage(
+ db,
+ id,
+ (e) => this.handleSaveError(e),
+ () => this.channel?.postMessage({ type: "changed", projectIds: [id] })
+ );
+ const storage = new SplitStrategyStorage(
+ new InMemoryFSStorage(undefined),
+ indexed,
+ this.logging
+ );
+ this.openId = id;
+ this.openStorage = indexed;
+ setCurrentProjectId(this.session, id);
+ if (this.firstStorageResolved) {
+ await this.fs.switchStorage(storage);
+ } else {
+ this.resolveFirstStorage(storage);
+ }
+ await previous?.dispose();
+ }
+
+ /**
+ * The open project is gone. Its storage is left in place until the editor
+ * next asks for a project; nothing writes to it from the pages.
+ */
+ private letGoOfOpenProject(): void {
+ this.openId = undefined;
+ this.openStorage = undefined;
+ if (getCurrentProjectId(this.session)) {
+ setCurrentProjectId(this.session, undefined);
+ }
+ }
+
+ /**
+ * Writes are per keystroke, so once storage is full every flush fails;
+ * report the first so Sentry sees it and leave the toast to say the rest.
+ */
+ private handleSaveError(error: unknown): void {
+ if (!this.saveErrorReported) {
+ this.saveErrorReported = true;
+ this.logging.error("Failed to save project", error);
+ }
+ this.dispatchTypedEvent("saveerror", new ProjectSaveErrorEvent(error));
+ }
+
+ private async changed(ids: string[]): Promise {
+ await this.refresh();
+ this.channel?.postMessage({ type: "changed", projectIds: ids });
+ }
+
+ private handleSyncMessage = (event: MessageEvent) => {
+ void (async () => {
+ const { type, projectIds } = event.data;
+ await this.refresh();
+ if (!this.openId || !projectIds.includes(this.openId)) {
+ return;
+ }
+ if (type === "deleted") {
+ this.letGoOfOpenProject();
+ } else if (this.db) {
+ // Reload the open project so this tab shows the other's edits.
+ const id = this.openId;
+ this.openId = undefined;
+ await this.switchTo(id);
+ }
+ })();
+ };
+}
diff --git a/src/project/storage-error-toast.tsx b/src/project/storage-error-toast.tsx
new file mode 100644
index 000000000..472dbd7f4
--- /dev/null
+++ b/src/project/storage-error-toast.tsx
@@ -0,0 +1,67 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { ToastOptions, useToast } from "@microbit/ui";
+import { useCallback, useEffect } from "react";
+import { useIntl } from "react-intl";
+import { useProjectsIfAvailable } from "./projects-hooks";
+import { ProjectSaveErrorEvent } from "./projects";
+
+const toastId = "storage-error";
+
+export const isQuotaExceededError = (error: unknown): boolean =>
+ error instanceof DOMException && error.name === "QuotaExceededError";
+
+/**
+ * Shows the persistent "storage full" or "failed to save" toast, the same
+ * one as ml-trainer. Repeat failures update the toast rather than stacking.
+ */
+export const useShowStorageError = (): ((error: unknown) => void) => {
+ const toast = useToast();
+ const intl = useIntl();
+ return useCallback(
+ (error: unknown) => {
+ const options: ToastOptions = {
+ id: toastId,
+ status: "error",
+ persistent: true,
+ ...(isQuotaExceededError(error)
+ ? {
+ title: intl.formatMessage({ id: "storage-error-quota-title" }),
+ description: intl.formatMessage({
+ id: "storage-error-quota-description",
+ }),
+ }
+ : { title: intl.formatMessage({ id: "storage-error-other" }) }),
+ };
+ if (toast.isActive(toastId)) {
+ toast.update(toastId, options);
+ } else {
+ toast(options);
+ }
+ },
+ [intl, toast]
+ );
+};
+
+/**
+ * Tells the user when the open project stops saving to the projects
+ * database. Nothing to do in iframe mode, where the host owns the project.
+ */
+const StorageErrorToast = () => {
+ const projects = useProjectsIfAvailable();
+ const showStorageError = useShowStorageError();
+ useEffect(() => {
+ if (!projects) {
+ return;
+ }
+ const listener = (e: ProjectSaveErrorEvent) => showStorageError(e.error);
+ projects.addEventListener("saveerror", listener);
+ return () => projects.removeEventListener("saveerror", listener);
+ }, [projects, showStorageError]);
+ return null;
+};
+
+export default StorageErrorToast;
diff --git a/src/router-hooks.test.tsx b/src/router-hooks.test.tsx
index cff888e3a..1062efd46 100644
--- a/src/router-hooks.test.tsx
+++ b/src/router-hooks.test.tsx
@@ -8,8 +8,9 @@ import { useEffect } from "react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { LoggingProvider } from "./logging/logging-hooks";
import { MockLogging } from "./logging/mock";
+import { IframeModeProvider } from "./iframe-mode-hooks";
import { NavigationSource, RouterState, useRouterState } from "./router-hooks";
-import { editorRoutePath } from "./urls";
+import { editorRoutePath, iframeEditorRoutePath } from "./urls";
const result: { current?: ReturnType } = {};
const state = (): RouterState => result.current![0];
@@ -24,14 +25,17 @@ const Probe = () => {
return null;
};
-const renderAt = (path: string) => {
+const renderAt = (path: string, iframe = false) => {
const logging = new MockLogging();
const router = createMemoryRouter(
[
{
path: "",
children: [
- { path: editorRoutePath, element: },
+ {
+ path: iframe ? iframeEditorRoutePath : editorRoutePath,
+ element: ,
+ },
{ path: "*", element: },
],
},
@@ -40,20 +44,22 @@ const renderAt = (path: string) => {
);
render(
-
+
+
+
);
return { router, logging };
};
describe("useRouterState", () => {
- it("is empty at the root", () => {
- renderAt("/");
+ it("is empty at the editor's root", () => {
+ renderAt("/project");
expect(state()).toEqual({});
});
it("reads the tab and slug from the path", () => {
- renderAt("/reference/display");
+ renderAt("/project/reference/display");
expect(state()).toEqual({
tab: "reference",
slug: { id: "display" },
@@ -62,21 +68,21 @@ describe("useRouterState", () => {
});
it("ignores unknown tabs", () => {
- renderAt("/nonsense/display");
+ renderAt("/project/nonsense/display");
expect(state()).toEqual({});
});
it("treats deeper paths as the editor with no tab", () => {
- renderAt("/api/a/b");
+ renderAt("/project/api/a/b");
expect(state()).toEqual({});
});
it("navigates, carrying focus in history state, and logs the source", async () => {
- const { router, logging } = renderAt("/");
+ const { router, logging } = renderAt("/project");
await act(async () => {
setState({ tab: "api", slug: { id: "microbit" }, focus: true }, "code");
});
- expect(router.state.location.pathname).toEqual("/api/microbit");
+ expect(router.state.location.pathname).toEqual("/project/api/microbit");
expect(state()).toEqual({
tab: "api",
slug: { id: "microbit" },
@@ -90,8 +96,21 @@ describe("useRouterState", () => {
]);
});
+ it("keeps the editor at the root in iframe mode", async () => {
+ const { router } = renderAt("/reference/display", true);
+ expect(state()).toEqual({
+ tab: "reference",
+ slug: { id: "display" },
+ focus: false,
+ });
+ await act(async () => {
+ setState({ tab: "api", slug: { id: "microbit" } });
+ });
+ expect(router.state.location.pathname).toEqual("/api/microbit");
+ });
+
it("does not log without a source", async () => {
- const { logging } = renderAt("/");
+ const { logging } = renderAt("/project");
await act(async () => {
setState({ tab: "ideas" });
});
@@ -99,7 +118,7 @@ describe("useRouterState", () => {
});
it("gives a new state object when navigating to the same anchor again", async () => {
- renderAt("/reference/display");
+ renderAt("/project/reference/display");
const before = state();
await act(async () => {
setState({ tab: "reference", slug: { id: "display" } });
diff --git a/src/router-hooks.tsx b/src/router-hooks.tsx
index aa728b435..62eb7d14f 100644
--- a/src/router-hooks.tsx
+++ b/src/router-hooks.tsx
@@ -12,13 +12,14 @@
import { useCallback, useMemo } from "react";
import { useLocation, useNavigate, useParams } from "react-router";
import { useLogging } from "./logging/logging-hooks";
-import { createEditorUrl } from "./urls";
+import { createEditorUrl, createIframeEditorUrl } from "./urls";
+import { useIframeMode } from "./iframe-mode-hooks";
export type TabName = "api" | "ideas" | "reference" | "project";
const tabNames: readonly string[] = ["api", "ideas", "reference", "project"];
-const isTabName = (value: string | undefined): value is TabName =>
+export const isTabName = (value: string | undefined): value is TabName =>
value !== undefined && tabNames.includes(value);
/**
@@ -65,6 +66,7 @@ export const useRouterState = (): RouterContextValue => {
const location = useLocation();
const navigate = useNavigate();
const logging = useLogging();
+ const iframeMode = useIframeMode();
const focus = (location.state as LocationState | null)?.focus ?? false;
const state = useMemo(
@@ -87,9 +89,12 @@ export const useRouterState = (): RouterContextValue => {
});
}
const locationState: LocationState = { focus: newState.focus };
- void navigate(createEditorUrl(newState), { state: locationState });
+ const url = iframeMode
+ ? createIframeEditorUrl(newState)
+ : createEditorUrl(newState);
+ void navigate(url, { state: locationState });
},
- [logging, navigate]
+ [iframeMode, logging, navigate]
);
return useMemo(() => [state, setState], [state, setState]);
diff --git a/src/router.tsx b/src/router.tsx
index 03f6f0645..474361c17 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -3,24 +3,136 @@
*
* SPDX-License-Identifier: MIT
*/
-import { createBrowserRouter } from "react-router";
+import { NotFoundPage } from "@microbit/ui-patterns";
+import {
+ createBrowserRouter,
+ Navigate,
+ redirect,
+ useParams,
+} from "react-router";
+import { baseUrl } from "./base";
+import { hasProjectLink } from "./fs/migration";
+import HomePage from "./pages/HomePage";
+import ProjectsPage from "./pages/ProjectsPage";
+import { Projects } from "./project/projects";
import RootLayout from "./RootLayout";
-import { basename, editorRoutePath } from "./urls";
+import { isTabName } from "./router-hooks";
+import {
+ basename,
+ createEditorUrl,
+ createHomePageUrl,
+ createProjectsPageUrl,
+ editorRoutePath,
+ iframeEditorRoutePath,
+ legacyEditorRoutePath,
+ routerPathFromUrl,
+} from "./urls";
+import ProjectDropTarget from "./project/ProjectDropTarget";
import Workbench from "./workbench/Workbench";
-export const createRouter = () =>
- createBrowserRouter(
+/**
+ * The editor's pre-/project URLs: a known documentation tab redirects to
+ * the same tab under /project, anything else is not found.
+ */
+const LegacyEditorRedirect = () => {
+ const { tab, slug } = useParams<"tab" | "slug">();
+ if (!isTabName(tab)) {
+ return ;
+ }
+ return (
+
+ );
+};
+
+const NotFound = () => ;
+
+export interface RouterOptions {
+ /**
+ * The projects in this browser. Undefined in iframe controller mode, where
+ * the editor is the only page and stays at the root.
+ */
+ projects: Projects | undefined;
+}
+
+export const createRouter = ({ projects }: RouterOptions) => {
+ /**
+ * The pages need the project list and only make sense with the projects
+ * database; without it there is one implicit project and the editor is
+ * the whole app.
+ */
+ const pagesLoader = async () => {
+ if (!(await projects!.isAvailable())) {
+ return redirect(createEditorUrl());
+ }
+ await projects!.refresh();
+ return null;
+ };
+ /**
+ * A #project: link on a microbit.org page still points at the root, so
+ * the program it carries goes to the editor.
+ */
+ const homeLoader = () => {
+ if (hasProjectLink(window.location.href)) {
+ return redirect(createEditorUrl() + window.location.hash);
+ }
+ return pagesLoader();
+ };
+ // Files dropped on the editor join the open project; the pages have their
+ // own targets, which make a new project.
+ const editor = (
+
+
+
+ );
+ return createBrowserRouter(
[
{
id: "root",
path: "",
element: ,
- children: [
- { path: editorRoutePath, element: },
- // Deeper paths are the editor with no tab selected, as before.
- { path: "*", element: },
- ],
+ children: !projects
+ ? [
+ { path: iframeEditorRoutePath, element: editor },
+ // Deeper paths are the editor with no tab selected, as before.
+ { path: "*", element: editor },
+ ]
+ : [
+ {
+ path: createHomePageUrl(),
+ element: ,
+ loader: homeLoader,
+ },
+ {
+ path: createProjectsPageUrl(),
+ element: ,
+ loader: pagesLoader,
+ },
+ {
+ path: editorRoutePath,
+ element: editor,
+ // The editor needs a project; choosing one is the only
+ // asynchronous step, the MicroPython load carries on behind.
+ loader: async ({ request }) => {
+ if (await projects.openCurrent()) {
+ // A #project: link became a project, so drop the hash or
+ // a reload would import it again. Without the database
+ // the host takes the link and strips the hash itself.
+ return redirect(routerPathFromUrl(request.url));
+ }
+ return null;
+ },
+ },
+ {
+ path: legacyEditorRoutePath,
+ element: ,
+ },
+ { path: "*", element: },
+ ],
},
],
{ basename }
);
+};
diff --git a/src/settings/SettingsMenu.tsx b/src/settings/SettingsMenu.tsx
index 64c2e5224..1edc42acc 100644
--- a/src/settings/SettingsMenu.tsx
+++ b/src/settings/SettingsMenu.tsx
@@ -3,7 +3,13 @@
*
* SPDX-License-Identifier: MIT
*/
-import { IconButton, MenuItem, MenuList, MenuTrigger } from "@microbit/ui";
+import {
+ IconButton,
+ MenuItem,
+ MenuList,
+ MenuTrigger,
+ SystemStyleObject,
+} from "@microbit/ui";
import { useCallback, useRef, useState } from "react";
import { IoMdGlobe } from "react-icons/io";
import { RiListSettingsLine, RiSettings2Line } from "react-icons/ri";
@@ -15,12 +21,24 @@ import { SettingsDialog } from "./SettingsDialog";
interface SettingsMenuProps {
size?: "lg" | "md" | "sm" | "xs";
+ /**
+ * The trigger's button variant. The default suits the editor's black
+ * chrome; the pages pass "plain" for the brand-coloured header, where the
+ * family shows no hover state on icon buttons.
+ */
+ variant?: "sidebar" | "plain";
+ /** Per-instance overrides for the trigger button, merged last. */
+ css?: SystemStyleObject;
}
/**
* The settings button triggers a menu with main and other settings.
*/
-const SettingsMenu = ({ size }: SettingsMenuProps) => {
+const SettingsMenu = ({
+ size,
+ variant = "sidebar",
+ css: cssProp,
+}: SettingsMenuProps) => {
const [languageDialogOpen, setLanguageDialogOpen] = useState(false);
const intl = useIntl();
const dialogs = useDialogs();
@@ -47,8 +65,8 @@ const SettingsMenu = ({ size }: SettingsMenuProps) => {
data-testid="settings"
aria-label={intl.formatMessage({ id: "settings" })}
size={size}
- css={{ fontSize: "xl" }}
- variant="sidebar"
+ css={{ fontSize: "xl", ...cssProp }}
+ variant={variant}
>
diff --git a/src/urls.test.ts b/src/urls.test.ts
new file mode 100644
index 000000000..6748d0234
--- /dev/null
+++ b/src/urls.test.ts
@@ -0,0 +1,29 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { routerPathFromUrl } from "./urls";
+
+describe("routerPathFromUrl", () => {
+ it("returns the path and query at the root", () => {
+ expect(
+ routerPathFromUrl("http://localhost:3000/project?flag=none", undefined)
+ ).toEqual("/project?flag=none");
+ });
+
+ it("drops the basename", () => {
+ expect(
+ routerPathFromUrl(
+ "https://python.microbit.org/v/3/project/reference/display",
+ "/v/3"
+ )
+ ).toEqual("/project/reference/display");
+ });
+
+ it("maps the basename itself to the root", () => {
+ expect(
+ routerPathFromUrl("https://python.microbit.org/v/3", "/v/3")
+ ).toEqual("/");
+ });
+});
diff --git a/src/urls.ts b/src/urls.ts
index 166405198..eab1e82e4 100644
--- a/src/urls.ts
+++ b/src/urls.ts
@@ -14,19 +14,58 @@ import type { RouterState } from "./router-hooks";
/**
* The react-router basename: the Vite base URL without its trailing slash, so
- * that both `/v/3` and `/v/3/` are the editor.
+ * that both `/v/3` and `/v/3/` are the app.
*/
export const basename =
baseUrl === "/" ? undefined : baseUrl.replace(/\/$/, "");
+/**
+ * The router-relative path and query of a full URL, for a loader that wants
+ * to redirect to where it already is: `request.url` carries the basename and
+ * `redirect` adds it back.
+ */
+export const routerPathFromUrl = (url: string, base = basename): string => {
+ const { pathname, search } = new URL(url);
+ const path =
+ base && pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
+ return (path || "/") + search;
+};
+
+export const createHomePageUrl = (): string => "/";
+
+export const createProjectsPageUrl = (): string => "/projects";
+
/**
* Route path for the editor. The documentation tab and its anchor are
* optional segments so the editor stays mounted as they change.
*/
-export const editorRoutePath = ":tab?/:slug?";
+export const editorRoutePath = "project/:tab?/:slug?";
+
+/**
+ * Route path for the editor in iframe controller mode, where the embedding
+ * page owns the URL and there are no other pages.
+ */
+export const iframeEditorRoutePath = ":tab?/:slug?";
+
+/**
+ * Route path matching the editor's URLs from before it moved under /project,
+ * so documentation links keep working.
+ */
+export const legacyEditorRoutePath = ":tab/:slug?";
+
+const editorSegments = ({ tab, slug }: RouterState = {}): string =>
+ [tab, slug?.id].filter((x): x is string => !!x).join("/");
/**
* Path for the editor showing the given documentation tab and anchor.
*/
-export const createEditorUrl = ({ tab, slug }: RouterState = {}): string =>
- "/" + [tab, slug?.id].filter((x): x is string => !!x).join("/");
+export const createEditorUrl = (state: RouterState = {}): string => {
+ const rest = editorSegments(state);
+ return rest ? `/project/${rest}` : "/project";
+};
+
+/**
+ * Path for the editor in iframe controller mode; see iframeEditorRoutePath.
+ */
+export const createIframeEditorUrl = (state: RouterState = {}): string =>
+ "/" + editorSegments(state);
diff --git a/src/workbench/BeforeUnloadDirtyCheck.tsx b/src/workbench/BeforeUnloadDirtyCheck.tsx
index 9901d82c6..f96e94a48 100644
--- a/src/workbench/BeforeUnloadDirtyCheck.tsx
+++ b/src/workbench/BeforeUnloadDirtyCheck.tsx
@@ -5,13 +5,20 @@
*/
import { useEffect } from "react";
import { useFileSystem } from "../fs/fs-hooks";
+import { useProjectsDatabaseActive } from "../fs/storage-status";
/**
- * Warns the user before closing a tab if they've made changes.
+ * Warns the user before closing a tab if they've made changes that would be
+ * lost. Nothing is lost when the project is in the projects database, so the
+ * warning only applies to the session-storage fallback and iframe mode.
*/
const BeforeUnloadDirtyCheck = () => {
const fs = useFileSystem();
+ const persisted = useProjectsDatabaseActive();
useEffect(() => {
+ if (persisted) {
+ return;
+ }
const listener = (e: BeforeUnloadEvent) => {
if (fs.dirty) {
e.preventDefault();
@@ -24,7 +31,7 @@ const BeforeUnloadDirtyCheck = () => {
return () => {
window.removeEventListener("beforeunload", listener);
};
- }, [fs]);
+ }, [fs, persisted]);
return null;
};
diff --git a/src/workbench/HelpMenu.tsx b/src/workbench/HelpMenu.tsx
index def10adbf..1f6dead58 100644
--- a/src/workbench/HelpMenu.tsx
+++ b/src/workbench/HelpMenu.tsx
@@ -9,6 +9,7 @@ import {
MenuItem,
MenuList,
MenuTrigger,
+ SystemStyleObject,
} from "@microbit/ui";
import { useCallback, useRef, useState } from "react";
import { MdOutlineCookie } from "react-icons/md";
@@ -26,12 +27,24 @@ import FeedbackForm from "./FeedbackForm";
interface HelpMenuProps {
size?: "lg" | "md" | "sm" | "xs";
+ /**
+ * The trigger's button variant. The default suits the editor's black
+ * chrome; the pages pass "plain" for the brand-coloured header, where the
+ * family shows no hover state on icon buttons.
+ */
+ variant?: "sidebar" | "plain";
+ /** Per-instance overrides for the trigger button, merged last. */
+ css?: SystemStyleObject;
}
/**
* A help button that triggers a drop-down menu with actions.
*/
-const HelpMenu = ({ size }: HelpMenuProps) => {
+const HelpMenu = ({
+ size,
+ variant = "sidebar",
+ css: cssProp,
+}: HelpMenuProps) => {
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
const intl = useIntl();
const dialogs = useDialogs();
@@ -62,8 +75,8 @@ const HelpMenu = ({ size }: HelpMenuProps) => {
ref={menuButtonRef}
aria-label={intl.formatMessage({ id: "help" })}
size={size}
- css={{ fontSize: "xl" }}
- variant="sidebar"
+ css={{ fontSize: "xl", ...cssProp }}
+ variant={variant}
>
diff --git a/src/workbench/PreReleaseNotice.tsx b/src/workbench/PreReleaseNotice.tsx
index d8c3ad3e5..cd5b830a9 100644
--- a/src/workbench/PreReleaseNotice.tsx
+++ b/src/workbench/PreReleaseNotice.tsx
@@ -5,10 +5,10 @@
*/
import { Button, darkSurface, Text } from "@microbit/ui";
import { useCallback, useEffect, useState } from "react";
-import { RiFeedbackFill, RiInformationFill } from "react-icons/ri";
+import { RiFeedbackFill } from "react-icons/ri";
import { HStack, styled } from "styled-system/jsx";
import { useStorage } from "../common/use-storage";
-import { useCookieConsent, useDeployment } from "../deployment";
+import { useCookieConsent } from "../deployment";
import { flags } from "../flags";
export type ReleaseNoticeState = "info" | "feedback" | "closed";
@@ -56,10 +56,6 @@ export const useReleaseDialogState = (): [
};
const PreReleaseNotice = ({ onDialogChange }: PreReleaseNoticeProps) => {
- const { welcomeVideoYouTubeId: hasInfoDialog } = useDeployment();
- const openInfoDialog = useCallback(() => {
- onDialogChange("info");
- }, [onDialogChange]);
const openFeedbackDialog = useCallback(() => {
onDialogChange("feedback");
}, [onDialogChange]);
@@ -80,17 +76,6 @@ const PreReleaseNotice = ({ onDialogChange }: PreReleaseNoticeProps) => {
Beta release
- {hasInfoDialog && (
- }
- variant="link"
- size="xs"
- css={{ color: "white", p: "1", fontWeight: "semibold" }}
- onPress={openInfoDialog}
- >
- More
-
- )}
}
variant="link"
diff --git a/src/workbench/SideBar.tsx b/src/workbench/SideBar.tsx
index 693a10c28..cbf5de007 100644
--- a/src/workbench/SideBar.tsx
+++ b/src/workbench/SideBar.tsx
@@ -95,7 +95,7 @@ const SideBar = ({
},
{
id: "project" as const,
- title: intl.formatMessage({ id: "project-tab" }),
+ title: intl.formatMessage({ id: "files-tab" }),
icon: VscFiles,
contents: (
{
if (searchAvailable) {
@@ -131,6 +141,18 @@ const SideBarHeader = ({
? faceLogoRef.current.getBoundingClientRect().right + paddingX
: 0;
const modalWidth = contentWidth - modalOffset + "px";
+ const logo = (
+
+
+ {brand.squareLogo}
+
+ {!query && sidebarShown && (
+
+ {brand.horizontalLogo}
+
+ )}
+
+ );
return (
<>
{searchAvailable && searchModalOpen && (
@@ -181,30 +203,32 @@ const SideBarHeader = ({
searchAvailable && searchModalOpen ? "4.95rem" : topBarHeight,
}}
>
-
-
-
- {brand.squareLogo}
-
- {!query && sidebarShown && (
-
- {brand.horizontalLogo}
-
- )}
-
-
+ {logoLinksHome ? (
+
+ {logo}
+
+ ) : (
+
+ {logo}
+
+ )}
{searchAvailable && !query && sidebarShown && (
{
const intl = useIntl();
const [maybeInvalidSelection, setSelection] = useSelection();
- const { files } = useProject();
+ const { id: projectId, files } = useProject();
const selection = defaultSelection(maybeInvalidSelection, files);
+ // Opening another project shows its main.py, not whichever file was open
+ // in the last one. Before paint so the previous file does not flash.
+ useLayoutEffect(() => {
+ setSelection({ file: MAIN_FILE, location: { line: undefined } });
+ }, [projectId, setSelection]);
const setSelectedFile = useCallback(
(file: string) => {
setSelection({ file, location: { line: undefined } });
diff --git a/src/workbench/flags.test.ts b/src/workbench/flags.test.ts
index 455f2144c..c30594d3a 100644
--- a/src/workbench/flags.test.ts
+++ b/src/workbench/flags.test.ts
@@ -3,61 +3,93 @@
*
* SPDX-License-Identifier: MIT
*/
-import { flagsForParams } from "../flags";
+import { FlagMetadata, flagsForParams } from "../flags";
-describe("flags", () => {
- it("enables opt-in flags for REVIEW stage", () => {
- const params = new URLSearchParams([]);
-
- const flags = flagsForParams("REVIEW", params);
- expect(flags.noWelcome).toEqual(true);
- expect(flags.dndDebug).toEqual(false);
- });
-
- it("only enables PWA in production", () => {
- const params = new URLSearchParams([]);
-
- const flags = flagsForParams("PRODUCTION", params);
+/**
+ * Test-only flag metadata so these tests cover the resolution rules
+ * rather than the current defaults for real flags.
+ */
+type TestFlag = "onInReview" | "onInProduction" | "alwaysOff";
+const testFlags: FlagMetadata[] = [
+ { name: "onInReview", defaultOnStages: ["local", "REVIEW"] },
+ { name: "onInProduction", defaultOnStages: ["PRODUCTION"] },
+ { name: "alwaysOff", defaultOnStages: [] },
+];
- expect(flags.pwa).toBe(true);
- const { pwa, ...filteredFlags } = flags;
+const resolve = (
+ stage: Parameters[0],
+ params: [string, string][]
+) => flagsForParams(stage, new URLSearchParams(params), testFlags);
- expect(Object.values(filteredFlags).every((x) => !x)).toEqual(true);
+describe("flags", () => {
+ it("uses stage defaults when nothing is specified", () => {
+ expect(resolve("REVIEW", [])).toEqual({
+ onInReview: true,
+ onInProduction: false,
+ alwaysOff: false,
+ });
+ expect(resolve("PRODUCTION", [])).toEqual({
+ onInReview: false,
+ onInProduction: true,
+ alwaysOff: false,
+ });
});
- it("enable specific flag", () => {
- const params = new URLSearchParams([["flag", "noWelcome"]]);
-
- const flags = flagsForParams("PRODUCTION", params);
+ it("enables a specific flag on top of the stage defaults", () => {
+ expect(resolve("PRODUCTION", [["flag", "alwaysOff"]])).toEqual({
+ onInReview: false,
+ onInProduction: true,
+ alwaysOff: true,
+ });
+ });
- expect(
- Object.entries(flags).every(
- ([flag, status]) => (flag === "noWelcome" || flag === "pwa") === status
- )
- ).toEqual(true);
+ it("enables everything with *", () => {
+ expect(resolve("PRODUCTION", [["flag", "*"]])).toEqual({
+ onInReview: true,
+ onInProduction: true,
+ alwaysOff: true,
+ });
});
- it("enable everything", () => {
- const params = new URLSearchParams([["flag", "*"]]);
- const flags = flagsForParams("PRODUCTION", params);
- expect(Object.values(flags).every((x) => x)).toEqual(true);
+ it("disables everything with none", () => {
+ expect(resolve("REVIEW", [["flag", "none"]])).toEqual({
+ onInReview: false,
+ onInProduction: false,
+ alwaysOff: false,
+ });
});
- it("enable nothing", () => {
- const params = new URLSearchParams([["flag", "none"]]);
- const flags = flagsForParams("REVIEW", params);
- expect(Object.values(flags).every((x) => !x)).toEqual(true);
+ it("combines none with specific enabled flags", () => {
+ expect(
+ resolve("REVIEW", [
+ ["flag", "none"],
+ ["flag", "alwaysOff"],
+ ])
+ ).toEqual({
+ onInReview: false,
+ onInProduction: false,
+ alwaysOff: true,
+ });
});
- it("can combine none with specific enabled flags in REVIEW", () => {
- const params = new URLSearchParams([
- ["flag", "none"],
- ["flag", "noWelcome"],
- ]);
+ it("ignores unknown flags", () => {
+ expect(resolve("PRODUCTION", [["flag", "doesNotExist"]])).toEqual({
+ onInReview: false,
+ onInProduction: true,
+ alwaysOff: false,
+ });
+ });
- const flags = flagsForParams("REVIEW", params);
+ describe("local storage", () => {
+ afterEach(() => localStorage.removeItem("flags"));
- expect(flags.dndDebug).toBe(false);
- expect(flags.noWelcome).toBe(true);
+ it("enables comma-separated flags from local storage", () => {
+ localStorage.setItem("flags", "alwaysOff, onInReview");
+ expect(resolve("PRODUCTION", [])).toEqual({
+ onInReview: true,
+ onInProduction: true,
+ alwaysOff: true,
+ });
+ });
});
});
diff --git a/vite.config.ts b/vite.config.ts
index d40ff7839..d285b16a4 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -34,6 +34,23 @@ const theme = "@microbit-foundation/python-editor-v3-microbit";
const external = `node_modules/${theme}`;
const internal = "src/deployment/default";
+/**
+ * Resolves theme-package/images/* imports per file rather than per package:
+ * the branded package wins when it ships the file, and the OSS default in
+ * src/deployment/default stands in otherwise, so the branded package only
+ * needs to carry images that differ from the defaults. A miss resolves to
+ * the nonexistent default path so the build fails loudly, typos included.
+ */
+const resolveThemeImage = (id: string): string => {
+ if (fs.existsSync(external)) {
+ const branded = path.resolve(__dirname, external, "dist", id);
+ if (fs.existsSync(branded)) {
+ return branded;
+ }
+ }
+ return path.resolve(__dirname, internal, id);
+};
+
const featurePwa = process.env.FEATURE_PWA === "true";
const pwaCacheId =
// v3 vs beta should have distinct caches
@@ -82,6 +99,29 @@ const viteRemoveManifestPlugin = (): Plugin => ({
},
});
+/**
+ * Serves the base URL without its trailing slash, which the app's own links
+ * to the home page are: react-router renders the route at "/" as the bare
+ * basename. S3 serves it, vite preview 404s it, and the e2e tests run
+ * against preview.
+ */
+const basePathWithoutTrailingSlashPlugin = (): Plugin => ({
+ name: "base-path-without-trailing-slash",
+ configurePreviewServer(server) {
+ const base = process.env.BASE_URL ?? "/";
+ if (base === "/") {
+ return;
+ }
+ server.middlewares.use((req, _res, next) => {
+ const [path, query] = (req.url ?? "").split("?");
+ if (path === base.replace(/\/$/, "")) {
+ req.url = base + (query ? `?${query}` : "");
+ }
+ next();
+ });
+ },
+});
+
export default defineConfig(({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
const unitTest: UserConfig["test"] = {
@@ -216,28 +256,46 @@ export default defineConfig(({ mode }) => {
},
}),
viteRemoveManifestPlugin(),
+ basePathWithoutTrailingSlashPlugin(),
],
test: unitTest,
resolve: {
- alias: {
- "theme-package": fs.existsSync(external)
- ? theme
- : path.resolve(__dirname, internal),
- // Resolve Panda's generated helpers for all importers, including
- // @microbit/ui's source (consumed from node_modules). Mirrors the
- // tsconfig `paths` entry.
- "styled-system": path.resolve(__dirname, "styled-system"),
- },
- // @microbit/ui is consumed as source via a file: symlink, so its
- // `import "react"` etc. would otherwise resolve to the ui monorepo's own
- // copies — two Reacts → invalid-hook "useContext of null" crashes. Force
- // a single copy (the app's) for React and the react-aria stack.
+ alias: [
+ {
+ // Theme images resolve per file so the branded package only ships
+ // images that differ from the OSS defaults. See resolveThemeImage.
+ find: /^theme-package\/(images\/.+)$/,
+ replacement: "$1",
+ customResolver: (id: string) => resolveThemeImage(id),
+ },
+ {
+ find: "theme-package",
+ replacement: fs.existsSync(external)
+ ? theme
+ : path.resolve(__dirname, internal),
+ },
+ {
+ // Resolve Panda's generated helpers for all importers, including
+ // @microbit/ui's source (consumed from node_modules). Mirrors the
+ // tsconfig `paths` entry.
+ find: "styled-system",
+ replacement: path.resolve(__dirname, "styled-system"),
+ },
+ ],
+ // The @microbit/ui packages are consumed as source and, when symlinked
+ // to a local ../ui checkout, their `import "react"` etc. would resolve
+ // to the ui monorepo's own copies — two Reacts → invalid-hook
+ // "useContext of null" crashes, and react-intl's context is per copy
+ // too. Force a single copy (the app's) of everything they share with it.
dedupe: [
"react",
"react-dom",
"react-aria-components",
"react-aria",
"react-stately",
+ "react-intl",
+ "react-icons",
+ "swiper",
],
},
};