From 5fa2656896e18101af373c019606d99579a0c9c4 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 09:35:43 +0000 Subject: [PATCH 01/34] Take @microbit/ui-carousel for the home and projects pages Adds the carousel package and its swiper peer, includes its source in the Panda extraction, and extends dev:link-ui so a local ../ui checkout supplies it alongside ui and ui-patterns. --- package-lock.json | 35 +++++++++++++++++++++++++++++++++++ package.json | 4 +++- panda.config.ts | 1 + vite.config.ts | 12 ++++++++---- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 195ff89cd..fe479e1a7 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.4.0", + "@microbit/ui-carousel": "^0.3.0", "@microbit/ui-patterns": "^0.6.1", "@sanity/block-content-to-react": "^3.0.0", "@sanity/image-url": "^1.0.1", @@ -48,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", @@ -3397,6 +3399,20 @@ "react-intl": "^6.6.8 || ^7.0.0" } }, + "node_modules/@microbit/ui-carousel": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@microbit/ui-carousel/-/ui-carousel-0.3.0.tgz", + "integrity": "sha512-92JCrpL8KHQ9/l4J5eeG58lQfQnSvwsplD07l1k4vJnZHQNoJ10d9A/gZo96zV8Q//d57TYzXm/hdnQB63zmmQ==", + "license": "MIT", + "peerDependencies": { + "@microbit/ui": "^0.4.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.6.1", "resolved": "https://registry.npmjs.org/@microbit/ui-patterns/-/ui-patterns-0.6.1.tgz", @@ -11667,6 +11683,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", diff --git a/package.json b/package.json index 85006632c..c5ee09a71 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.4.0", + "@microbit/ui-carousel": "^0.3.0", "@microbit/ui-patterns": "^0.6.1", "@sanity/block-content-to-react": "^3.0.0", "@sanity/image-url": "^1.0.1", @@ -52,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", @@ -91,7 +93,7 @@ "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", "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/vite.config.ts b/vite.config.ts index d40ff7839..b829d2e89 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -228,16 +228,20 @@ export default defineConfig(({ mode }) => { // 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. + // 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", ], }, }; From 4141cae2efe7fb6e78655195c885e9ddac3301ff Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 09:50:06 +0000 Subject: [PATCH 02/34] Move the editor to /project The home and projects pages take the root, as decided in the multi-project plan. Documentation links to the editor's old /:tab/:slug paths redirect to the same tab under /project; a #project: link at the root redirects to the editor with its hash, since microbit.org pages still link there. Anything else is the shared not-found page. Iframe controller mode keeps the editor at the root as its only route: the embedding page owns the URL and there are no other pages. The mode is provided through context so the URL builders pick the right shape. The host strips a #project: hash from the current URL rather than the boot URL, which no longer names the page the editor is on after the redirect. --- src/App.tsx | 72 +++++++++++++++++++++------------------ src/e2e/app.ts | 2 ++ src/fs/host.ts | 9 +++-- src/iframe-mode-hooks.tsx | 16 +++++++++ src/router-hooks.test.tsx | 45 +++++++++++++++++------- src/router-hooks.tsx | 13 ++++--- src/router.tsx | 67 +++++++++++++++++++++++++++++++----- src/urls.ts | 35 ++++++++++++++++--- 8 files changed, 193 insertions(+), 66 deletions(-) create mode 100644 src/iframe-mode-hooks.tsx diff --git a/src/App.tsx b/src/App.tsx index 37a7bae50..a0b81650d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,8 @@ 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 { createHost, IframeHost } from "./fs/host"; +import { IframeModeProvider } from "./iframe-mode-hooks"; import { fetchMicroPython } from "./micropython/micropython"; import { LanguageServerClientProvider } from "./language-server/language-server-hooks"; import { logDeviceStatusChange } from "./logging/analytics"; @@ -48,6 +49,7 @@ const device: MicrobitUSBConnection = isMockDeviceMode() : createUSBConnection({ logging }); const host = createHost(logging); +const iframeMode = host instanceof IframeHost; const fs = new FileSystem(logging, host, fetchMicroPython); // If this fails then we retry on access. @@ -75,46 +77,48 @@ const App = () => { const deployment = useDeployment(); const { ConsentProvider } = deployment.compliance; - const router = useMemo(() => createRouter(), []); + const router = useMemo(() => createRouter({ iframe: iframeMode }), []); 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/e2e/app.ts b/src/e2e/app.ts index acf2d5718..873d9c68f 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -855,6 +855,8 @@ export const editorUrl = (options: UrlOptions = {}): string => { // 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 ?? "/") + + // In controller mode the editor is the only page and stays at the root. + (options.controller ? "" : "project") + "?" + new URLSearchParams(params).toString() + (options.fragment ?? "") diff --git a/src/fs/host.ts b/src/fs/host.ts index 7ccdecfd9..fad808055 100644 --- a/src/fs/host.ts +++ b/src/fs/host.ts @@ -54,7 +54,7 @@ export class DefaultHost implements Host { async createInitialProject(): Promise { const migrationParseResult = parseMigrationFromUrl(this.url); if (migrationParseResult) { - const { migration, postMigrationUrl } = migrationParseResult; + const { migration } = migrationParseResult; const project = { files: projectFilesToBase64({ [MAIN_FILE]: migration.source, @@ -62,8 +62,11 @@ export class DefaultHost implements Host { 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); + // will reload from storage not remigrate. 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 project; } return defaultInitialProject; 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/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..fa1e7d592 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -3,23 +3,74 @@ * * SPDX-License-Identifier: MIT */ -import { createBrowserRouter } from "react-router"; +import { NotFoundPage } from "@microbit/ui-patterns"; +import { createBrowserRouter, Navigate, useParams } from "react-router"; +import { baseUrl } from "./base"; import RootLayout from "./RootLayout"; -import { basename, editorRoutePath } from "./urls"; +import { isTabName } from "./router-hooks"; +import { + basename, + createEditorUrl, + editorRoutePath, + iframeEditorRoutePath, + legacyEditorRoutePath, +} from "./urls"; import Workbench from "./workbench/Workbench"; -export const createRouter = () => +/** + * 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 ( + + ); +}; + +/** + * The editor keeps its place at the root here so a #project: link on a + * microbit.org page still opens the program it carries. + */ +const RootRedirect = () => ( + +); + +const NotFound = () => ; + +export interface RouterOptions { + /** Controller mode: the editor is the only page and stays at the root. */ + iframe: boolean; +} + +export const createRouter = ({ iframe }: RouterOptions) => createBrowserRouter( [ { id: "root", path: "", element: , - children: [ - { path: editorRoutePath, element: }, - // Deeper paths are the editor with no tab selected, as before. - { path: "*", element: }, - ], + children: iframe + ? [ + { path: iframeEditorRoutePath, element: }, + // Deeper paths are the editor with no tab selected, as before. + { path: "*", element: }, + ] + : [ + { index: true, element: }, + { path: editorRoutePath, element: }, + { + path: legacyEditorRoutePath, + element: , + }, + { path: "*", element: }, + ], }, ], { basename } diff --git a/src/urls.ts b/src/urls.ts index 166405198..9a364c38a 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -14,19 +14,46 @@ 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(/\/$/, ""); +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); From e7b1b17655cde9943daa2c7917373ca0751d18c5 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 10:05:07 +0000 Subject: [PATCH 03/34] Choose the editor's project when its route loads Projects lists, creates, opens, renames, duplicates and deletes projects and keeps the file system on the open one. The host now takes the file system's persistent storage as a promise that Projects resolves when a project is first opened, so the pages can render without choosing one; later opens go through FileSystem.switchStorage. The database opens at boot and reports itself active; the editor route's loader asks Projects for a project, sharing one choice between concurrent calls. The tab's project is chosen as before, then the most recent project rather than a new one, so a straight-to-editor bookmark reopens your work. A new project is created with the starter program so it can be opened by switching, which does not run the host's initialisation. Pending writes are stamped with the edit time rather than the flush time, so "last modified" orders projects by when they were edited. Flushes report a change and the IndexedDB storage no longer closes the shared connection. Tabs tell each other about changes over a BroadcastChannel; a tab with the changed project open reloads it, or lets go of a deleted one. The router is created once at module level: a browser router runs its loaders on creation and StrictMode's double render made two, each creating a project. --- src/App.tsx | 68 +++++--- src/common/deferred.ts | 22 +++ src/e2e/iframe.test.ts | 18 +- src/fs/current-project.test.ts | 160 ++++++++++-------- src/fs/current-project.ts | 86 ++++++---- src/fs/host.ts | 22 ++- src/fs/indexeddb-storage.test.ts | 18 +- src/fs/indexeddb-storage.ts | 19 ++- src/fs/projects-db.ts | 28 ++++ src/fs/storage-status.ts | 2 + src/project/projects-hooks.tsx | 45 +++++ src/project/projects.test.ts | 169 +++++++++++++++++++ src/project/projects.ts | 279 +++++++++++++++++++++++++++++++ src/router.tsx | 23 ++- 14 files changed, 817 insertions(+), 142 deletions(-) create mode 100644 src/common/deferred.ts create mode 100644 src/project/projects-hooks.tsx create mode 100644 src/project/projects.test.ts create mode 100644 src/project/projects.ts diff --git a/src/App.tsx b/src/App.tsx index a0b81650d..293a0cc8e 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,8 +23,12 @@ 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 { openProjectsDatabase } from "./fs/current-project"; import { createHost, IframeHost } from "./fs/host"; +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"; @@ -48,13 +53,27 @@ 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 host = createHost(logging, 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); +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( @@ -77,7 +96,6 @@ const App = () => { const deployment = useDeployment(); const { ConsentProvider } = deployment.compliance; - const router = useMemo(() => createRouter({ iframe: iframeMode }), []); return ( <> @@ -93,26 +111,28 @@ const App = () => { - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/common/deferred.ts b/src/common/deferred.ts new file mode 100644 index 000000000..1123abd7b --- /dev/null +++ b/src/common/deferred.ts @@ -0,0 +1,22 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +/** + * A promise settled from outside, for a value that one party waits on and + * another supplies later. + */ +export interface Deferred { + 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/e2e/iframe.test.ts b/src/e2e/iframe.test.ts index 137aad086..e2f87707e 100644 --- a/src/e2e/iframe.test.ts +++ b/src/e2e/iframe.test.ts @@ -56,6 +56,20 @@ const openEmbeddedEditor = async (page: Page): Promise => { 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) => @@ -89,7 +103,7 @@ test.describe("iframe controller mode", () => { "workspaceloaded", ]); - await editorText(frame).fill("display.scroll('edited')"); + await replaceEditorText(app.page, frame, "display.scroll('edited')"); await expect .poll(() => lastSavedMain(app.page)) .toEqual("display.scroll('edited')"); @@ -120,7 +134,7 @@ test.describe("iframe controller mode", () => { const frame = await openEmbeddedEditor(app.page); await expect(editorText(frame)).toContainText("from the host"); - await editorText(frame).fill("display.scroll('edited')"); + await replaceEditorText(app.page, frame, "display.scroll('edited')"); await expect(editorText(frame)).toContainText("edited"); await app.closeAndExpectBeforeUnloadPrompt(); diff --git a/src/fs/current-project.test.ts b/src/fs/current-project.test.ts index 48619ae2b..3d2d43b57 100644 --- a/src/fs/current-project.test.ts +++ b/src/fs/current-project.test.ts @@ -4,21 +4,22 @@ * 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, - openCurrentProjectStorage, + openProjectsDatabase, setCurrentProjectId, } from "./current-project"; -import { IndexedDBFSStorage } from "./indexeddb-storage"; +import { MAIN_FILE } from "./fs"; import { databaseName, ProjectsDatabase } from "./projects-db"; import { SessionStorageFSStorage } from "./storage"; -import { renderHook } from "@testing-library/react"; import { + hasStorageVersionError, resetStorageStatus, useProjectsDatabaseActive, - useStorageVersionError, } from "./storage-status"; const deleteDatabase = () => @@ -30,109 +31,59 @@ const deleteDatabase = () => }); const encode = (text: string) => new TextEncoder().encode(text); +const isActive = () => + renderHook(() => useProjectsDatabaseActive()).result.current; -describe("openCurrentProjectStorage", () => { +describe("openProjectsDatabase", () => { let logging: MockLogging; beforeEach(async () => { - sessionStorage.clear(); await deleteDatabase(); logging = new MockLogging(); resetStorageStatus(); }); - it("creates a new project and makes it current when there is nothing", async () => { - const storage = await openCurrentProjectStorage(logging); - expect(storage).toBeInstanceOf(IndexedDBFSStorage); - expect( - renderHook(() => useProjectsDatabaseActive()).result.current - ).toEqual(true); - const id = getCurrentProjectId(sessionStorage); - expect(id).toBeDefined(); - - const db = await ProjectsDatabase.open(); - expect((await db.list()).map((p) => p.id)).toEqual([id]); - expect(await db.files(id!)).toEqual({}); - db.close(); - await (storage as IndexedDBFSStorage).dispose(); - }); - - it("reopens the tab's current project and marks it most recent", async () => { - const db = await ProjectsDatabase.open(); - await db.create({ id: "old", name: "Old", timestamp: 1 }, {}); - await db.create({ id: "cur", name: "Cur", timestamp: 2 }, {}); - await db.touch("old", 3); - db.close(); - setCurrentProjectId(sessionStorage, "cur"); - - const storage = await openCurrentProjectStorage(logging); - expect(await storage!.projectName()).toEqual("Cur"); - const reopened = await ProjectsDatabase.open(); - expect((await reopened.mostRecent())?.id).toEqual("cur"); - reopened.close(); - await (storage as IndexedDBFSStorage).dispose(); + 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("migrates a project from session storage on first load", async () => { - 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 storage = await openCurrentProjectStorage(logging); - - expect(await storage!.projectName()).toEqual("My project"); - expect(await storage!.ls()).toEqual(["helper.py", "main.py"]); - expect(Array.from(await storage!.read("main.py"))).toEqual( - Array.from(encode("# mine")) - ); - expect(await legacy.ls()).toEqual([]); - expect(await legacy.projectName()).toBeUndefined(); - expect(sessionStorage.getItem("unrelated")).toEqual("kept"); - expect(getCurrentProjectId(sessionStorage)).toBeDefined(); - await (storage as IndexedDBFSStorage).dispose(); - }); - - it("falls back to session storage when the database cannot be opened", async () => { + it("gives up and logs when the database cannot be opened", async () => { vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( new Error("blocked") ); - const storage = await openCurrentProjectStorage(logging); - expect(storage).toBeInstanceOf(SessionStorageFSStorage); + expect(await openProjectsDatabase(logging)).toBeUndefined(); expect(logging.errors[0].message).toMatch(/using session storage/); - expect( - renderHook(() => useProjectsDatabaseActive()).result.current - ).toEqual(false); + expect(isActive()).toEqual(false); }); - it("falls back to session storage for an incompatible database on public stages", async () => { + it("gives up quietly on an incompatible database on public stages", async () => { vi.spyOn(ProjectsDatabase, "open").mockRejectedValueOnce( new DOMException("nope", "VersionError") ); - const storage = await openCurrentProjectStorage(logging, true); - expect(storage).toBeInstanceOf(SessionStorageFSStorage); + 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") ); - const storage = await openCurrentProjectStorage(logging, false); - expect(storage).toBeUndefined(); + expect(await openProjectsDatabase(logging, false)).toBeUndefined(); expect(logging.errors).toEqual([]); - const { result } = renderHook(() => useStorageVersionError()); - expect((result.current as DOMException).name).toEqual("VersionError"); + expect(hasStorageVersionError()).toEqual(true); }); - it("falls back to session storage without IndexedDB", async () => { + it("gives up without IndexedDB", async () => { const original = globalThis.indexedDB; Object.defineProperty(globalThis, "indexedDB", { value: undefined, configurable: true, }); try { - const storage = await openCurrentProjectStorage(logging); - expect(storage).toBeInstanceOf(SessionStorageFSStorage); + expect(await openProjectsDatabase(logging)).toBeUndefined(); } finally { Object.defineProperty(globalThis, "indexedDB", { value: original, @@ -141,3 +92,66 @@ describe("openCurrentProjectStorage", () => { } }); }); + +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("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 index df9c620e1..c38745b34 100644 --- a/src/fs/current-project.ts +++ b/src/fs/current-project.ts @@ -5,12 +5,13 @@ * * SPDX-License-Identifier: MIT */ +import { toByteArray } from "base64-js"; import { isPublicFacingStage } from "../environment"; import { Logging } from "../logging/logging"; import { generateId } from "./fs-util"; -import { IndexedDBFSStorage } from "./indexeddb-storage"; +import { defaultInitialProject } from "./initial-project"; import { ProjectsDatabase } from "./projects-db"; -import { FSStorage, SessionStorageFSStorage } from "./storage"; +import { SessionStorageFSStorage } from "./storage"; import { reportProjectsDatabaseActive, reportStorageVersionError, @@ -27,8 +28,14 @@ export const getCurrentProjectId = ( export const setCurrentProjectId = ( session: Storage | undefined, - id: string -): void => session?.setItem(currentProjectKey, id); + id: string | undefined +): void => { + if (id === undefined) { + session?.removeItem(currentProjectKey); + } else { + session?.setItem(currentProjectKey, id); + } +}; export const sessionStorageIfPossible = (): Storage | undefined => { try { @@ -41,44 +48,56 @@ export const sessionStorageIfPossible = (): Storage | undefined => { }; /** - * Opens the storage for the tab's current project. + * Opens the projects database, or explains why not. * - * In order: 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; otherwise a new project. - * - * Without IndexedDB (unavailable, blocked, or an incompatible database) this - * falls back to session storage, which is what the editor 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. + * 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 openCurrentProjectStorage = async ( +export const openProjectsDatabase = async ( logging: Logging, publicFacing: boolean = isPublicFacingStage() -): Promise => { +): Promise => { if (typeof indexedDB === "undefined") { - return SessionStorageFSStorage.create(); + return undefined; } - let db: ProjectsDatabase; try { - db = await ProjectsDatabase.open(); + const db = await ProjectsDatabase.open(); + reportProjectsDatabaseActive(); + return db; } catch (e) { if (isVersionError(e) && !publicFacing) { reportStorageVersionError(e); - return undefined; + } else { + logging.error("Projects database unavailable, using session storage", e); } - logging.error("Projects database unavailable, using session storage", e); - return SessionStorageFSStorage.create(); + return undefined; } - const session = sessionStorageIfPossible(); - const id = await chooseProject(db, session); - reportProjectsDatabaseActive(); - return new IndexedDBFSStorage(db, id, (e) => - logging.error("Failed to save project", e) - ); }; -const chooseProject = async ( +/** + * 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), + ]) + ); + +/** + * Decides which project the editor opens and marks it most recent. + * + * In order: 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 ): Promise => { @@ -93,8 +112,17 @@ const chooseProject = async ( 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() }, {}); + await db.create( + { id, name: undefined, timestamp: Date.now() }, + defaultProjectFiles() + ); setCurrentProjectId(session, id); return id; }; diff --git a/src/fs/host.ts b/src/fs/host.ts index fad808055..52c669634 100644 --- a/src/fs/host.ts +++ b/src/fs/host.ts @@ -12,7 +12,6 @@ import { projectFilesToBase64, } from "./initial-project"; import { parseMigrationFromUrl } from "./migration"; -import { openCurrentProjectStorage } from "./current-project"; import { FSStorage, InMemoryFSStorage, SplitStrategyStorage } from "./storage"; const messages = { @@ -33,12 +32,22 @@ export interface Host { } export class DefaultHost implements Host { - constructor(private url: string = "") {} + /** + * @param url The URL at boot, for a #project: migration. + * @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 url: string = "", + private persistentStorage: Promise = Promise.resolve( + undefined + ) + ) {} createStorage(logging: Logging): FSStorage { return new SplitStrategyStorage( new InMemoryFSStorage(undefined), - openCurrentProjectStorage(logging), + this.persistentStorage, logging ); } @@ -146,12 +155,15 @@ export class IframeHost implements Host { } } -export const createHost = (logging: Logging): Host => { +export const createHost = ( + logging: Logging, + persistentStorage: Promise +): Host => { const iframeHost = getControllerHost(logging); if (iframeHost) { return new IframeHost(iframeHost, window); } - return new DefaultHost(window.location.href); + return new DefaultHost(window.location.href, persistentStorage); }; const getControllerHost = (logging: Logging): Window | undefined => { diff --git a/src/fs/indexeddb-storage.test.ts b/src/fs/indexeddb-storage.test.ts index f9e21388c..8112c6286 100644 --- a/src/fs/indexeddb-storage.test.ts +++ b/src/fs/indexeddb-storage.test.ts @@ -27,13 +27,22 @@ describe("IndexedDBFSStorage", () => { let db: ProjectsDatabase; let storage: IndexedDBFSStorage; let errors: unknown[]; + let changes: number; beforeEach(async () => { db = await openWithProject(); errors = []; - storage = new IndexedDBFSStorage(db, projectId, (e) => errors.push(e), 20); + changes = 0; + storage = new IndexedDBFSStorage( + db, + projectId, + (e) => errors.push(e), + () => changes++, + 20 + ); }); afterEach(async () => { await storage.dispose(); + db.close(); }); commonStorageTests(() => storage); @@ -64,6 +73,13 @@ describe("IndexedDBFSStorage", () => { 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"); diff --git a/src/fs/indexeddb-storage.ts b/src/fs/indexeddb-storage.ts index 47d6b09a8..374a56cfe 100644 --- a/src/fs/indexeddb-storage.ts +++ b/src/fs/indexeddb-storage.ts @@ -26,6 +26,9 @@ const defaultFlushDelayMs = 300; 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 = () => { @@ -34,10 +37,15 @@ export class IndexedDBFSStorage implements FSStorage { } }; + /** + * @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") { @@ -46,14 +54,16 @@ export class IndexedDBFSStorage implements FSStorage { } } - /** Flushes, stops listening and closes the database. */ + /** + * 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(); - this.db.close(); } async ls(): Promise { @@ -112,6 +122,7 @@ export class IndexedDBFSStorage implements FSStorage { } private schedule(): void { + this.pendingSince ??= Date.now(); if (this.timer === undefined) { this.timer = setTimeout(() => void this.flush(), this.flushDelayMs); } @@ -142,14 +153,16 @@ export class IndexedDBFSStorage implements FSStorage { } } const changes: ProjectChanges = { - meta: { ...this.pendingMeta, timestamp: Date.now() }, + 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); } diff --git a/src/fs/projects-db.ts b/src/fs/projects-db.ts index 9a0f033e1..98ed3223e 100644 --- a/src/fs/projects-db.ts +++ b/src/fs/projects-db.ts @@ -19,6 +19,11 @@ export interface ProjectMeta { 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. @@ -116,6 +121,29 @@ export class ProjectsDatabase { 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); } diff --git a/src/fs/storage-status.ts b/src/fs/storage-status.ts index 804a5bbf9..37b301efd 100644 --- a/src/fs/storage-status.ts +++ b/src/fs/storage-status.ts @@ -37,6 +37,8 @@ const subscribe = (listener: () => void) => { return () => listeners.delete(listener); }; +export const hasStorageVersionError = (): boolean => versionError !== undefined; + export const useStorageVersionError = (): unknown => useSyncExternalStore(subscribe, () => versionError); diff --git a/src/project/projects-hooks.tsx b/src/project/projects-hooks.tsx new file mode 100644 index 000000000..56f9de61a --- /dev/null +++ b/src/project/projects-hooks.tsx @@ -0,0 +1,45 @@ +/** + * (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. Undefined in iframe controller mode, where + * the embedding page owns the project. + */ +export const useProjects = (): Projects => { + const projects = useContext(ProjectsContext); + if (!projects) { + throw new Error("Missing ProjectsProvider"); + } + return projects; +}; + +/** + * 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..4792eecce --- /dev/null +++ b/src/project/projects.test.ts @@ -0,0 +1,169 @@ +/** + * @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 } 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("", 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("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..747b815dc --- /dev/null +++ b/src/project/projects.ts @@ -0,0 +1,279 @@ +/** + * 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 { 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"); + } +} + +interface EventMap { + change: ProjectsChangedEvent; +} + +/** + * 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 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. + */ + constructor( + private fs: FileSystem, + private logging: Logging, + db: Promise, + private firstStorage: Deferred + ) { + 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: the one already open, else 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. + */ + 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; + } + if (this.openId && (await this.db.get(this.openId))) { + return; + } + await this.switchTo(await chooseProject(this.db, this.session)); + } + + 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 { + const db = await this.requireDb(); + const id = generateId(); + await db.create({ id, name, timestamp: Date.now() }, defaultProjectFiles()); + 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.logging.error("Failed to save project", 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); + } + } + + 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/router.tsx b/src/router.tsx index fa1e7d592..6719ca5f0 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -6,6 +6,7 @@ import { NotFoundPage } from "@microbit/ui-patterns"; import { createBrowserRouter, Navigate, useParams } from "react-router"; import { baseUrl } from "./base"; +import { Projects } from "./project/projects"; import RootLayout from "./RootLayout"; import { isTabName } from "./router-hooks"; import { @@ -45,18 +46,21 @@ const RootRedirect = () => ( const NotFound = () => ; export interface RouterOptions { - /** Controller mode: the editor is the only page and stays at the root. */ - iframe: boolean; + /** + * 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 = ({ iframe }: RouterOptions) => +export const createRouter = ({ projects }: RouterOptions) => createBrowserRouter( [ { id: "root", path: "", element: , - children: iframe + children: !projects ? [ { path: iframeEditorRoutePath, element: }, // Deeper paths are the editor with no tab selected, as before. @@ -64,7 +68,16 @@ export const createRouter = ({ iframe }: RouterOptions) => ] : [ { index: true, element: }, - { path: editorRoutePath, element: }, + { + path: editorRoutePath, + element: , + // The editor needs a project; choosing one is the only + // asynchronous step, the MicroPython load carries on behind. + loader: async () => { + await projects.openCurrent(); + return null; + }, + }, { path: legacyEditorRoutePath, element: , From 881d1d5bb56e18bed9821e24a5563b0c97b6aa5c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 10:15:03 +0000 Subject: [PATCH 04/34] Add the home and projects pages Composed from @microbit/ui-patterns' project components and @microbit/ui-carousel, with app-local layout, banner, resource cards and what each action does. The home page shows the banner, the user's projects with a new-project card and a link to see them all, and rows of project ideas, teacher resources and help. The projects page has search, sort, multi-select and a toolbar that slides in from the bottom on narrow screens. Both take their list from the route loader, so there is no empty flash, and stay current through Projects' change events. Without the projects database the pages redirect to the editor, which is then the whole app. A #project: link at the root goes to the editor with its hash. Opening a project resets the editor's file to main.py. Theme images resolve per file, as in ml-trainer, so the branded package only ships what differs; the OSS defaults are neutral placeholders. Project management events follow ml-trainer's names and are documented. The carousel's catalog joins the compiled messages. --- docs/analytics-events.md | 58 ++++- i18n.config.mjs | 6 +- lang/ui.en.json | 88 +++++++ .../default/images/accessibility.svg | 1 + .../default/images/animated-animals.gif | Bin 0 -> 1764 bytes .../default/images/banner-background.svg | 1 + .../default/images/beating-heart.gif | Bin 0 -> 2017 bytes .../default/images/emotion-badge.png | Bin 0 -> 2961 bytes .../default/images/first-lessons-python.svg | 1 + .../default/images/flashing-emotions.gif | Bin 0 -> 1753 bytes src/deployment/default/images/get-silly.png | Bin 0 -> 3007 bytes src/deployment/default/images/heart.png | Bin 0 -> 3014 bytes .../default/images/troubleshooting.svg | 1 + src/deployment/default/images/user-guide.svg | 1 + src/external-links.ts | 12 + src/pages/ActionCard.tsx | 46 ++++ src/pages/DefaultPageLayout.tsx | 116 +++++++++ src/pages/HomePage.tsx | 159 ++++++++++++ src/pages/HomepageBanner.tsx | 81 ++++++ src/pages/ProjectsPage.tsx | 242 ++++++++++++++++++ src/pages/ResourceCard.tsx | 70 +++++ src/pages/project-page-actions.tsx | 102 ++++++++ src/pages/resource-cards.tsx | 115 +++++++++ src/router.tsx | 57 ++++- src/workbench/Workbench.tsx | 16 +- vite.config.ts | 48 +++- 26 files changed, 1196 insertions(+), 25 deletions(-) create mode 100644 src/deployment/default/images/accessibility.svg create mode 100644 src/deployment/default/images/animated-animals.gif create mode 100644 src/deployment/default/images/banner-background.svg create mode 100644 src/deployment/default/images/beating-heart.gif create mode 100644 src/deployment/default/images/emotion-badge.png create mode 100644 src/deployment/default/images/first-lessons-python.svg create mode 100644 src/deployment/default/images/flashing-emotions.gif create mode 100644 src/deployment/default/images/get-silly.png create mode 100644 src/deployment/default/images/heart.png create mode 100644 src/deployment/default/images/troubleshooting.svg create mode 100644 src/deployment/default/images/user-guide.svg create mode 100644 src/pages/ActionCard.tsx create mode 100644 src/pages/DefaultPageLayout.tsx create mode 100644 src/pages/HomePage.tsx create mode 100644 src/pages/HomepageBanner.tsx create mode 100644 src/pages/ProjectsPage.tsx create mode 100644 src/pages/ResourceCard.tsx create mode 100644 src/pages/project-page-actions.tsx create mode 100644 src/pages/resource-cards.tsx diff --git a/docs/analytics-events.md b/docs/analytics-events.md index 0e0eae1f1..8e69a11ed 100644 --- a/docs/analytics-events.md +++ b/docs/analytics-events.md @@ -123,7 +123,63 @@ when the menu action is chosen, before the confirm dialog. No params. ### `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` 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.en.json b/lang/ui.en.json index 0b1b8a0cb..e96e03e17 100644 --- a/lang/ui.en.json +++ b/lang/ui.en.json @@ -27,6 +27,10 @@ "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" + }, "added-change": { "defaultMessage": "Added file {changeName}", "description": "Change made to file" @@ -227,6 +231,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" @@ -327,6 +335,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 +371,10 @@ "defaultMessage": "Help", "description": "Help menu label" }, + "help-resources-row-title": { + "defaultMessage": "Help and support", + "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 +395,18 @@ "defaultMessage": "Simple", "description": "Highlight code structure option" }, + "home-action": { + "defaultMessage": "Home", + "description": "Button returning to the home page from the editor and the projects page" + }, + "homepage-banner-heading": { + "defaultMessage": "Code your BBC micro:bit with Python", + "description": "Heading of the banner at the top of the home page" + }, + "homepage-banner-subtitle": { + "defaultMessage": "Create, edit and flash MicroPython programs 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." @@ -394,6 +422,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)" @@ -470,6 +502,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": "Your 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 +522,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" @@ -586,6 +630,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" @@ -602,6 +674,10 @@ "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", "description": "Python powered logo alt text" @@ -1070,6 +1146,10 @@ "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 +1190,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 +1226,10 @@ "defaultMessage": "User guide", "description": "Menu item for link to user guide site" }, + "view-all-projects": { + "defaultMessage": "View all projects", + "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/src/deployment/default/images/accessibility.svg b/src/deployment/default/images/accessibility.svg new file mode 100644 index 000000000..4b7127f0d --- /dev/null +++ b/src/deployment/default/images/accessibility.svg @@ -0,0 +1 @@ + diff --git a/src/deployment/default/images/animated-animals.gif b/src/deployment/default/images/animated-animals.gif new file mode 100644 index 0000000000000000000000000000000000000000..023df14c9cd3f389628f4edc608ddb74ac72ad45 GIT binary patch literal 1764 zcmVd#vY*wdjqv=#RMRm%Ztmzv`R7>Y>Bx zrN!%}#p|xh>#oY|wa@If&+NF+?7PzKywdHz)a}64?a0~g$=dGB-0scX?%3n+*yHfo zhb03@#yUF>h1CC?(yvI@$m8T@$vHV^6~%x000000000000000 z00000A^8LV00000A^!_WZDD6+O<`wgV`~m)VQp<;JuogbH8eFeH2^FC0001x0W1Lk z2LHgEa7Zi~kI1BQ$!t2G(5Q4uty-_xtai)odcWYXcuX#v&*-#z&2GEj@VIs;jK6 zuCK7Mva__cwzs&sy1Tr+zQ4f1!o$SH#>dFX%FE2n&d<=%($mz{*4NnC+S}aS-rwNi z;^XAy=I7|?>g(+7?(gvN^7Hid_V@Vt`uqI-{{H|23LHqVpuvL(6DnNDu%W|;5F<*Q zNU@^Dix@L%+{m$`$B!UGiX2I@q{)*gQ>t9a@*@owC_rqmFyR4$0s{aHD1hL>1pf;g zK%j6zqopw$D@0sS0HCSUr%AhxYr zsSQ57K+Ebyhn#Qo>UBDx!(6(gXox_tx3FOV2t=F(+{wj-!;lYOaKTuU3J8%iABLc! zawQamHH$Wk0EK5rDwvL5ZCF9)(~4Y3U`_k51j?`r(ZEo<_hAf+bN?~nyZB)f!u`aU zV7z(Z2{DrEDdD_&;S$pA(5PU&yWR@Hv%6v8y}Vu);@^-k&;Bim^lUP;pWikEd^9$g z)87wv0@nLu#1I;P1V)8GbO3_!LV*l^^;d#ixX|E)QQ-z*7UWT=p;PExc>jfj8-@sg zg&s<=pok(qSmF~gpy(okD>h-nfG%!`z<)6=poOeydv2FYVDLVn*A{{VXCc*n`RZ{w=NP1m{;VYc>e%kHl$l)l`S>t zZirAC73;hq(wfz&_-?2wT8Z{cA){ysEFxOj8ayGlCyAQuZR<^dxUal<_ATxgjWL50V?ICLv&;02O^n5&e6;g%S@N|vQZ~T?VQRiiEMRuB^%Z8*4BY+6vkjzx3N(Zm))GjEIF)p zZxN%E@Y}U9d=lNp(G@V>s}Z~}-?jal)xCg^rms=*7OvR}bhWGaXz;d#?&FcI3z&SA zM`j;j-ezuCxJJ$Gd0uv_#MbD0HQ<>CsP83hWy-eRRy95wcPx7d?Bm8$z^UUGWTU7E1W}JxNR6Fb z1IRLfMogtCBpNQ`B+e*u4UVbNWFF}T!4Qu=iIE^B$zT`T^~^7uyu%ATaJuq1KrW%= z!&bV|oC9dUEU6R(Qj~(e)>MTBWsD^uUJ{d;)Z`{P*-1}+5|p86QkTd~rZShw%w{_C znb3@;G^a_;YFhJ}*vzIjx5>?Jdh?s$45v89NzQVb^PK2Rr#jck&UU)e$s6rRY(1tqnp%9IzL?=qoidyud7!^ej G0028M2b#M8 literal 0 HcmV?d00001 diff --git a/src/deployment/default/images/banner-background.svg b/src/deployment/default/images/banner-background.svg new file mode 100644 index 000000000..fb814f35d --- /dev/null +++ b/src/deployment/default/images/banner-background.svg @@ -0,0 +1 @@ + diff --git a/src/deployment/default/images/beating-heart.gif b/src/deployment/default/images/beating-heart.gif new file mode 100644 index 0000000000000000000000000000000000000000..4c77559879a91f5bdb7e7eb3becc60bbc574a676 GIT binary patch literal 2017 zcmdVX`#%#39|!QctvF#1M~_OH<~ElaBI&7!<1&;->OpeMy)wt;a1NzhXBdVe3^Q}t z=rnf;ncJ9_u^E=jT*q>2Mn|eWdj5;&^L_vL`QiKee(fA=%`YLa5(W}`002|+6jMr# zDWlE=s*eKI#}u^36t!6=VXTuzY&8@1X%qGtQ#SaJ*|_c%4$Nlakg3CjnZx827p|QL z*WP319&*MTIqMrf>mNBA5XlRR<3%SeJV{@8nz4|AT};6)VT+e?idTw>E4a#)vKl_I zj!%5KN~&M2ZCGpR5VUp*TDt@tR6%EtptJX5XYa;4<_6=?=*Ig)V;fA?29v!xKDo)6 z+M4BU&CPG|7Pc3cx0jZ;SNPj2tDn{c!mVxL_9x+|L&C!V{(qveFa5t|HN-zTAQ3?U z&WND5Tg(b@I3o7&Uc*FQiT9HI}u zW4s?>j*hX|wR2?YkHt zC8uUj!BdltD(iU-Qp$T%K@d33zMjyZp=$DAda%BNmJPN}S953}4&~|IB6|%rR1O!w z{OCA`#wtd!d8A-^sIhva%rZ&tG`y*1w8B15?;gF0#Hx0wfS16V$>X)|jSpt%&9xJ+ ze0tMQJGRtKH3qZD_l7yOSamb;9n5NcLQj&k3=p8|0;WpIsajy%5f!P}7MY|z-5i8O zJPiwRELDrKLMa&~f@eQV0aK-(S!bxr^8iOwubGYXYV=5~O;DhhK?&BTm({?cfmy~W zApq2j%#(6GYqhmFD50sFO`*N&C+>PBoe0SG*{{+iTOBwN0om=Jtk3wS56If z>53Bq=UDo0jHsR8mXzy;&+qy5eiIKNnbS|Yi2z>0P^BgQyoFmlFNt;=-<#O3dUQUe z6Kanzv~rK1Pwnu)Poe7jKmfGvz;VlCnq3js(_sVA*E8zEK#Q5L!Yv_sR_5r%Y{G5W zLKa~grjE70kq;0n+Mp7xb6rr!z4CC=pylUzYjy$JMm}o+{sy5jY0rPnmyOT&g$s#= zfj0_QAQluTKlwKcrM{eqCxzYED81`er4g=7{#i3gz3Qwabs?cV=KTv@jeEv&!7s9b z8qkWzdu#KR@t)f?DL6?oq9hze*Dd+&`+ahGVwbFdOsLwIsw+^KBtC62Zl~s(Eya@L z-8g*x3grv3y_A9hJIJF!V{Uhr{)I=-Si=P^9s}GJ(Sx4r~#=B)uvRwK&rA7Ly1r3zgirhiHT9)?yVs<6<-y$Q=q%2jeOYh6|y0a|S}RE77A58w)>^ zt2W>N+ERP6(ely$df@e+kt+!FF8=qzDNj{+U{`#ZkQ7=chwZ z)X9JQQVO$!As*B~#io)asp4h*iw#N12zH zPEIkn8lefvKiKb#!%X(C?^Jz?QlgFA?Vm6W5kCag0{{L_@R*JQ0xic>F$ft=XK{$~ zYiZX|Z`ot=qOr!VqgR1P*BX5M^IOkwJ(v%K5(P4}W{n*!Slq#V-oAPpenQ11`c#CI zM7AwNc|bAcXQlja>EoJ;rHaXhJt_y$&6RS#BXJjERrYMPv~MG}kVK_3WUuBvJ7oHw zrDQjuB);>agJSf!Bb(sEud(~b*~d6Y*@+|beZyAanJI0`{5@%!^>%!u6M0cXL~E51 zj2>NzkiIsx=3r}8~ zLK`*IVTj4e8ak*!H#36uY2_S!!*ZKAqwxG^ObH#vFA`1*4NA=&9K)wEY2T9_E&D{$ zaYd#HAGBD?Fsw!$Z^y|MO>^UuR-g?K=vMG873R<_Aa0-G_7!jL?Wv$NTqrmF^=4Q% zqT0}1>Kce)n=8cp5_EM3%))DzQC*Jtp>#@sUfL!AXB6m3Sdt1h{+vpiqqk)DKi|56 zVVdq#`D1y^tXNS6Se|5W2OrBV5)pNsN$~&B$MV}nm9R{bQxKe0#1&PUGe|f7ptDN9 zh^j5+$u6mIHsR!MjlDD3wSdm9ve+fLWRl&g;p1e_U9vlvLB7*UAO9pA-ciwZp&vTylJ@4;% zKF@O};EdN^EfXyO0QUNLdjtW1S}yq6eg*-RuRI$#;ARoy9pnc9_(K4YeiH!Xpp-5E zfa~@EAVdRzOECZ#T`ubgbOjB&qkX+RR9j0kY;_z|pouPypO2FX#iEy*q5j0_#7|)fA9ia}oSK|Ir{2(Y zOkfEf?6N7~mBv}uP`zyvD)ZwIf2uLDJe|k!&NT9X{&h8;`_ z7*nPT8yqBoSa+#yY$1sb0W2ON@BrXW(+>lHvk+~-;#;&Da8uJ3NVm_>06sg^2msI3 z4FUJ!ZV0f);FHsTKSASJ3bZ?wMssd-sO%CB520GQeXa0zWv}o~J@nZq%eU2kw3@vp|X@Wd42eAUb%p+d*Ogp(A% zJVJl;H0HO0%A&$Zb%l0@MpP|keCd8$Z)@bbq6|f@54lKGvW+Q5Jx5>Rvc~PNgv@1M z32q>>#$EqCpmgxn%gk3dJc%TJA1iE_go*rmL&_wfqYT1y0%aTJq;p(t`qLx(Fw;F4ve#kqYGJ z&PEZ$=PA&hF3ZOKSA}jELIR(Gbl`~POW~BZ?tmfO_smXT3Fe4N=hf#vpwcx}y!&>p z95z6H%xSo!fT5o zASAO;^O2ILqW7HgK{NbPI}?>&TU#qhGpEyv3nm~0Quo7`Z0*Ulp0`G|XZs({%61&i z|ApU%_4Uu_^h6J}2=6D^@}$wkk*XIGR1jYK1A>1euw!oU=LrZ7LaJcn<39?=I0cbv z;y;SK6yFT$he;pEN6YIb%Ol{jrTX)WHk}&0N(73WcKy`q%y*P(_x$dMPDPp!-M0d zN;YL<)q1w6QK6Zd@h5hC!sq|mK}ZwLlJ0$$<}f&9f5Fi#_$2nyyngPV8wGYGMPUv< z9ItMFc$%3goV>X7b12W-Q0J3)g>ODbXJ zSl`V8a16~xI!fAiDx;L3JDT*4x|!*563OuuD*ZGqT_?j6V@!3!)W?|#Cy$A;nZfC_ zy6_RVjKMGxW5B3fwk!5^Oi@G-UgJwmTYNwZb@4@0T0MA& z6yaSEA8?n&+yjRO5+wWo_Fh2QF80{#XA~3p2L`M=A-sPM7|&Gw!>vZD^)G`k>EqeL zJcjF~Yc0(xD+jkDof>Pk=Wwd5mRZN6_nx(=UNcb^{^+5ZQZo72k`Whd30C#DC~W`& zfnZDUk;Kyus^6;TDiC-ru^k9I?uGbz6J^T)ZJ?rwiTFxvT_@~U< z+5egd!<3Rv(aJ6dZBmy7P7wS{MHOlaBs^_us^0*K{X0SM`^*kSNe||9Sma*~lg!^> z7Y`PhsVj1hgZ+V@^?7;afSc$Rj^9=SHj{}VBB5&7%* zUiHJOc@$n>e6M5jqd2up*atfdK9#2J+M>KPqI_6X2c1Y=ER|<0EOQu$`q7T0g(ouR zg4c!Q$EEb;jeb$BL6W~&auwL4k=UubdG|uMa&qLg@e}zwy;|+wlq(7R*Iy9V+fh5o vB$;FT>fQR7w>cp#TVX# diff --git a/src/deployment/default/images/flashing-emotions.gif b/src/deployment/default/images/flashing-emotions.gif new file mode 100644 index 0000000000000000000000000000000000000000..94f60c49a2b177ea051e57fb6bd30e36893df9d6 GIT binary patch literal 1753 zcmV;~1}6DONk%w1VUPhV0rda?y%6BN5#YWP;Jy^#z#HMf9O1$y;=(85!ztp!E8@c} z;>0ZC#WUl@G~>rRDr6x+?MOzmh0c3?BAj6;H2!~sqEsd?c%QO;;-%Hx$Wh(cM+*YNDv@b2I6?&0z8s;jK6 zuCK7Mva__cwzs&sy1Tr+zQ4f1!o$SH#>dFX%FE2n&d<=%($mz{*4NnC+S}aS-rwNi z;^XAy=I7|?>g(+7?(gvN^7Hid_V@Vt`uqI-{{H|23LHqVpuvL(6DnNDu%W|;5F<*Q zNU@^Dix@L%+{m$`$B!UGiX2I@q{)*gQ>t9a@}rFxDnxX!Fu?%=0swLnK;U44h5rsB zRJ^d!(in~wBrqVz$+W4{r%n-IAkhLx(OouHY%l<|tJkjp3~X3I^s1~FAsT>P%NA;b z5HZZMit%AU+q-z3euyiVlnoL~^9mk}fJE88onBxV%(yWC3@;FCVgbRiWyTO#NUo$J z@a4~f5vbSt?LsuZefCzRaLxEnrkl=$))y7~J4nk-lQxZ;CMTHl3nE#=MQ5@Le zhyW}IViYuxs3L(UI)MX#Dq4sDe=RC;UyLJ8P~#Hs;i%$#J05|ak1?_rBoW~eiDP+3 z?tp`mMABGec2MG2!I9BbiDPvw&Y-1|&b4^smoe&iVwPlX38D>YLg`z7xwT281`~29 zr;i_+7r~uMMo=INd_u`ud#43Teh?2Gz!>mg3wb=)_68S-Ey(&rCt-<=JYhNK0 z>thI;sika;4M>LSvoWsP(hAh#xB_B5W=kTFa&hG?i3arb;*5c77PgQ#wryW( zSB_WPD>0zCV9`pJ?B{qfD;cq*w{>h^skb#iYrM7&*000L4*%Bbh^=gU-K^T)mEWXg z?mGaS*E@VudfQgE>}VsF>F}IdN9pjF8mD{Zy#EHa>|KZmB_Tx!Vl+)r!h<>Fy#(f!_*zZOwxZdW=|;ogS069F!4gFDiY z!e*o<4dHQ3lbi*Gus@vjZ-Y*{p2%hdLL;OIX)G#X5k#~z6RFS$>zi2nN*F_e%}|9k zyw?q1$ij)a@P$nHkHZjJn&f&tYQ?fL#^&; z5eox!RROb$3m<3FwDyU?{OJXvUnjpYu04M-Yd;%1qP%@Ul45l!LNz7sz^O(p?rZShw%w{_C znb3@;G^a_;YFhJ}*vzIjx5>?Jdh?s$45v89NzQVb^PK2Rr#jck&UU)e$s6rRY(1tqnp%Ar45C8x>r~;CH literal 0 HcmV?d00001 diff --git a/src/deployment/default/images/get-silly.png b/src/deployment/default/images/get-silly.png new file mode 100644 index 0000000000000000000000000000000000000000..94341ac446cd58361e0551062833dac119344e7c GIT binary patch literal 3007 zcmeH}YgAKL7RN75~#CoG*yhYT;J551_bEQyjHD?b2hh-5#qt)gbt+1h4K2Zv5}xWFnRd?TM< z`Rn*C|AiYH(Q!p5E8>lfWj^2d{myKN-Ff@+cKVeGY2C-x`!)Z{vW3kY_O2n;WYe`5 z8I5HMH?BfMZk3Vfxb|oOFiJ*v0YDwg$R5bwR{#Yzf7$^6zZhBoK9z$Iz{nf}0AE8) zfgOhl2Eez*A13|xC&1+J4LWGT0m>U6&bFBe`2?+Y?qGvcitLIQRh$?#*Uz7Ui({7K z7($O*ZTN9z7jeg)>KIa&V<%@3jAx6LQ3MGn35PJsS&~?WVwuZt41Y<3&P>LKIyJ0YwKmy z@FlK8?b#(Vmf?0x_q|9_$_XQFS=?)Av2uLG#-rzcPP*zltgefYrf8B#aH=#u>U9b; z;}={}bKZf@gg?5M+pV0lr|Qkcd+0_%A7RXr>2TS3*V7tBxYwq=54AOU!U|8fHtDV; z=rEOs$@%%&Hnc}6O;9d>$y#@+xg2MBDVX5oFFP!zpLmOe$9BK$<`jzuXu6mE1EARU zqxxLNSs4!~zr#%+bOl*D-B`n!nGnMUS2Hv)0d-eLQVcdqqC7t5Ok~_WlAD{&cNk(&QUQ5E^ANGgkq%Yv z9>tnhRfK~n0`i73EB3z@tdeeVbQ3=s<^j0q*3)LQ%0fr#4(Kzyc1OY_ka^ zL5~~R#|9~+Lv+Ldf)24-Gb(OXc=}`X_FnRtQ~a%iKHsS2g*g+R;4a=RVKC%6>z<2)!El%-QQ$!VC1o?RrEHjmT%qI z3DIOpdq&%|P>@^*R{F{fs<}MYQ`Gp@u7`spqrnok1&OjB4pa52($Vbz`5(_pewZ8^ z00aDHj_JA|iu?=A`K24xJ(_vW(rFVgI^Dd~I%A4*tDoI7c@ zn86wM?j!jG6UKi6S?tRd$Zrqp$nq(t4W9C0ki|UrRThhK>+|N7X?hs$?y&M5s)?TA z@>HXxN^nk6GMVo7A6ENy>sywLxu4J>tDdKwd3(9gh4^>THKB&V$It0-eAM$(RSa(z zT^&(-HIB5!%%`jF=wPf3lp0{L@{g) zFR_Sxu!T4;x6}7?_Rdt8BL1Ilc~ke7_75#~awE&e79UYFsewRLh(FbraPi;H9E|?} literal 0 HcmV?d00001 diff --git a/src/deployment/default/images/heart.png b/src/deployment/default/images/heart.png new file mode 100644 index 0000000000000000000000000000000000000000..60c6a5c75d6080e97e8f314960e3ad7110ef1e0a GIT binary patch literal 3014 zcmeH}Yc$*07Qp|~#-m2nRcfMM9i4{?Mm?&e>Xx=)NC{%RLR+*!y z+QDSJlemZyK`5$fGEF?{k!Y7{1Q8>NbS21*PWx>>+;u;9uXWa0XP>jrIqRIge|z7& z;B`)8A7mc@02=V~u08;ubX(EP4}cUJYd7MgqBFj9-p3OFuwMf}PCfug6{?&i07$U{ zfaNd%I9Ub&hvFZ0c{?d4lrMUmbCrJ(wE)SSLZgxl_jFTPR{KKDVb5=O>J}oITIB5#Ko{_w9 zs&`a^Lf8A6-FG(t022*@3IL2la>2lXugL(=zgHJHUDgKzRJ8*DARMF)82=Ea1mvrk z136Zo7yZK%M4Q2Yv$X3$J20DQAs?rS>+X_|L~teMJUNE4; zRWZzl+sFpL%L1MLvbV2Gpw&^38jHZB|%^om16wSj+7S+4Bzl83cEipip*``TH zG7l#W7ud&K&0g;tuH^(`weFdc^;kF1yvNS*%tM~`YuPf=q##wm)|~goYK`apRS^6t zb?t_lB|1%8{Q`mEO!$&L;gCyny>s=> zs`Sf})U|6_kqNBwTaL56-_cYl2DyV}$K(4OdoveuCM1>d^Ktqfak(wkvF}=CPBW|x z-b;bkY?Vm&mF{6ypPuzP3$;J62OdbLbVwH;;}iX(!~#zqlnhlhAIcxo&a~vswn(+n zSoNZiiC+5&eG#FfZ}KR)E@1$+UA%v;4RdL#RGVQ=1J^Y+Hp(`qhfz|l=0jhsdJGg) z2$2lFn3pHK!3~f!ovpjuM7qBc6(S9Ui}`G3P*}89z<3i~$Z?Dv)LEI|5iS>`LTTXl z#AW)o6GEc`ugp=y;XLTNJP}-2Zk;}HZmco#mr$jmr|)KEhSKyDp3ZF( za@}`(B<7h-QSLmQzVjmI&ebi$DPo-#sIZ(Vr{A6JinE}B^lt8uN)0)%7SXKmw6B`Sh&yAwk`o^hu3T8wN) zNrM~$kv1i6-B?px|IBBQd}itYnohitkWGkWix@&c0bjjbfBHvbT43G)VL0 z&_w&fOw0eq=LhfdMh2!n*z7k^_aFght0S0gHT&7VA5B&)j&qg*k-LuCEMs(auQYrz zK|ILDbDNmQa_(H|43qLIjqZmkeYqPA$v?_EWfaAz$W=IoRQ?8PMgv*z)oqV(S>R}R z3>n4KXOc=wAAz<47MvgBhaNhlpzI7;>xux}jWU;oZ~mY~Y|<6icb}_$7W_bo;ibm)F=A(wSBgZPU6O&fPD`qkk>@ZcZVyK&*VW*hbUxQOB^-DC|lo1L0OieKEe)#PhO!x5s1L9)joWehEh%cZ%ZNNh literal 0 HcmV?d00001 diff --git a/src/deployment/default/images/troubleshooting.svg b/src/deployment/default/images/troubleshooting.svg new file mode 100644 index 000000000..0dce255e2 --- /dev/null +++ b/src/deployment/default/images/troubleshooting.svg @@ -0,0 +1 @@ + diff --git a/src/deployment/default/images/user-guide.svg b/src/deployment/default/images/user-guide.svg new file mode 100644 index 000000000..e96080303 --- /dev/null +++ b/src/deployment/default/images/user-guide.svg @@ -0,0 +1 @@ + diff --git a/src/external-links.ts b/src/external-links.ts index f5e7dcfe2..5cddfc899 100644 --- a/src/external-links.ts +++ b/src/external-links.ts @@ -28,3 +28,15 @@ export const microbitOrgMiciProjectsUrl = (languageId: string) => `https://microbit.org/${langPath( languageId )}projects/make-it-code-it/?filters=python`; + +export const microbitOrgCodeUrl = (languageId: string) => + `https://microbit.org/${langPath(languageId)}code/`; + +/** 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/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/DefaultPageLayout.tsx b/src/pages/DefaultPageLayout.tsx new file mode 100644 index 000000000..1ba99a40e --- /dev/null +++ b/src/pages/DefaultPageLayout.tsx @@ -0,0 +1,116 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + Box, + Button, + css, + darkSurface, + HStack, + Icon, + VStack, +} from "@microbit/ui"; +import { ReactNode } from "react"; +import { RiArrowLeftLine } from "react-icons/ri"; +import { FormattedMessage } from "react-intl"; +import { Link as RouterLink, useNavigate } from "react-router"; +import { styled } from "styled-system/jsx"; +import { useDeployment } from "../deployment"; +import SettingsMenu from "../settings/SettingsMenu"; +import { createHomePageUrl } from "../urls"; +import HelpMenu from "../workbench/HelpMenu"; + +interface DefaultPageLayoutProps { + children: ReactNode; + /** A back-to-home button in place of the branding. */ + backToHome?: boolean; +} + +const Branding = () => { + const { squareLogo, horizontalLogo } = useDeployment(); + return ( + + {squareLogo ? ( + + {squareLogo} + + ) : null} + {horizontalLogo ? ( + + {horizontalLogo} + + ) : null} + + ); +}; + +const BackToHomeButton = () => { + const navigate = useNavigate(); + return ( + + ); +}; + +/** + * Full-height page with the brand header and a scrolling body, for the + * pages outside the editor. + */ +const DefaultPageLayout = ({ + children, + backToHome = false, +}: DefaultPageLayoutProps) => ( + + + {backToHome ? : } + + + + + + + {children} + + +); + +export default DefaultPageLayout; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 000000000..83e8238c3 --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,159 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Box, css } from "@microbit/ui"; +import { CarouselRow } from "@microbit/ui-carousel"; +import { NameProjectDialog, ProjectCard } from "@microbit/ui-patterns"; +import { useCallback, useState } from "react"; +import { RiAddLine, RiFolderOpenLine } from "react-icons/ri"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Link as RouterLink, useNavigate } from "react-router"; +import PythonLogo from "../common/PythonLogo"; +import { useDeployment } from "../deployment"; +import { useSettings } from "../settings/settings"; +import { createProjectsPageUrl } from "../urls"; +import ActionCard from "./ActionCard"; +import DefaultPageLayout from "./DefaultPageLayout"; +import HomepageBanner from "./HomepageBanner"; +import { 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(); + return ( + + + + } + navigation + /> + } + navigation + /> + } + navigation + /> + + ); +}; + +const ProjectsRow = () => { + const projects = usePageProjects(); + const { actions, open, create } = useProjectPageActions("home", projects); + const cards = [ + , + ...projects.slice(0, numCardsDisplayed).map((project) => ( + + + + )), + ...(projects.length > numCardsDisplayed + ? [] + : []), + ]; + return ( + <> + {actions.dialogs} + } + actions={} + navigation + /> + + ); +}; + +/** The picture on a project card. */ +export const ProjectGlyph = () => ( + + + +); + +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}> + + + + ); +}; + +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..2375f39e3 --- /dev/null +++ b/src/pages/HomepageBanner.tsx @@ -0,0 +1,81 @@ +/** + * (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 { microbitOrgCodeUrl } from "../external-links"; +import { useSettings } from "../settings/settings"; + +// Landscape phones. +const shortHeight = "@media (max-height: 700px)"; + +const HomepageBanner = () => { + const [{ languageId }] = useSettings(); + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default HomepageBanner; diff --git a/src/pages/ProjectsPage.tsx b/src/pages/ProjectsPage.tsx new file mode 100644 index 000000000..fdee06dca --- /dev/null +++ b/src/pages/ProjectsPage.tsx @@ -0,0 +1,242 @@ +/** + * (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, + ProjectSortField, + ProjectsToolbar, + rankProjects, + SearchInput, + SortDirection, + SortInput, + sortProjects, + useProjectSelection, +} from "@microbit/ui-patterns"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FormattedMessage } from "react-intl"; +import { useLogging } from "../logging/logging-hooks"; +import DefaultPageLayout from "./DefaultPageLayout"; +import { ProjectGlyph } from "./HomePage"; +import { + PageProject, + usePageProjects, + useProjectPageActions, +} from "./project-page-actions"; + +const fileNames = (project: PageProject) => project.fileNames; + +const ProjectsPage = () => { + const projects = usePageProjects(); + const logging = useLogging(); + 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 [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), + [direction, field, projects, query] + ); + + const desktopToolbarRef = useRef(null); + const mobileToolbarRef = useRef(null); + const handleSkipToToolbar = useCallback(() => { + const toolbar = desktopToolbarRef.current?.offsetParent + ? desktopToolbarRef.current + : mobileToolbarRef.current; + toolbar?.querySelector("button")?.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..3fd8a7d52 --- /dev/null +++ b/src/pages/ResourceCard.tsx @@ -0,0 +1,70 @@ +/** + * (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..e16c40563 --- /dev/null +++ b/src/pages/project-page-actions.tsx @@ -0,0 +1,102 @@ +/** + * 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 { useProjectList, useProjects } from "../project/projects-hooks"; +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[], + getSelectedIds?: () => string[] +) => { + const store = useProjects(); + const logging = useLogging(); + const navigate = useNavigate(); + const actionFeedback = useActionFeedback(); + + const attempt = useCallback( + async (action: () => Promise) => { + try { + await action(); + } catch (e) { + actionFeedback.unexpectedError(e); + } + }, + [actionFeedback] + ); + + const actions = useProjectActions({ + projects, + getSelectedIds, + 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 }; +}; diff --git a/src/pages/resource-cards.tsx b/src/pages/resource-cards.tsx new file mode 100644 index 000000000..8eeedf68f --- /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.gif"; +import beatingHeart from "theme-package/images/beating-heart.gif"; +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.gif"; +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/router.tsx b/src/router.tsx index 6719ca5f0..c1698bdcb 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -4,14 +4,23 @@ * SPDX-License-Identifier: MIT */ import { NotFoundPage } from "@microbit/ui-patterns"; -import { createBrowserRouter, Navigate, useParams } from "react-router"; +import { + createBrowserRouter, + Navigate, + redirect, + useParams, +} from "react-router"; import { baseUrl } from "./base"; +import HomePage from "./pages/HomePage"; +import ProjectsPage from "./pages/ProjectsPage"; import { Projects } from "./project/projects"; import RootLayout from "./RootLayout"; import { isTabName } from "./router-hooks"; import { basename, createEditorUrl, + createHomePageUrl, + createProjectsPageUrl, editorRoutePath, iframeEditorRoutePath, legacyEditorRoutePath, @@ -35,14 +44,6 @@ const LegacyEditorRedirect = () => { ); }; -/** - * The editor keeps its place at the root here so a #project: link on a - * microbit.org page still opens the program it carries. - */ -const RootRedirect = () => ( - -); - const NotFound = () => ; export interface RouterOptions { @@ -53,8 +54,30 @@ export interface RouterOptions { projects: Projects | undefined; } -export const createRouter = ({ projects }: RouterOptions) => - createBrowserRouter( +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 (window.location.hash.startsWith("#project:")) { + return redirect(createEditorUrl() + window.location.hash); + } + return pagesLoader(); + }; + return createBrowserRouter( [ { id: "root", @@ -67,7 +90,16 @@ export const createRouter = ({ projects }: RouterOptions) => { path: "*", element: }, ] : [ - { index: true, element: }, + { + path: createHomePageUrl(), + element: , + loader: homeLoader, + }, + { + path: createProjectsPageUrl(), + element: , + loader: pagesLoader, + }, { path: editorRoutePath, element: , @@ -88,3 +120,4 @@ export const createRouter = ({ projects }: RouterOptions) => ], { basename } ); +}; diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index 1816da594..b909ed1ff 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -5,7 +5,14 @@ */ import { useMediaQuery } from "@microbit/ui"; import { Flex, styled } from "styled-system/jsx"; -import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { + ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { useIntl } from "react-intl"; import { hideSidebarMediaQuery, @@ -56,8 +63,13 @@ const Workbench = () => { 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/vite.config.ts b/vite.config.ts index b829d2e89..b9910df09 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 @@ -219,15 +236,28 @@ export default defineConfig(({ mode }) => { ], 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"), - }, + 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 From e70fb7f376fd79162ba26316a8bbfc62b0a0a02c Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 10:15:03 +0000 Subject: [PATCH 05/34] Add a home button to the editor's sidebar header Shown only when the projects database is active and not in iframe mode, where there is no home page to go to. --- src/e2e/app.ts | 7 +++---- src/workbench/SideBarHeader.tsx | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/e2e/app.ts b/src/e2e/app.ts index 873d9c68f..db3f47a19 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -776,11 +776,10 @@ export class App { } async expectFocusOnLoad(): Promise { - const link = this.page.getByLabel( - "visit microbit.org (opens in a new tab)" - ); + // The home button leads the sidebar header. + const home = this.page.getByRole("button", { name: "Home" }); await this.page.keyboard.press("Tab"); - await expect(link).toBeFocused(); + await expect(home).toBeFocused(); } async assertFocusOnSidebar(): Promise { diff --git a/src/workbench/SideBarHeader.tsx b/src/workbench/SideBarHeader.tsx index 42752c788..4503bf6c9 100644 --- a/src/workbench/SideBarHeader.tsx +++ b/src/workbench/SideBarHeader.tsx @@ -14,8 +14,9 @@ import { ModalBody, } from "@microbit/ui"; import { useCallback, useEffect, useRef, useState } from "react"; -import { RiCloseLine, RiSearch2Line } from "react-icons/ri"; +import { RiCloseLine, RiHome2Line, RiSearch2Line } from "react-icons/ri"; import { useIntl } from "react-intl"; +import { useNavigate } from "react-router"; import { Box, Flex, HStack, styled } from "styled-system/jsx"; import CollapsibleButton from "../common/CollapsibleButton"; import HideSplitViewButton from "../common/SplitView/HideSplitViewButton"; @@ -26,9 +27,12 @@ import { supportedSearchLanguages } from "../documentation/search/search.worker" import { useSearch } from "../documentation/search/search-hooks"; import SearchDialog from "../documentation/search/SearchDialog"; import { microbitOrgUrl } from "../external-links"; +import { useProjectsDatabaseActive } from "../fs/storage-status"; +import { useIframeMode } from "../iframe-mode-hooks"; import { useLogging } from "../logging/logging-hooks"; import { RouterState, useRouterState } from "../router-hooks"; import { useSettings } from "../settings/settings"; +import { createHomePageUrl } from "../urls"; import { useHotkeys } from "react-hotkeys-hook"; import { globalShortcutConfig, @@ -73,6 +77,11 @@ const SideBarHeader = ({ const [{ languageId }] = useSettings(); const searchAvailable = supportedSearchLanguages.includes(languageId); + // Without the projects database there is nothing on the home page. + const projectsDatabaseActive = useProjectsDatabaseActive(); + const iframeMode = useIframeMode(); + const showHome = projectsDatabaseActive && !iframeMode; + const navigate = useNavigate(); const handleSearchShortcut = useCallback(() => { if (searchAvailable) { @@ -181,6 +190,16 @@ const SideBarHeader = ({ searchAvailable && searchModalOpen ? "4.95rem" : topBarHeight, }} > + {showHome && ( + void navigate(createHomePageUrl())} + css={{ color: "white", fontSize: "xl", ml: "2" }} + > + + + )} Date: Sat, 12 Sep 2026 10:15:03 +0000 Subject: [PATCH 06/34] Add e2e coverage for the home and projects pages and the new routes Page objects for the home and projects pages, with the card and menu helpers shared between them, and specs for creating, opening, renaming, duplicating and deleting projects from cards and the toolbar, search and sort, the legacy documentation redirects, not-found, a #project: link at the root, and a new tab opening the most recent project. --- src/e2e/app-test-fixtures.ts | 10 +++ src/e2e/app.ts | 18 +++++- src/e2e/home-page.ts | 119 ++++++++++++++++++++++++++++++++++ src/e2e/home.test.ts | 120 ++++++++++++++++++++++++++++++++++ src/e2e/projects-page.ts | 122 +++++++++++++++++++++++++++++++++++ src/e2e/projects.test.ts | 102 +++++++++++++++++++++++++++++ src/e2e/routing.test.ts | 54 ++++++++++++++++ 7 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 src/e2e/home-page.ts create mode 100644 src/e2e/home.test.ts create mode 100644 src/e2e/projects-page.ts create mode 100644 src/e2e/projects.test.ts create mode 100644 src/e2e/routing.test.ts diff --git a/src/e2e/app-test-fixtures.ts b/src/e2e/app-test-fixtures.ts index ddf9c22d5..87701c4cf 100644 --- a/src/e2e/app-test-fixtures.ts +++ b/src/e2e/app-test-fixtures.ts @@ -1,8 +1,12 @@ import { test as base } from "@playwright/test"; import { App } from "./app.js"; +import { HomePage } from "./home-page.js"; +import { ProjectsPage } from "./projects-page.js"; type MyFixtures = { app: App; + homePage: HomePage; + projectsPage: ProjectsPage; }; type Options = { @@ -52,4 +56,10 @@ export const test = base.extend({ } 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 db3f47a19..93130f98d 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -833,7 +833,20 @@ const getAbsoluteFilePath = (filePathFromProjectRoot: string) => { return path.join(dir.replace("src/e2e", ""), filePathFromProjectRoot); }; -export const editorUrl = (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. + */ +export const appUrl = (path: string, options: UrlOptions = {}): string => { const flags = new Set([ "none", "noWelcome", @@ -854,8 +867,7 @@ export const editorUrl = (options: UrlOptions = {}): string => { // 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 ?? "/") + - // In controller mode the editor is the only page and stays at the root. - (options.controller ? "" : "project") + + path + "?" + new URLSearchParams(params).toString() + (options.fragment ?? "") diff --git a/src/e2e/home-page.ts b/src/e2e/home-page.ts new file mode 100644 index 000000000..c326c8b8f --- /dev/null +++ b/src/e2e/home-page.ts @@ -0,0 +1,119 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { expect, Locator, Page } from "@playwright/test"; +import { 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: "Your 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(/\/(\?|$)/); + } + + /** 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(); + } + } + + async viewAllProjects(): Promise { + await this.page.getByRole("link", { name: "View all projects" }).click(); + } +} diff --git a/src/e2e/home.test.ts b/src/e2e/home.test.ts new file mode 100644 index 000000000..90bfe15e2 --- /dev/null +++ b/src/e2e/home.test.ts @@ -0,0 +1,120 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { expect } from "@playwright/test"; +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: "Code your BBC micro:bit with Python", + }) + ).toBeVisible(); + for (const row of [ + "Project ideas", + "Teacher resources", + "Help and support", + ]) { + 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(/\/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.page.getByRole("button", { name: "Home" }).click(); + + 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.page.getByRole("button", { name: "Home" }).click(); + await homePage.expectOnPage(); + await homePage.newProject("Other"); + await app.expectProjectName("Other"); + await app.page.getByRole("button", { name: "Home" }).click(); + 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.page.getByRole("button", { name: "Home" }).click(); + 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("keeps projects across a reload", async ({ app, homePage }) => { + await homePage.goto(); + await homePage.newProject("Night light"); + await app.expectProjectName("Night light"); + await app.page.getByRole("button", { name: "Home" }).click(); + 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/projects-page.ts b/src/e2e/projects-page.ts new file mode 100644 index 000000000..3b7c1c55e --- /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 { 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: "Your 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(/\/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..c49916716 --- /dev/null +++ b/src/e2e/projects.test.ts @@ -0,0 +1,102 @@ +/** + * (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.page.getByRole("button", { name: "Home" }).click(); + 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("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/routing.test.ts b/src/e2e/routing.test.ts new file mode 100644 index 000000000..3386cb312 --- /dev/null +++ b/src/e2e/routing.test.ts @@ -0,0 +1,54 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { expect } from "@playwright/test"; +import { appUrl, 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(/\/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(); + }); + + test("a #project: link at the root opens the program in the editor", async ({ + app, + }) => { + await app.page.goto(homeUrl({ fragment: heartMigrationFragment })); + await expect(app.page).toHaveURL(/\/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"); + }); +}); From 2b075f66630dd657d5d98b1cab4b0270ab122cc9 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 15:52:32 +0000 Subject: [PATCH 07/34] Make the sidebar logo the way home instead of a separate button With the sidebar collapsed there is no room for a home button beside the logo, so the logo links to the home page when the projects database is active outside iframe mode. Otherwise it keeps its microbit.org link, as the fallback and iframe cases have no home page to go to. Short term; revisit with the header. E2e gets an App.goHome helper for the link. --- lang/ui.en.json | 2 +- src/e2e/app.ts | 18 ++++++-- src/e2e/home.test.ts | 10 ++--- src/e2e/projects.test.ts | 2 +- src/workbench/SideBarHeader.tsx | 77 ++++++++++++++++++--------------- 5 files changed, 63 insertions(+), 46 deletions(-) diff --git a/lang/ui.en.json b/lang/ui.en.json index e96e03e17..0bef48137 100644 --- a/lang/ui.en.json +++ b/lang/ui.en.json @@ -397,7 +397,7 @@ }, "home-action": { "defaultMessage": "Home", - "description": "Button returning to the home page from the editor and the projects page" + "description": "Link on the editor logo and button on the projects page returning to the home page" }, "homepage-banner-heading": { "defaultMessage": "Code your BBC micro:bit with Python", diff --git a/src/e2e/app.ts b/src/e2e/app.ts index 93130f98d..59d763b12 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -776,10 +776,22 @@ export class App { } async expectFocusOnLoad(): Promise { - // The home button leads the sidebar header. - const home = this.page.getByRole("button", { name: "Home" }); + // The logo's home link leads the sidebar header. await this.page.keyboard.press("Tab"); - await expect(home).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 { diff --git a/src/e2e/home.test.ts b/src/e2e/home.test.ts index 90bfe15e2..62fb934a2 100644 --- a/src/e2e/home.test.ts +++ b/src/e2e/home.test.ts @@ -48,7 +48,7 @@ test.describe("home page", () => { await homePage.newProject("Night light"); await app.expectProjectName("Night light"); - await app.page.getByRole("button", { name: "Home" }).click(); + await app.goHome(); await homePage.expectOnPage(); await homePage.cards.expectVisible("Night light"); @@ -63,11 +63,11 @@ test.describe("home page", () => { await app.expectProjectName("Night light"); await app.typeInEditor("# a change"); await app.expectEditorContainText("# a change"); - await app.page.getByRole("button", { name: "Home" }).click(); + await app.goHome(); await homePage.expectOnPage(); await homePage.newProject("Other"); await app.expectProjectName("Other"); - await app.page.getByRole("button", { name: "Home" }).click(); + await app.goHome(); await homePage.expectOnPage(); await homePage.cards.open("Night light"); @@ -83,7 +83,7 @@ test.describe("home page", () => { await homePage.goto(); await homePage.newProject("Night light"); await app.expectProjectName("Night light"); - await app.page.getByRole("button", { name: "Home" }).click(); + await app.goHome(); await homePage.expectOnPage(); await homePage.cards.menuRename("Night light", "Day light"); @@ -103,7 +103,7 @@ test.describe("home page", () => { await homePage.goto(); await homePage.newProject("Night light"); await app.expectProjectName("Night light"); - await app.page.getByRole("button", { name: "Home" }).click(); + await app.goHome(); await homePage.expectOnPage(); await homePage.page.reload(); diff --git a/src/e2e/projects.test.ts b/src/e2e/projects.test.ts index c49916716..f6a44a150 100644 --- a/src/e2e/projects.test.ts +++ b/src/e2e/projects.test.ts @@ -28,7 +28,7 @@ test.describe("projects page", () => { for (const name of ["Alpha", "Beta"]) { await homePage.newProject(name); await app.expectProjectName(name); - await app.page.getByRole("button", { name: "Home" }).click(); + await app.goHome(); await homePage.expectOnPage(); } await projectsPage.goto(); diff --git a/src/workbench/SideBarHeader.tsx b/src/workbench/SideBarHeader.tsx index 4503bf6c9..66137f905 100644 --- a/src/workbench/SideBarHeader.tsx +++ b/src/workbench/SideBarHeader.tsx @@ -14,9 +14,10 @@ import { ModalBody, } from "@microbit/ui"; import { useCallback, useEffect, useRef, useState } from "react"; -import { RiCloseLine, RiHome2Line, RiSearch2Line } from "react-icons/ri"; +import { RiCloseLine, RiSearch2Line } from "react-icons/ri"; import { useIntl } from "react-intl"; -import { useNavigate } from "react-router"; +import { Link as RouterLink } from "react-router"; +import { css } from "styled-system/css"; import { Box, Flex, HStack, styled } from "styled-system/jsx"; import CollapsibleButton from "../common/CollapsibleButton"; import HideSplitViewButton from "../common/SplitView/HideSplitViewButton"; @@ -77,11 +78,11 @@ const SideBarHeader = ({ const [{ languageId }] = useSettings(); const searchAvailable = supportedSearchLanguages.includes(languageId); - // Without the projects database there is nothing on the home page. + // Without the projects database there is nothing on the home page, so + // the logo keeps its link to microbit.org. const projectsDatabaseActive = useProjectsDatabaseActive(); const iframeMode = useIframeMode(); - const showHome = projectsDatabaseActive && !iframeMode; - const navigate = useNavigate(); + const logoLinksHome = projectsDatabaseActive && !iframeMode; const handleSearchShortcut = useCallback(() => { if (searchAvailable) { @@ -140,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 && ( @@ -190,40 +203,32 @@ const SideBarHeader = ({ searchAvailable && searchModalOpen ? "4.95rem" : topBarHeight, }} > - {showHome && ( - void navigate(createHomePageUrl())} - css={{ color: "white", fontSize: "xl", ml: "2" }} + className={css({ + display: "block", + mx: "1rem", + borderRadius: "md", + outline: "none", + _focusVisible: { focusRing: "outline" }, + })} + > + {logo} + + ) : ( + - - + {logo} + )} - - - - {brand.squareLogo} - - {!query && sidebarShown && ( - - {brand.horizontalLogo} - - )} - - {searchAvailable && !query && sidebarShown && ( Date: Sat, 12 Sep 2026 15:52:32 +0000 Subject: [PATCH 08/34] Show the Python logo in brand colour at the top-left of project cards ProjectIcon mirrors ml-trainer's component of the same name. On the projects page it drops below the selection checkbox's 60px hover strip so the two do not crowd the corner; the home row gets a matching inset. The theme's spacing scale is smaller than Panda's default, so the margins were measured in the browser rather than taken from ml-trainer. --- src/pages/HomePage.tsx | 13 +++---------- src/pages/ProjectIcon.tsx | 34 ++++++++++++++++++++++++++++++++++ src/pages/ProjectsPage.tsx | 4 ++-- 3 files changed, 39 insertions(+), 12 deletions(-) create mode 100644 src/pages/ProjectIcon.tsx diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 83e8238c3..03e7c62ee 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -3,19 +3,19 @@ * * SPDX-License-Identifier: MIT */ -import { Box, css } from "@microbit/ui"; +import { css } from "@microbit/ui"; import { CarouselRow } from "@microbit/ui-carousel"; import { NameProjectDialog, ProjectCard } from "@microbit/ui-patterns"; import { useCallback, useState } from "react"; import { RiAddLine, RiFolderOpenLine } from "react-icons/ri"; import { FormattedMessage, useIntl } from "react-intl"; import { Link as RouterLink, useNavigate } from "react-router"; -import PythonLogo from "../common/PythonLogo"; 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 { usePageProjects, useProjectPageActions } from "./project-page-actions"; import { @@ -68,7 +68,7 @@ const ProjectsRow = () => { onRename={actions.rename} onDuplicate={actions.duplicate} > - + )), ...(projects.length > numCardsDisplayed @@ -88,13 +88,6 @@ const ProjectsRow = () => { ); }; -/** The picture on a project card. */ -export const ProjectGlyph = () => ( - - - -); - const NewProjectCard = ({ onCreate, }: { 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 index fdee06dca..3356db4e2 100644 --- a/src/pages/ProjectsPage.tsx +++ b/src/pages/ProjectsPage.tsx @@ -33,7 +33,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FormattedMessage } from "react-intl"; import { useLogging } from "../logging/logging-hooks"; import DefaultPageLayout from "./DefaultPageLayout"; -import { ProjectGlyph } from "./HomePage"; +import ProjectIcon from "./ProjectIcon"; import { PageProject, usePageProjects, @@ -191,7 +191,7 @@ const ProjectsPage = () => { onRename={actions.rename} onDuplicate={actions.duplicate} > - + ))} From 3c5f2dc974918289f5951bc0ee564afbfdce4715 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 16:21:54 +0000 Subject: [PATCH 09/34] Make a #project: link a new project when the projects database is active PendingMigration holds the link the app booted with and hands it out once. The Projects session takes it when the editor first chooses a project and creates one from it, ahead of the tab's current project, so a link no longer overwrites whatever was open. Without the database the host takes it as before and writes it into the single implicit project. The editor route's loader redirects off the hash once the link has become a project; the session cannot strip it itself because the router commits the navigation, hash included, after the loader returns. --- src/App.tsx | 12 +++++++-- src/e2e/migration.test.ts | 6 ++++- src/fs/current-project.test.ts | 17 ++++++++++++ src/fs/current-project.ts | 28 ++++++++++++++++--- src/fs/host-default.test.ts | 5 ++-- src/fs/host.ts | 38 +++++++++++++------------- src/fs/migration.ts | 27 +++++++++++++++++++ src/project/projects.test.ts | 2 +- src/project/projects.ts | 49 +++++++++++++++++++++++++--------- src/router.tsx | 10 +++++-- 10 files changed, 151 insertions(+), 43 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 293a0cc8e..edd60e604 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { FileSystem } from "./fs/fs"; import { FileSystemProvider } from "./fs/fs-hooks"; 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"; @@ -56,12 +57,19 @@ const device: MicrobitUSBConnection = isMockDeviceMode() // 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 host = createHost(logging, projectStorage.promise); +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); + : new Projects( + fs, + logging, + openProjectsDatabase(logging), + projectStorage, + migration + ); if (!projects) { projectStorage.resolve(undefined); } 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/fs/current-project.test.ts b/src/fs/current-project.test.ts index 3d2d43b57..78b2f842a 100644 --- a/src/fs/current-project.test.ts +++ b/src/fs/current-project.test.ts @@ -14,6 +14,8 @@ import { 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 { @@ -134,6 +136,21 @@ describe("chooseProject", () => { 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); diff --git a/src/fs/current-project.ts b/src/fs/current-project.ts index c38745b34..5f0dc37e9 100644 --- a/src/fs/current-project.ts +++ b/src/fs/current-project.ts @@ -9,7 +9,9 @@ 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 { @@ -88,19 +90,39 @@ export const defaultProjectFiles = (): Record => ]) ); +/** + * 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: the project the tab already has open; a project migrated from - * the session-storage file system that predates the projects database, so a + * 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 + 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); 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 52c669634..7e836a088 100644 --- a/src/fs/host.ts +++ b/src/fs/host.ts @@ -11,7 +11,7 @@ import { PythonProject, projectFilesToBase64, } from "./initial-project"; -import { parseMigrationFromUrl } from "./migration"; +import { PendingMigration } from "./migration"; import { FSStorage, InMemoryFSStorage, SplitStrategyStorage } from "./storage"; const messages = { @@ -33,12 +33,13 @@ export interface Host { export class DefaultHost implements Host { /** - * @param url The URL at boot, for a #project: migration. + * @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 url: string = "", + private migration: PendingMigration = new PendingMigration(""), private persistentStorage: Promise = Promise.resolve( undefined ) @@ -53,30 +54,26 @@ export class DefaultHost implements Host { } 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 } = 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. 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 project; } return defaultInitialProject; } @@ -157,13 +154,14 @@ export class IframeHost implements 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, persistentStorage); + return new DefaultHost(migration, persistentStorage); }; const getControllerHost = (logging: Logging): Window | undefined => { diff --git a/src/fs/migration.ts b/src/fs/migration.ts index 8a494813a..ca96f52b1 100644 --- a/src/fs/migration.ts +++ b/src/fs/migration.ts @@ -56,3 +56,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/project/projects.test.ts b/src/project/projects.test.ts index 4792eecce..47a3190e0 100644 --- a/src/project/projects.test.ts +++ b/src/project/projects.test.ts @@ -45,7 +45,7 @@ const setup = async (dbAvailable = true) => { ? await ProjectsDatabase.open(`projects-${Date.now()}-${counter++}`) : undefined; const storage = deferred(); - const host = new DefaultHost("", storage.promise); + 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 }; diff --git a/src/project/projects.ts b/src/project/projects.ts index 747b815dc..9df8a671c 100644 --- a/src/project/projects.ts +++ b/src/project/projects.ts @@ -17,6 +17,7 @@ import { 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, @@ -64,7 +65,7 @@ export class Projects extends TypedEventTarget { private firstStorageResolved = false; private openId: string | undefined; private openStorage: IndexedDBFSStorage | undefined; - private opening: Promise | undefined; + private opening: Promise | undefined; private cachedList: ProjectListEntry[] = []; private readonly channel: BroadcastChannel | undefined; private readonly session = sessionStorageIfPossible(); @@ -72,12 +73,15 @@ export class Projects extends TypedEventTarget { /** * @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 firstStorage: Deferred, + private migration: PendingMigration = new PendingMigration("") ) { super(); this.ready = db.then((resolved) => { @@ -120,29 +124,39 @@ export class Projects extends TypedEventTarget { } /** - * Makes sure the editor has a project: the one already open, else 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. + * 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 { + openCurrent(): Promise { this.opening ??= this.chooseAndOpen().finally(() => { this.opening = undefined; }); return this.opening; } - private async chooseAndOpen(): Promise { + private async chooseAndOpen(): Promise { await this.ready; if (!this.db) { this.resolveFirstStorage( hasStorageVersionError() ? undefined : SessionStorageFSStorage.create() ); - return; + return false; } - if (this.openId && (await this.db.get(this.openId))) { - return; + const migration = this.migration.take(); + if (!migration && this.openId && (await this.db.get(this.openId))) { + return false; } - await this.switchTo(await chooseProject(this.db, this.session)); + 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 { @@ -155,9 +169,20 @@ export class Projects extends TypedEventTarget { } 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() }, defaultProjectFiles()); + await db.create({ id, name, timestamp: Date.now() }, files); await this.switchTo(id); await this.changed([id]); return id; diff --git a/src/router.tsx b/src/router.tsx index c1698bdcb..9e9196f39 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -105,8 +105,14 @@ export const createRouter = ({ projects }: RouterOptions) => { element: , // The editor needs a project; choosing one is the only // asynchronous step, the MicroPython load carries on behind. - loader: async () => { - await projects.openCurrent(); + 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. + const { pathname, search } = new URL(request.url); + return redirect(pathname + search); + } return null; }, }, From fafd95df9ac2ebb693d28976cde29997f49e3d7e Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 16:21:54 +0000 Subject: [PATCH 10/34] Import files as new projects, and add files to the open project A hex is a whole program, so it always becomes a new project, whether dropped on the editor, chosen from the Files tab, or imported from the home page. Other files join the open project from the editor, replacing any with the same names, and become a new project from the home page, where a single script becomes main.py and names the project. Ideas from the documentation open as new projects too. Without the projects database a hex or idea replaces the single implicit project, with no confirmation. ProjectImporter (src/project/project-import.tsx) holds this for both surfaces. The confirm-replace dialog, the choose-main-script dialog and the replace-or-add flow go, along with Reset project; replacing a program now means importing from the home page. The Project tab is Files, Open is Add files and Open leaves the action bar. FileSystem.filesFromHex reads a hex with a separate file system so the open project is untouched; the MicroPython hex is fetched once for both. project_import gains a surface param; project_reset is retired. --- docs/analytics-events.md | 21 +- lang/ui.en.json | 68 +--- src/e2e/app.ts | 88 +---- src/e2e/home-page.ts | 10 +- src/e2e/home.test.ts | 21 ++ src/e2e/multiple-files.test.ts | 21 +- src/e2e/open.test.ts | 92 ++--- src/e2e/reset.test.ts | 22 -- src/e2e/save.test.ts | 11 +- src/fs/fs.test.ts | 34 +- src/fs/fs.ts | 60 ++- src/pages/HomePage.tsx | 110 ++++-- src/pages/project-page-actions.tsx | 22 ++ .../{OpenButton.tsx => AddFilesButton.tsx} | 20 +- src/project/ChooseMainScriptQuestion.test.tsx | 146 -------- src/project/ChooseMainScriptQuestion.tsx | 189 ---------- src/project/ProjectActionBar.tsx | 9 +- src/project/ProjectAreaNav.tsx | 15 +- src/project/ProjectDropTarget.tsx | 2 +- src/project/ResetButton.tsx | 41 -- src/project/project-actions.tsx | 349 +---------------- src/project/project-hooks.tsx | 23 +- src/project/project-import.test.ts | 56 +++ src/project/project-import.tsx | 350 ++++++++++++++++++ src/project/projects-hooks.tsx | 10 +- 25 files changed, 754 insertions(+), 1036 deletions(-) delete mode 100644 src/e2e/reset.test.ts rename src/project/{OpenButton.tsx => AddFilesButton.tsx} (59%) delete mode 100644 src/project/ChooseMainScriptQuestion.test.tsx delete mode 100644 src/project/ChooseMainScriptQuestion.tsx delete mode 100644 src/project/ResetButton.tsx create mode 100644 src/project/project-import.test.ts create mode 100644 src/project/project-import.tsx diff --git a/docs/analytics-events.md b/docs/analytics-events.md index 8e69a11ed..59052dc22 100644 --- a/docs/analytics-events.md +++ b/docs/analytics-events.md @@ -108,17 +108,15 @@ 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 page. -| 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` @@ -183,7 +181,7 @@ Projects page only. Fires when the user changes the sort field or direction. ### `idea_open` -User opened an idea into the editor. +User opened an idea from the documentation as a new project. | Param | Values | | ----- | -------------------------- | @@ -290,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/lang/ui.en.json b/lang/ui.en.json index 0bef48137..c984b3698 100644 --- a/lang/ui.en.json +++ b/lang/ui.en.json @@ -31,6 +31,14 @@ "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" @@ -75,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,30 +139,10 @@ "defaultMessage": "Confirm delete", "description": "Confirmation header of deletion of a file" }, - "confirm-replace-body": { - "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-title": { - "defaultMessage": "Confirm replace project", - "description": "Confirmation message title for replacing project dialog" - }, - "confirm-replace-with-idea": { - "defaultMessage": "Replace all files with {ideaName}?", - "description": "Confirmation message body for replacing project dialog with an idea" - }, "confirm-save-action": { "defaultMessage": "Confirm and save", "description": "Confirm and save action label" }, - "confirm-save-hint": { - "defaultMessage": "Cancel then use Save to keep a copy of your project.", - "description": "Hint shown when the user has unsaved changes that they might lose if they continue with an action" - }, "connect-action": { "defaultMessage": "Connect", "description": "Connect button text" @@ -415,6 +399,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" }, @@ -566,22 +554,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." @@ -667,8 +643,8 @@ "description": "Validation message for project name" }, "project-tab": { - "defaultMessage": "Project", - "description": "Project tab button text" + "defaultMessage": "Files", + "description": "Files tab button text (the project's files)" }, "project-tab-description": { "defaultMessage": "View, create, add and edit the files in your project", @@ -706,22 +682,6 @@ "defaultMessage": "Reference", "description": "Reference tab button text" }, - "replace-action-label": { - "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." diff --git a/src/e2e/app.ts b/src/e2e/app.ts index 59d763b12..3b0bda1c9 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -17,13 +17,6 @@ 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; @@ -44,38 +37,6 @@ 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; @@ -103,8 +64,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) { @@ -336,12 +297,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 @@ -349,12 +310,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(); @@ -363,27 +318,17 @@ 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(); - } } - async dropFile( - filePathFromProjectRoot: string, - options: { acceptDialog?: LoadDialogType } = {} - ) { + async dropFile(filePathFromProjectRoot: string) { const filePath = getAbsoluteFilePath(filePathFromProjectRoot); const filename = getFilename(filePathFromProjectRoot); @@ -411,11 +356,6 @@ export class App { const dropZone = this.page.getByTestId("project-drop-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(); - } } async expectAlertText(title: string, description?: string): Promise { @@ -426,19 +366,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(); } @@ -483,7 +423,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(); } @@ -840,7 +780,7 @@ 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); }; diff --git a/src/e2e/home-page.ts b/src/e2e/home-page.ts index c326c8b8f..3270e9e6b 100644 --- a/src/e2e/home-page.ts +++ b/src/e2e/home-page.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: MIT */ import { expect, Locator, Page } from "@playwright/test"; -import { homeUrl } from "./app.js"; +import { getAbsoluteFilePath, homeUrl } from "./app.js"; /** * The modal dialog rather than a menu popover, which also has the dialog @@ -113,6 +113,14 @@ export class HomePage { } } + /** 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 projects" }).click(); } diff --git a/src/e2e/home.test.ts b/src/e2e/home.test.ts index 62fb934a2..dac096a42 100644 --- a/src/e2e/home.test.ts +++ b/src/e2e/home.test.ts @@ -99,6 +99,27 @@ test.describe("home page", () => { 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("keeps projects across a reload", async ({ app, homePage }) => { await homePage.goto(); await homePage.newProject("Night light"); diff --git a/src/e2e/multiple-files.test.ts b/src/e2e/multiple-files.test.ts index a33f94bfa..4f7bfcee4 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,17 @@ 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.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 +50,12 @@ 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.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..42d9c586e 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,16 @@ 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("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 +35,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 +77,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 +87,11 @@ 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.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/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/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/fs/fs.test.ts b/src/fs/fs.test.ts index ccaf45e6a..e9d7df2a6 100644 --- a/src/fs/fs.test.ts +++ b/src/fs/fs.test.ts @@ -148,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/); @@ -160,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(); @@ -179,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); diff --git a/src/fs/fs.ts b/src/fs/fs.ts index bd4bdeb61..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"; @@ -378,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 { @@ -531,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/pages/HomePage.tsx b/src/pages/HomePage.tsx index 03e7c62ee..b359b0834 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -3,13 +3,14 @@ * * SPDX-License-Identifier: MIT */ -import { css } from "@microbit/ui"; +import { Button, css, Icon, IconButton } from "@microbit/ui"; import { CarouselRow } from "@microbit/ui-carousel"; import { NameProjectDialog, ProjectCard } from "@microbit/ui-patterns"; -import { useCallback, useState } from "react"; -import { RiAddLine, RiFolderOpenLine } from "react-icons/ri"; +import { ChangeEvent, useCallback, useRef, useState } from "react"; +import { RiAddLine, RiFolderOpenLine, 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"; @@ -17,7 +18,11 @@ import ActionCard from "./ActionCard"; import DefaultPageLayout from "./DefaultPageLayout"; import ProjectIcon from "./ProjectIcon"; import HomepageBanner from "./HomepageBanner"; -import { usePageProjects, useProjectPageActions } from "./project-page-actions"; +import { + useImportProjectFiles, + usePageProjects, + useProjectPageActions, +} from "./project-page-actions"; import { createHelpCards, createLessonCards, @@ -30,25 +35,32 @@ 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 - /> + + + + } + navigation + /> + } + navigation + /> + } + navigation + /> + ); }; @@ -81,7 +93,10 @@ const ProjectsRow = () => { } - actions={} + actions={[ + , + , + ]} navigation /> @@ -119,6 +134,57 @@ const NewProjectCard = ({ ); }; +/** + * 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 ( + <> + + + + + + + ); +}; + const ViewAllProjectsCard = () => { const navigate = useNavigate(); return ( diff --git a/src/pages/project-page-actions.tsx b/src/pages/project-page-actions.tsx index e16c40563..a7bb6372c 100644 --- a/src/pages/project-page-actions.tsx +++ b/src/pages/project-page-actions.tsx @@ -11,6 +11,8 @@ 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 { createEditorUrl } from "../urls"; @@ -100,3 +102,23 @@ export const useProjectPageActions = ( 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/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/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..0e0ff2e7e 100644 --- a/src/project/ProjectDropTarget.tsx +++ b/src/project/ProjectDropTarget.tsx @@ -15,7 +15,7 @@ const ProjectDropTarget = ({ children }: ProjectDropTargetProps) => { const actions = useProjectActions(); const handleDrop = useCallback( (files: File[]) => { - actions.load(files, "drop-load"); + actions.load(files, "drop"); }, [actions] ); 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..2f232c6d6 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,40 @@ 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 { + await this.importer.newProject(title, { + [MAIN_FILE]: new TextEncoder().encode(code), + }); 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 +788,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..0442bafca 100644 --- a/src/project/project-hooks.tsx +++ b/src/project/project-hooks.tsx @@ -22,6 +22,24 @@ 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 intl = useIntl(); + const logging = useLogging(); + return useMemo( + () => new ProjectImporter(fs, projects, actionFeedback, intl, logging), + [fs, projects, actionFeedback, intl, logging] + ); +}; /** * Hook exposing the main UI actions. @@ -37,6 +55,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 +68,8 @@ export const useProjectActions = (): ProjectActions => { { values: sessionSettings, setValues: setSessionSettings }, intl, logging, - client + client, + importer ), [ fs, @@ -64,6 +84,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..d65e5adff --- /dev/null +++ b/src/project/project-import.tsx @@ -0,0 +1,350 @@ +/** + * 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 { ActionFeedback } from "../common/use-action-feedback"; +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 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 { + await this.newProject(project.name, project.files); + } 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. + */ + newProject = async ( + name: string | undefined, + files: Record + ): Promise => { + if (this.projects && (await this.projects.isAvailable())) { + await this.projects.createFromFiles(name, files); + } else { + await this.fs.replaceWithFiles(name, files); + } + }; + + 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 { + await this.newProject(name, files); + } 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, + })); + 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 index 56f9de61a..11be741e9 100644 --- a/src/project/projects-hooks.tsx +++ b/src/project/projects-hooks.tsx @@ -17,8 +17,8 @@ const ProjectsContext = createContext(undefined); export const ProjectsProvider = ProjectsContext.Provider; /** - * The projects in this browser. Undefined in iframe controller mode, where - * the embedding page owns the project. + * 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); @@ -28,6 +28,12 @@ export const useProjects = (): Projects => { 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. From 8376e867e20127e68bbead789c7ebd8636a36319 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sat, 12 Sep 2026 21:26:09 +0000 Subject: [PATCH 11/34] Align the home page projects row with ml-trainer Use the solid button variant for Import, rename the row to "My projects" with a "View all" link, and add the info tooltip explaining that projects are stored in the browser. The tooltip text is copied from ml-trainer. Tidying the catalogs also removes translations of strings that an earlier commit dropped from English, which the CI tidy check requires. --- lang/ui.ca.json | 39 -------------------------------------- lang/ui.de.json | 39 -------------------------------------- lang/ui.en.json | 8 ++++++-- lang/ui.es-ES.json | 39 -------------------------------------- lang/ui.fr.json | 39 -------------------------------------- lang/ui.ga-IE.json | 39 -------------------------------------- lang/ui.ja.json | 39 -------------------------------------- lang/ui.ko.json | 39 -------------------------------------- lang/ui.lol.json | 39 -------------------------------------- lang/ui.nl.json | 39 -------------------------------------- lang/ui.pl.json | 39 -------------------------------------- lang/ui.zh-CN.json | 39 -------------------------------------- lang/ui.zh-TW.json | 39 -------------------------------------- src/e2e/home-page.ts | 4 ++-- src/e2e/projects-page.ts | 2 +- src/pages/HomePage.tsx | 41 +++++++++++++++++++++++++++++++++++++--- 16 files changed, 47 insertions(+), 476 deletions(-) diff --git a/lang/ui.ca.json b/lang/ui.ca.json index 3ddeec423..c723b3034 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" }, @@ -473,18 +446,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 a3e21c661..9cfef4340 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" }, @@ -473,18 +446,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 c984b3698..20313cc41 100644 --- a/lang/ui.en.json +++ b/lang/ui.en.json @@ -491,7 +491,7 @@ "description": "Title of dialog shown when multiple files are available for download but only the main file downloaded." }, "my-projects-row-title": { - "defaultMessage": "Your projects", + "defaultMessage": "My projects", "description": "Heading of the home page row of the user's projects, and of the projects page" }, "name-project": { @@ -642,6 +642,10 @@ "defaultMessage": "The project name cannot be empty", "description": "Validation message for project name" }, + "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": { "defaultMessage": "Files", "description": "Files tab button text (the project's files)" @@ -1187,7 +1191,7 @@ "description": "Menu item for link to user guide site" }, "view-all-projects": { - "defaultMessage": "View all projects", + "defaultMessage": "View all", "description": "Link and card on the home page leading to the projects page" }, "visit-dot-org": { diff --git a/lang/ui.es-ES.json b/lang/ui.es-ES.json index 44badbdb0..4d353f8e2 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" }, @@ -473,18 +446,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 75cf586da..457bf6bb0 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" }, @@ -473,18 +446,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 cc8d2a4bf..eeae19028 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" }, @@ -473,18 +446,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 22d3baacd..2e0798fd7 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": "パラメータのヘルプ" }, @@ -473,18 +446,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 ddea6d64d..155dcc315 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": "매개변수 도움말" }, @@ -470,18 +443,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 8186e5efa..68dcaab09 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" }, @@ -473,18 +446,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 9fea59b0a..f97271039 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" }, @@ -473,18 +446,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 9792c7d6a..746ed6f70 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" }, @@ -473,18 +446,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 0d8dc26da..861a93fd1 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": "参数帮助" }, @@ -473,18 +446,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 1df1e54b4..2ce53a2c1 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": "參數說明" }, @@ -470,18 +443,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/src/e2e/home-page.ts b/src/e2e/home-page.ts index 3270e9e6b..62e2f163a 100644 --- a/src/e2e/home-page.ts +++ b/src/e2e/home-page.ts @@ -88,7 +88,7 @@ export class HomePage { constructor(public readonly page: Page) { this.cards = new ProjectCards(page); - this.projectsHeading = page.getByRole("heading", { name: "Your projects" }); + this.projectsHeading = page.getByRole("heading", { name: "My projects" }); } async goto(): Promise { @@ -122,6 +122,6 @@ export class HomePage { } async viewAllProjects(): Promise { - await this.page.getByRole("link", { name: "View all projects" }).click(); + await this.page.getByRole("link", { name: "View all" }).click(); } } diff --git a/src/e2e/projects-page.ts b/src/e2e/projects-page.ts index 3b7c1c55e..60242482d 100644 --- a/src/e2e/projects-page.ts +++ b/src/e2e/projects-page.ts @@ -15,7 +15,7 @@ export class ProjectsPage { constructor(public readonly page: Page) { this.cards = new ProjectCards(page); - this.heading = page.getByRole("heading", { name: "Your projects" }); + 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. diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index b359b0834..c6380d394 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -3,11 +3,25 @@ * * SPDX-License-Identifier: MIT */ -import { Button, css, Icon, IconButton } from "@microbit/ui"; +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, RiUpload2Line } from "react-icons/ri"; +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"; @@ -66,6 +80,10 @@ const HomePage = () => { }; const ProjectsRow = () => { + const tooltipPlacement = useBreakpointValue<"bottom" | "right">({ + base: "bottom", + sm: "right", + }); const projects = usePageProjects(); const { actions, open, create } = useProjectPageActions("home", projects); const cards = [ @@ -93,6 +111,24 @@ const ProjectsRow = () => { } + titleSuffix={ + + + + + + } + > + + + } actions={[ , , @@ -174,7 +210,6 @@ const ImportProjectButton = () => { From 0382b7a0972c97b189c3765e9ec965f5af867d04 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 13 Sep 2026 09:11:53 +0000 Subject: [PATCH 18/34] Use the plain button variant for the page header's settings and help menus The menus' sidebar variant, made for the editor's black chrome, brought its white-pill hover to the purple header. The family shows no hover state on icon buttons in the brand-coloured bar, so the pages pass the base recipe's plain variant; the editor keeps sidebar. --- src/pages/DefaultPageLayout.tsx | 4 ++-- src/settings/SettingsMenu.tsx | 14 ++++++++++++-- src/workbench/HelpMenu.tsx | 14 ++++++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/pages/DefaultPageLayout.tsx b/src/pages/DefaultPageLayout.tsx index 2ee79ad69..9f8cbc6fe 100644 --- a/src/pages/DefaultPageLayout.tsx +++ b/src/pages/DefaultPageLayout.tsx @@ -107,8 +107,8 @@ const DefaultPageLayout = ({ > {backToHome ? : } - - + + diff --git a/src/settings/SettingsMenu.tsx b/src/settings/SettingsMenu.tsx index 14237c2a7..1edc42acc 100644 --- a/src/settings/SettingsMenu.tsx +++ b/src/settings/SettingsMenu.tsx @@ -21,6 +21,12 @@ 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; } @@ -28,7 +34,11 @@ interface SettingsMenuProps { /** * The settings button triggers a menu with main and other settings. */ -const SettingsMenu = ({ size, css: cssProp }: SettingsMenuProps) => { +const SettingsMenu = ({ + size, + variant = "sidebar", + css: cssProp, +}: SettingsMenuProps) => { const [languageDialogOpen, setLanguageDialogOpen] = useState(false); const intl = useIntl(); const dialogs = useDialogs(); @@ -56,7 +66,7 @@ const SettingsMenu = ({ size, css: cssProp }: SettingsMenuProps) => { aria-label={intl.formatMessage({ id: "settings" })} size={size} css={{ fontSize: "xl", ...cssProp }} - variant="sidebar" + variant={variant} > diff --git a/src/workbench/HelpMenu.tsx b/src/workbench/HelpMenu.tsx index cea3e8d9e..1f6dead58 100644 --- a/src/workbench/HelpMenu.tsx +++ b/src/workbench/HelpMenu.tsx @@ -27,6 +27,12 @@ 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; } @@ -34,7 +40,11 @@ interface HelpMenuProps { /** * A help button that triggers a drop-down menu with actions. */ -const HelpMenu = ({ size, css: cssProp }: HelpMenuProps) => { +const HelpMenu = ({ + size, + variant = "sidebar", + css: cssProp, +}: HelpMenuProps) => { const [aboutDialogOpen, setAboutDialogOpen] = useState(false); const intl = useIntl(); const dialogs = useDialogs(); @@ -66,7 +76,7 @@ const HelpMenu = ({ size, css: cssProp }: HelpMenuProps) => { aria-label={intl.formatMessage({ id: "help" })} size={size} css={{ fontSize: "xl", ...cssProp }} - variant="sidebar" + variant={variant} > From ec9f8f401ae6f1e1c121e578d3cae825520eb782 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 13 Sep 2026 09:13:01 +0000 Subject: [PATCH 19/34] Update the home spec for the reworded banner heading --- src/e2e/home.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/e2e/home.test.ts b/src/e2e/home.test.ts index dfe0bd1d4..0b4776df0 100644 --- a/src/e2e/home.test.ts +++ b/src/e2e/home.test.ts @@ -14,7 +14,7 @@ test.describe("home page", () => { await homePage.goto(); await expect( homePage.page.getByRole("heading", { - name: "Code your BBC micro:bit with Python", + name: "Python for the BBC micro:bit", }) ).toBeVisible(); for (const row of ["Project ideas", "Teacher resources", "Help"]) { From 748d40165aae148d9a3c847192d3c938b5fc59c7 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 13 Sep 2026 09:44:24 +0000 Subject: [PATCH 20/34] Scope the editor's drop target to the editor, give the projects page one, and confirm before replacing files The editor's drop target had moved from around the editor to around the router when the pages arrived, so a drop on a page took the editor path. On the projects page in a tab with no project open yet that write waited silently on the storage promise and landed in whatever project was opened next; on the home page the nested page target took the drop and the outer overlay, never seeing a drag-leave, stayed over the editor. The target now wraps the editor routes only, and the projects page has its own with the home page's semantics: the files make a new project that opens in the editor. Without the projects database a hex or idea asks before replacing an edited project again, with the strings from main so translations survive; the before-unload prompt only guards closing the tab, not an in-tab replace. Adding files that would overwrite existing ones asks first in every mode. load-error-mixed no longer claims a hex replaces the project. --- docs/analytics-events.md | 2 +- lang/ui.en.json | 30 ++++- src/App.tsx | 9 +- src/e2e/app.ts | 32 +++++- src/e2e/home.test.ts | 11 ++ src/e2e/multiple-files.test.ts | 2 + src/e2e/open.test.ts | 16 +++ src/e2e/projects.test.ts | 23 ++++ src/e2e/storage-errors.test.ts | 22 ++++ src/pages/ProjectsPage.tsx | 197 +++++++++++++++++--------------- src/project/project-actions.tsx | 14 ++- src/project/project-hooks.tsx | 6 +- src/project/project-import.tsx | 80 +++++++++++-- src/router.tsx | 14 ++- 14 files changed, 338 insertions(+), 120 deletions(-) diff --git a/docs/analytics-events.md b/docs/analytics-events.md index 59052dc22..857b98e3d 100644 --- a/docs/analytics-events.md +++ b/docs/analytics-events.md @@ -110,7 +110,7 @@ No `destination` param: the editor only downloads. ml-trainer's User brought files in. Fires once per drop / picker selection, before the 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 page. +from the home and projects pages. | Param | Values | | --------- | ------------------------------------------------------------------------------------------------------------ | diff --git a/lang/ui.en.json b/lang/ui.en.json index 4fd614bc0..a6d6f026c 100644 --- a/lang/ui.en.json +++ b/lang/ui.en.json @@ -139,10 +139,34 @@ "defaultMessage": "Confirm delete", "description": "Confirmation header of deletion of a file" }, + "confirm-replace-body": { + "defaultMessage": "Replace all files with those in the hex?", + "description": "Confirmation message body for replacing project dialog" + }, + "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", + "description": "Confirmation message title for replacing project dialog" + }, + "confirm-replace-with-idea": { + "defaultMessage": "Replace all files with {ideaName}?", + "description": "Confirmation message body for replacing project dialog with an idea" + }, "confirm-save-action": { "defaultMessage": "Confirm and save", "description": "Confirm and save action label" }, + "confirm-save-hint": { + "defaultMessage": "Cancel then use Save to keep a copy of your project.", + "description": "Hint shown when the user has unsaved changes that they might lose if they continue with an action" + }, "connect-action": { "defaultMessage": "Connect", "description": "Connect button text" @@ -427,7 +451,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": { @@ -686,6 +710,10 @@ "defaultMessage": "Reference", "description": "Reference tab button text" }, + "replace-action-label": { + "defaultMessage": "Replace", + "description": "Action label for replacing project dialog" + }, "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." diff --git a/src/App.tsx b/src/App.tsx index 6f577eb87..fd2439bf4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,7 +35,6 @@ import { LanguageServerClientProvider } from "./language-server/language-server- 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"; @@ -130,11 +129,9 @@ const App = () => { - - - - - + + + diff --git a/src/e2e/app.ts b/src/e2e/app.ts index 3b0bda1c9..499adcac6 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -328,12 +328,21 @@ export class App { await this.projectTab.chooseFile(filePathFromProjectRoot); } - async dropFile(filePathFromProjectRoot: string) { + /** + * 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, + 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 @@ -350,14 +359,27 @@ 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 }); } + /** 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 { await expect(this.page.getByText(title)).toBeVisible(); if (description) { diff --git a/src/e2e/home.test.ts b/src/e2e/home.test.ts index 0b4776df0..1bdfcc931 100644 --- a/src/e2e/home.test.ts +++ b/src/e2e/home.test.ts @@ -116,6 +116,17 @@ test.describe("home page", () => { 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"); diff --git a/src/e2e/multiple-files.test.ts b/src/e2e/multiple-files.test.ts index 4f7bfcee4..fcdbe0a5d 100644 --- a/src/e2e/multiple-files.test.ts +++ b/src/e2e/multiple-files.test.ts @@ -34,6 +34,7 @@ test.describe("multiple-files", () => { await app.expectEditorContainText(/b_works/); await app.loadFiles("testData/updated/usermodule.py"); + await app.answerDialog("Replace"); await app.expectEditorContainText(/c_works/); }); @@ -51,6 +52,7 @@ test.describe("multiple-files", () => { } await app.loadFiles("testData/updated/module.py"); + await app.answerDialog("Replace"); await app.expectThirdPartModuleWarning("a", "1.1.0"); }); diff --git a/src/e2e/open.test.ts b/src/e2e/open.test.ts index 42d9c586e..723be8ef7 100644 --- a/src/e2e/open.test.ts +++ b/src/e2e/open.test.ts @@ -24,6 +24,21 @@ test.describe("open", () => { 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"); @@ -92,6 +107,7 @@ test.describe("open", () => { await app.expectAlertText("Added file module.py"); await app.loadFiles("testData/module.py"); + await app.answerDialog("Replace"); await app.expectAlertText("Updated file module.py"); }); }); diff --git a/src/e2e/projects.test.ts b/src/e2e/projects.test.ts index f6a44a150..c7505700e 100644 --- a/src/e2e/projects.test.ts +++ b/src/e2e/projects.test.ts @@ -54,6 +54,29 @@ test.describe("projects page", () => { 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"); diff --git a/src/e2e/storage-errors.test.ts b/src/e2e/storage-errors.test.ts index 63fce8e20..441479cdf 100644 --- a/src/e2e/storage-errors.test.ts +++ b/src/e2e/storage-errors.test.ts @@ -34,6 +34,28 @@ test.describe("storage fallback", () => { 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/); diff --git a/src/pages/ProjectsPage.tsx b/src/pages/ProjectsPage.tsx index b5b6db0ac..1727c458c 100644 --- a/src/pages/ProjectsPage.tsx +++ b/src/pages/ProjectsPage.tsx @@ -31,6 +31,7 @@ import { } from "@microbit/ui-patterns"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FormattedMessage } from "react-intl"; +import FileDropTarget from "../common/FileDropTarget"; import { useLogging } from "../logging/logging-hooks"; import DefaultPageLayout from "./DefaultPageLayout"; import ProjectIcon from "./ProjectIcon"; @@ -38,6 +39,7 @@ import { PageProject, usePageProjects, useProjectPageActions, + useImportProjectFiles, } from "./project-page-actions"; const fileNames = (project: PageProject) => project.fileNames; @@ -72,6 +74,12 @@ const ProjectsPage = () => { 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) => { @@ -114,101 +122,106 @@ const ProjectsPage = () => { <> {actions.dialogs} - - - - - - - - {selection.hasSelection && ( - - - - )} - - - {shown.length > 0 ? ( - - {shown.map((project) => ( - - + + + + + + + + {selection.hasSelection && ( + + - - + onDelete={actions.requestDelete} + onClearSelection={selection.clear} + /> - ))} - - ) : ( - - - - - - )} - - + )} + + + {shown.length > 0 ? ( + + {shown.map((project) => ( + + + + + + ))} + + ) : ( + + + + + + )} + + + { 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, intl, logging), - [fs, projects, actionFeedback, intl, logging] + () => + new ProjectImporter(fs, projects, actionFeedback, dialogs, intl, logging), + [fs, projects, actionFeedback, dialogs, intl, logging] ); }; diff --git a/src/project/project-import.tsx b/src/project/project-import.tsx index d65e5adff..812bafe64 100644 --- a/src/project/project-import.tsx +++ b/src/project/project-import.tsx @@ -10,7 +10,9 @@ 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 { @@ -102,6 +104,7 @@ export class ProjectImporter { private fs: FileSystem, private projects: Projects | undefined, private actionFeedback: ActionFeedback, + private dialogs: Dialogs, private intl: IntlShape, private logging: Logging ) {} @@ -146,7 +149,9 @@ export class ProjectImporter { const inputs = await this.readAll(files); const project = filesForNewProject(inputs); try { - await this.newProject(project.name, project.files); + if (!(await this.newProject(project.name, project.files))) { + return false; + } } catch (e) { this.actionFeedback.unexpectedError(e); return false; @@ -169,19 +174,72 @@ export class ProjectImporter { /** * Opens a new project with these files, or replaces the implicit project - * where there is no projects database. + * 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 - ): Promise => { + files: Record, + confirmPrompt?: string + ): Promise => { if (this.projects && (await this.projects.isAvailable())) { await this.projects.createFromFiles(name, files); - } else { - await this.fs.replaceWithFiles(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, @@ -211,7 +269,9 @@ export class ProjectImporter { return false; } try { - await this.newProject(name, files); + if (!(await this.newProject(name, files))) { + return false; + } } catch (e) { this.actionFeedback.unexpectedError(e); return false; @@ -229,6 +289,12 @@ export class ProjectImporter { ? 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( diff --git a/src/router.tsx b/src/router.tsx index 9e9196f39..2ed052de1 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -25,6 +25,7 @@ import { iframeEditorRoutePath, legacyEditorRoutePath, } from "./urls"; +import ProjectDropTarget from "./project/ProjectDropTarget"; import Workbench from "./workbench/Workbench"; /** @@ -77,6 +78,13 @@ export const createRouter = ({ projects }: RouterOptions) => { } 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( [ { @@ -85,9 +93,9 @@ export const createRouter = ({ projects }: RouterOptions) => { element: , children: !projects ? [ - { path: iframeEditorRoutePath, element: }, + { path: iframeEditorRoutePath, element: editor }, // Deeper paths are the editor with no tab selected, as before. - { path: "*", element: }, + { path: "*", element: editor }, ] : [ { @@ -102,7 +110,7 @@ export const createRouter = ({ projects }: RouterOptions) => { }, { path: editorRoutePath, - element: , + element: editor, // The editor needs a project; choosing one is the only // asynchronous step, the MicroPython load carries on behind. loader: async ({ request }) => { From a2394413f81f35ce70bb65464aaf97a37cf6a031 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Sun, 13 Sep 2026 10:00:37 +0000 Subject: [PATCH 21/34] Show the beta notice as a band under the page header and drop the sidebar notice's More button The pages had no notice; they now show ml-trainer's band with its longer copy and a Feedback button, under the header. The editor keeps its short sidebar notice. Both are English only, as they show on non-public stages only. The welcome video is reached from the welcome dialog alone. --- src/pages/DefaultPageLayout.tsx | 3 ++ src/pages/PageReleaseNotice.tsx | 57 ++++++++++++++++++++++++++++++ src/workbench/PreReleaseNotice.tsx | 19 ++-------- 3 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 src/pages/PageReleaseNotice.tsx diff --git a/src/pages/DefaultPageLayout.tsx b/src/pages/DefaultPageLayout.tsx index 9f8cbc6fe..3c3fd3ade 100644 --- a/src/pages/DefaultPageLayout.tsx +++ b/src/pages/DefaultPageLayout.tsx @@ -9,10 +9,12 @@ 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; @@ -111,6 +113,7 @@ const DefaultPageLayout = ({ + {flags.betaNotice && } {children} 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 + + + + + ); +}; + +export default PageReleaseNotice; 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 && ( - - )} + )} + {privacyPolicyLink && ( + + + + )} + {termsOfUseLink && ( + + + + )} + + + ); +}; + +export default HomepageFooter; From 2dcf3573b75f2bb0cbf8823567ce66ee7cd5e694 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 14 Sep 2026 12:58:19 +0000 Subject: [PATCH 27/34] Pin the released ui packages and the theme branch build @microbit/ui 0.5.0, ui-carousel 0.4.0 and ui-patterns 0.7.0 replace the linked ../ui checkout. The theme version in build.yml is a temporary build from the theme package's projects-pages branch. --- .github/workflows/build.yml | 2 +- docs/home-page.md | 7 +++---- package-lock.json | 28 ++++++++++++++-------------- package.json | 6 +++--- 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5e222f060..f73c6cf26 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-projects.pages.111 # 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 diff --git a/docs/home-page.md b/docs/home-page.md index 457cfbabc..3539e181e 100644 --- a/docs/home-page.md +++ b/docs/home-page.md @@ -16,12 +16,11 @@ Launch is coordinated with content, support articles and video, so the work sits - `home-page` (python-editor-v3, origin): the integration branch, from `main` after #1326. Merge `main` into it rather than rebasing. - `storage-foundation` → `home-page`: PR open, under review from 12 September. `FileSystem.switchStorage`, the projects database and per-project storage, session-storage migration and fallback, the review-stage clear-and-reload page. -- `projects-pages` → `storage-foundation`: stacked, local. Everything else in this document: routes, pages, import, editor chrome, header, notice, toasts, e2e. **Its CI is red until the ui releases below are pinned**, because it builds against the local `../ui` checkout through `npm run dev:link-ui`. -- `project-components` (ui repo): the shared project components in `ui-patterns` and `ConfirmDialog` moved into `@microbit/ui`. Needs review, then releases in order: `@microbit/ui` 0.5.0, then `ui-carousel` and `ui-patterns` with peers at `^0.5.0`. -- `projects-pages` (private theme package): the home page images and the `AppLogo` and `OrgLogo` header components. Merge and publish; until then the review build shows grey placeholders and a text wordmark. +- `projects-pages` → `storage-foundation`: stacked, local. Everything else in this document: routes, pages, import, editor chrome, header, notice, toasts, e2e. Pins the released `@microbit/ui` 0.5.0, `ui-carousel` 0.4.0 and `ui-patterns` 0.7.0; the `dev:link-ui` and `dev:link-theme` scripts are no longer in use here. +- `projects-pages` (private theme package): the home page images, the `AppLogo` and `OrgLogo` header components and the footer's `copyrightHolder`. A temporary branch build, `0.0.0-projects.pages.111`, is pinned in `build.yml`; merge and publish a release before landing. - `shared-project-components` (ml-trainer): consumes the shared components. Bump its pins to the releases before merging. -Order once the storage PR is in: release the ui packages, pin them in `projects-pages` and drop the link, publish the theme package, review `projects-pages`, land on `home-page`. At launch merge `home-page` to `main`; the database name includes the base path, so beta starts empty. +Order once the storage PR is in: publish the theme package and pin the release, review `projects-pages`, land on `home-page`. At launch merge `home-page` to `main`; the database name includes the base path, so beta starts empty. ## 3. Reviewing and running it diff --git a/package-lock.json b/package-lock.json index fe479e1a7..4722443e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,9 @@ "@microbit/microbit-connection": "^1.0.0", "@microbit/microbit-fs": "^0.10.0", "@microbit/micropython-microbit-stubs": "^1.0.0", - "@microbit/ui": "^0.4.0", - "@microbit/ui-carousel": "^0.3.0", - "@microbit/ui-patterns": "^0.6.1", + "@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", "@sentry/browser": "^10.71.0", @@ -3386,9 +3386,9 @@ "license": "SEE LICENSE IN LICENSE.md" }, "node_modules/@microbit/ui": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@microbit/ui/-/ui-0.4.2.tgz", - "integrity": "sha512-NsfDwtyxxjgwTb9OzUaThdSjax7KiPk53IFXRegTP8mCoebCu71Tipp/qgjQoHN/oG91UgJh7DbFJMVAxpo+Mg==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@microbit/ui/-/ui-0.5.0.tgz", + "integrity": "sha512-r95JncGQHl9ERABLf3yf29taBBB6r0KewQqFkT5l9lGb6h3DHPeoYcmIXB64WvadWwhMq5afWD+orxuDyqKJ/A==", "license": "MIT", "peerDependencies": { "@pandacss/dev": "^1.11.4", @@ -3400,12 +3400,12 @@ } }, "node_modules/@microbit/ui-carousel": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@microbit/ui-carousel/-/ui-carousel-0.3.0.tgz", - "integrity": "sha512-92JCrpL8KHQ9/l4J5eeG58lQfQnSvwsplD07l1k4vJnZHQNoJ10d9A/gZo96zV8Q//d57TYzXm/hdnQB63zmmQ==", + "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.4.0", + "@microbit/ui": "^0.5.0", "@pandacss/dev": "^1.11.4", "react": "^18.3.1", "react-aria": "^3.50.0", @@ -3414,15 +3414,15 @@ } }, "node_modules/@microbit/ui-patterns": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@microbit/ui-patterns/-/ui-patterns-0.6.1.tgz", - "integrity": "sha512-Imnmc9Jpg2M80/3aY3HKaK3sUHjLWLQtoqzuj/IedjT8Ozsige/mCZE5g9efwYqcT4xIxpI80gvFBSWvKvigqw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@microbit/ui-patterns/-/ui-patterns-0.7.0.tgz", + "integrity": "sha512-IFzdKUu34iV5Pw3vFod4J1Ofrkkjh+CVw3ih4UjUj8qgUBSN1ij8TvlwPXC3E83ShKfCtSwgm1raW5qf3+SRkQ==", "license": "MIT", "dependencies": { "@formatjs/intl-localematcher": "^0.8.0" }, "peerDependencies": { - "@microbit/ui": "^0.4.0", + "@microbit/ui": "^0.5.0", "@pandacss/dev": "^1.11.4", "react": "^18.3.1", "react-icons": "^4.12.0 || ^5.0.0", diff --git a/package.json b/package.json index 81f76ca41..db321125b 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,9 @@ "@microbit/microbit-connection": "^1.0.0", "@microbit/microbit-fs": "^0.10.0", "@microbit/micropython-microbit-stubs": "^1.0.0", - "@microbit/ui": "^0.4.0", - "@microbit/ui-carousel": "^0.3.0", - "@microbit/ui-patterns": "^0.6.1", + "@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", "@sentry/browser": "^10.71.0", From 15ddb54f72d6e8d3be3c1c561b991a6b1effe602 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 14 Sep 2026 14:50:45 +0100 Subject: [PATCH 28/34] Fix #project: links on a deployment under a base URL Two bugs, both only visible where the app isn't served from the root, so neither shows in local development and both are live on the review build. The editor's loader redirected to `request.url`'s pathname to drop the hash, but that carries the basename and `redirect` adds it again, landing on /v/3/v/3/project. It now strips the basename first. The home page loader only took a hash starting with #project:, while microbit.org links put it behind the v2 editor's #import: prefix. Such a link reached the home page with its program silently dropped. --- src/e2e/routing.test.ts | 23 +++++++++++++++-------- src/fs/migration.ts | 7 +++++++ src/router.tsx | 7 ++++--- src/urls.test.ts | 29 +++++++++++++++++++++++++++++ src/urls.ts | 12 ++++++++++++ 5 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 src/urls.test.ts diff --git a/src/e2e/routing.test.ts b/src/e2e/routing.test.ts index 3386cb312..4f5f14ed7 100644 --- a/src/e2e/routing.test.ts +++ b/src/e2e/routing.test.ts @@ -28,14 +28,21 @@ test.describe("routing", () => { ).toBeVisible(); }); - test("a #project: link at the root opens the program in the editor", async ({ - app, - }) => { - await app.page.goto(homeUrl({ fragment: heartMigrationFragment })); - await expect(app.page).toHaveURL(/\/project(\?|$)/); - await app.expectProjectName("Hearts"); - await app.expectEditorContainText("display.show(Image.HEART)"); - }); + // 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(/\/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, diff --git a/src/fs/migration.ts b/src/fs/migration.ts index ca96f52b1..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 => { diff --git a/src/router.tsx b/src/router.tsx index 2ed052de1..474361c17 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -11,6 +11,7 @@ import { 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"; @@ -24,6 +25,7 @@ import { editorRoutePath, iframeEditorRoutePath, legacyEditorRoutePath, + routerPathFromUrl, } from "./urls"; import ProjectDropTarget from "./project/ProjectDropTarget"; import Workbench from "./workbench/Workbench"; @@ -73,7 +75,7 @@ export const createRouter = ({ projects }: RouterOptions) => { * the program it carries goes to the editor. */ const homeLoader = () => { - if (window.location.hash.startsWith("#project:")) { + if (hasProjectLink(window.location.href)) { return redirect(createEditorUrl() + window.location.hash); } return pagesLoader(); @@ -118,8 +120,7 @@ export const createRouter = ({ projects }: RouterOptions) => { // 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. - const { pathname, search } = new URL(request.url); - return redirect(pathname + search); + return redirect(routerPathFromUrl(request.url)); } return null; }, 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 9a364c38a..eab1e82e4 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -19,6 +19,18 @@ import type { RouterState } from "./router-hooks"; 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"; From f0ebbd8cbb0dfdd6c95dfcdd09cf7509451e50ad Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 14 Sep 2026 14:51:52 +0100 Subject: [PATCH 29/34] Match page URLs against the deployment's base URL in the e2e tests The assertions took the app to be at the root, so on the review and staging deployments the home page's URL, which is the base URL without its trailing slash, matched nothing. appUrlPattern builds the expected URL from E2E_BASE_URL, as appUrl already does, and is stricter than the patterns it replaces: /projects no longer satisfies an assertion for /project. --- src/e2e/app.ts | 21 ++++++++++++++++++--- src/e2e/home-page.ts | 4 ++-- src/e2e/home.test.ts | 3 ++- src/e2e/projects-page.ts | 4 ++-- src/e2e/routing.test.ts | 8 +++++--- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/e2e/app.ts b/src/e2e/app.ts index 499adcac6..1a537462c 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -25,6 +25,10 @@ export interface BrowserDownload { // E2E_PORT points the suite at a server on another port. const baseUrl = `http://localhost:${process.env.E2E_PORT ?? "3000"}`; +// 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; @@ -820,6 +824,19 @@ export const projectsPageUrl = (options: UrlOptions = {}): string => /** * @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", @@ -838,9 +855,7 @@ export const appUrl = (path: string, options: UrlOptions = {}): string => { } 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() + diff --git a/src/e2e/home-page.ts b/src/e2e/home-page.ts index 62e2f163a..78d404598 100644 --- a/src/e2e/home-page.ts +++ b/src/e2e/home-page.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: MIT */ import { expect, Locator, Page } from "@playwright/test"; -import { getAbsoluteFilePath, homeUrl } from "./app.js"; +import { appUrlPattern, getAbsoluteFilePath, homeUrl } from "./app.js"; /** * The modal dialog rather than a menu popover, which also has the dialog @@ -98,7 +98,7 @@ export class HomePage { async expectOnPage(): Promise { await expect(this.projectsHeading).toBeVisible(); - await expect(this.page).toHaveURL(/\/(\?|$)/); + await expect(this.page).toHaveURL(appUrlPattern()); } /** Creates a project, accepting the default name unless one is given. */ diff --git a/src/e2e/home.test.ts b/src/e2e/home.test.ts index 1bdfcc931..cc4d57be5 100644 --- a/src/e2e/home.test.ts +++ b/src/e2e/home.test.ts @@ -4,6 +4,7 @@ * 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", () => { @@ -32,7 +33,7 @@ test.describe("home page", () => { await homePage.newProject("Night light"); await app.expectProjectName("Night light"); - await expect(app.page).toHaveURL(/\/project(\?|$)/); + await expect(app.page).toHaveURL(appUrlPattern("project")); await app.expectEditorContainText("from microbit import"); }); diff --git a/src/e2e/projects-page.ts b/src/e2e/projects-page.ts index 60242482d..7a85aefe9 100644 --- a/src/e2e/projects-page.ts +++ b/src/e2e/projects-page.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: MIT */ import { expect, Locator, Page } from "@playwright/test"; -import { projectsPageUrl } from "./app.js"; +import { appUrlPattern, projectsPageUrl } from "./app.js"; import { ProjectCards } from "./home-page.js"; export class ProjectsPage { @@ -31,7 +31,7 @@ export class ProjectsPage { async expectOnPage(): Promise { await expect(this.heading).toBeVisible(); - await expect(this.page).toHaveURL(/\/projects(\?|$)/); + await expect(this.page).toHaveURL(appUrlPattern("projects")); } async goHome(): Promise { diff --git a/src/e2e/routing.test.ts b/src/e2e/routing.test.ts index 4f5f14ed7..6532aeb65 100644 --- a/src/e2e/routing.test.ts +++ b/src/e2e/routing.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: MIT */ import { expect } from "@playwright/test"; -import { appUrl, homeUrl } from "./app.js"; +import { appUrl, appUrlPattern, homeUrl } from "./app.js"; import { test } from "./app-test-fixtures.js"; const heartMigrationFragment = @@ -15,7 +15,9 @@ test.describe("routing", () => { test("redirects the editor's old documentation URLs", async ({ app }) => { await app.page.goto(appUrl("reference/display")); - await expect(app.page).toHaveURL(/\/project\/reference\/display/); + await expect(app.page).toHaveURL( + appUrlPattern("project/reference/display") + ); await expect( app.page.getByRole("tab", { name: "Reference" }) ).toHaveAttribute("aria-selected", "true"); @@ -38,7 +40,7 @@ test.describe("routing", () => { app, }) => { await app.page.goto(homeUrl({ fragment })); - await expect(app.page).toHaveURL(/\/project(\?|$)/); + await expect(app.page).toHaveURL(appUrlPattern("project")); await app.expectProjectName("Hearts"); await app.expectEditorContainText("display.show(Image.HEART)"); }); From 4900d9c5a2cc2815df2a2cea3bc7efea772e0f1f Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 14 Sep 2026 14:52:33 +0100 Subject: [PATCH 30/34] Set the e2e cookies on the context, not the app fixture Tests that drive only the home or projects page never took the app fixture, so on Foundation builds they met the compliance notice. It is a modal dialog, which hides the page from the accessibility tree, and the headings they wait for did not exist. --- src/e2e/app-test-fixtures.ts | 17 ++++++++++++----- src/e2e/app.ts | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/e2e/app-test-fixtures.ts b/src/e2e/app-test-fixtures.ts index 87701c4cf..7e5077e77 100644 --- a/src/e2e/app-test-fixtures.ts +++ b/src/e2e/app-test-fixtures.ts @@ -1,5 +1,5 @@ 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"; @@ -19,15 +19,18 @@ type Options = { export const test = base.extend({ noIndexedDB: [false, { option: true }], autoGoto: [true, { option: true }], - app: async ({ page, context, noIndexedDB, autoGoto }, use) => { - const app = new App(page, context); + // 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 { @@ -39,7 +42,7 @@ export const test = base.extend({ functional: true, }) ), - url: app.baseUrl, + url: baseUrl, }, ]); if (noIndexedDB) { @@ -51,6 +54,10 @@ export const test = base.extend({ }); }); } + await use(context); + }, + app: async ({ page, context, autoGoto }, use) => { + const app = new App(page, context); if (autoGoto) { await app.goto(); } diff --git a/src/e2e/app.ts b/src/e2e/app.ts index 1a537462c..c9a4eb02a 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -23,7 +23,7 @@ export interface BrowserDownload { } // 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"}`; // 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. From 008f92b26654920fa84a733777d7252ae9f98bcc Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 14 Sep 2026 14:52:44 +0100 Subject: [PATCH 31/34] Upload the Playwright report CI actually writes The html reporter writes playwright-report/, so the upload step found nothing and failing runs left no screenshots or traces behind. --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f73c6cf26..def9063c7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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()) From ab1f54eb00e789ff743b145e7890885dc5a4c632 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 14 Sep 2026 15:24:46 +0100 Subject: [PATCH 32/34] Serve the base URL without its trailing slash from vite preview react-router renders the route at "/" as the bare basename, so the app's own home page link is /projects-pages, not /projects-pages/. The deployments serve that; vite preview, which the e2e tests run against, returned its own 404 page, so reloading the home page failed. --- vite.config.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index b9910df09..d285b16a4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -99,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"] = { @@ -233,6 +256,7 @@ export default defineConfig(({ mode }) => { }, }), viteRemoveManifestPlugin(), + basePathWithoutTrailingSlashPlugin(), ], test: unitTest, resolve: { From 82d561e42198a413f5e52ff9a99ce032664bdb78 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 14 Sep 2026 15:28:51 +0100 Subject: [PATCH 33/34] Give the OSS build a logo to click The sidebar logo links home now that there is a home page, but the default brand config left squareLogo and horizontalLogo unset. The boxes that hold them take their height from their content, so an OSS build had a zero-height link and no way to reach the home page. It also stopped the e2e tests running against a build without the brand package. --- src/deployment/default/index.tsx | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/deployment/default/index.tsx b/src/deployment/default/index.tsx index 087bf840b..6200d6203 100644 --- a/src/deployment/default/index.tsx +++ b/src/deployment/default/index.tsx @@ -23,9 +23,52 @@ const AppLogo = ({ h, color }: LogoProps) => ( ); +// 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", }); From 8d7434ae0e99afe4840b10973a6cfdf1fcdfb543 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Mon, 14 Sep 2026 15:20:41 +0000 Subject: [PATCH 34/34] Size the drop targets to their content so the home footer sits at the bottom FileDropTarget fixed its height at 100% of its parent, which suited the editor's full-height pane but sized the home page's target to one viewport regardless of the content inside it. The rows overflowed the target and the new footer was laid out at the one-viewport mark, roughly half way down the page, with the drag overlay only dimming the first screen. The target now takes its layout from the caller: the editor keeps the full height, the home and projects pages grow with their content. On tall windows the footer is pushed to the bottom of the window. --- src/common/FileDropTarget.tsx | 10 +++++++--- src/pages/HomePage.tsx | 6 +++++- src/pages/ProjectsPage.tsx | 3 +++ src/project/ProjectDropTarget.tsx | 6 +++++- 4 files changed, 20 insertions(+), 5 deletions(-) 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 && (
{ ); return ( - + { { [actions] ); return ( - + {children} );