diff --git a/src/App.tsx b/src/App.tsx index 3c20e60e2..060abb5bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,10 +17,13 @@ import Project from "./project/Project"; import ProjectDropTarget from "./project/ProjectDropTarget"; import { LoggingContext } from "./logging/logging-hooks"; import { DefaultLogging } from "./logging/default"; +import { fetchMicroPython } from "./fs/micropython"; const logging = new DefaultLogging(); const device = new MicrobitWebUSBConnection({ logging }); -const fs = new FileSystem(); +const fs = new FileSystem(logging, fetchMicroPython); +// If this fails then we retry on access. +fs.initializeInBackground(); const App = () => { useEffect(() => { diff --git a/src/common/FileInputButton.tsx b/src/common/FileInputButton.tsx new file mode 100644 index 000000000..deba375af --- /dev/null +++ b/src/common/FileInputButton.tsx @@ -0,0 +1,65 @@ +import { Button, ButtonProps, Input } from "@chakra-ui/react"; +import React, { useCallback, useRef } from "react"; +import { RiFolderOpenLine } from "react-icons/ri"; + +interface OpenButtonProps extends ButtonProps { + onOpen: (file: File) => void; + /** + * File input tag accept attribute. + */ + accept?: string; +} + +/** + * File open button, with an associated input field. + */ +const FileInputButton = ({ + accept, + onOpen, + leftIcon = , + children, + ...props +}: OpenButtonProps) => { + const ref = useRef(null); + + const handleChooseFile = useCallback(() => { + ref.current && ref.current.click(); + }, []); + + const handleOpenFile = useCallback( + async (e: React.ChangeEvent) => { + const files = e.target.files; + if (files) { + const file = files.item(0); + // Clear the input so we're triggered if the user opens the same file again. + ref.current!.value = ""; + if (file) { + onOpen(file); + } + } + }, + [onOpen] + ); + + return ( + <> + + + + ); +}; + +export default FileInputButton; diff --git a/src/e2e/app.ts b/src/e2e/app.ts index afdbe1476..9f81e3130 100644 --- a/src/e2e/app.ts +++ b/src/e2e/app.ts @@ -40,6 +40,11 @@ export class App { })(); } + /** + * Open a file using the file chooser. + * + * @param filePath The file on disk. + */ async open(filePath: string): Promise { await this.selectSideBar("Files"); const document = await this.document(); @@ -47,6 +52,14 @@ export class App { await openInput.uploadFile(filePath); } + /** + * Open a file using drag and drop. + * + * This is a bit fragile and likely to break if we change the DnD DOM as + * we resort to simulating DnD events. + * + * @param filePath The file on disk. + */ async dropFile(filePath: string): Promise { const page = await this.page; // Puppeteer doesn't have file drio support but we can use an input @@ -90,6 +103,26 @@ export class App { return fileInput!.uploadFile(filePath); } + /** + * Use the Files sidebar to change the current file we're editing. + * + * @param filename The name of the file in the file list. + */ + async switchToEditing(filename: string): Promise { + await this.selectSideBar("Files"); + const document = await this.document(); + const editButton = await document.findByRole("button", { + name: "Edit " + filename, + }); + await editButton.click(); + } + + /** + * Wait for an alert, throwing if it doesn't happen. + * + * @param title The expected alert title. + * @param description The expected alert description (if any). + */ async alertText(title: string, description?: string): Promise { const document = await this.document(); await document.findByText(title); @@ -99,6 +132,13 @@ export class App { await document.findAllByRole("alert"); } + /** + * Wait for the editor contents to match the given regexp, throwing if it doesn't happen. + * + * Only the first few lines will be visible. + * + * @param match The regex. + */ async findVisibleEditorContents(match: RegExp): Promise { const document = await this.document(); const text = () => @@ -112,6 +152,11 @@ export class App { }); } + /** + * Edit the project name. + * + * @param projectName The new name. + */ async setProjectName(projectName: string): Promise { const document = await this.document(); const editButton = await document.getByRole("button", { @@ -123,6 +168,12 @@ export class App { await input.press("Enter"); } + /** + * Wait for the project name + * + * @param match + * @returns + */ async findProjectName(match: string): Promise { const text = async () => { const document = await this.document(); @@ -135,16 +186,30 @@ export class App { }); } + /** + * Trigger a download but don't wait for it to complete. + * + * Useful when the action is expected to fail. + * Otherwise see waitForDownload. + */ async download(): Promise { const document = await this.document(); const downloadButton = await document.getByText("Download"); return downloadButton.click(); } + /** + * Trigger a download and wait for it to complete. + * + * @returns Download details. + */ async waitForDownload(): Promise { return this.waitForDownloadOnDisk(() => this.download()); } + /** + * Reload the page after clearing local storage. + */ async reload() { const page = await this.page; await page.evaluate(() => { @@ -155,6 +220,9 @@ export class App { await page.goto("http://localhost:3000"); } + /** + * Clean up, including the browser and downloads temporary folder. + */ async dispose() { await fsp.rmdir(this.downloadPath, { recursive: true }); const page = await this.page; diff --git a/src/e2e/multiple-files.test.ts b/src/e2e/multiple-files.test.ts new file mode 100644 index 000000000..f3e178e56 --- /dev/null +++ b/src/e2e/multiple-files.test.ts @@ -0,0 +1,28 @@ +import { App } from "./app"; + +describe("Browser - multiple and missing file cases", () => { + const app = new App(); + beforeEach(app.reload.bind(app)); + afterAll(app.dispose.bind(app)); + + it("Copes with hex with no Python files", async () => {}); + + it("Prevents deleting main.py", async () => {}); + + it("Copes with currently open file being updated (module)", async () => { + await app.open("testData/module.py"); + await app.switchToEditing("module.py"); + await app.findVisibleEditorContents(/1.0.0/); + + await app.open("testData/updated/module.py"); + + await app.findVisibleEditorContents(/1.1.0/); + await app.findVisibleEditorContents(/Now with documentation/); + }); + + it("Copes with currently open file being deleted", async () => {}); + + it("Doesn't offer editor for non-Python file", async () => {}); + + it("Shows some kind of error for non-UTF-8 main.py", async () => {}); +}); diff --git a/src/e2e/open.test.ts b/src/e2e/open.test.ts index 08f8724dd..07638a35f 100644 --- a/src/e2e/open.test.ts +++ b/src/e2e/open.test.ts @@ -70,15 +70,4 @@ describe("Browser - open", () => { await app.open("testData/module.py"); await app.alertText("Updated module module.py"); }); - - // File system test cases to cover at a lower-level. - // I'm not so sure we should prevent the user adding large files. - it.todo( - "Shows an error when loading a file to the filesystem that is too large" - ); - it.todo("Can store the correct number of small files in the filesystem"); - it.todo("Can store one large file in the filesystem"); - - // File system test cases to cover via the Files UI when we focus on it - it.todo("Correctly loads files via the load modal"); }); diff --git a/src/files/FileRow.tsx b/src/files/FileRow.tsx index 48838b1fc..c696e4dc1 100644 --- a/src/files/FileRow.tsx +++ b/src/files/FileRow.tsx @@ -1,11 +1,12 @@ import { Button, HStack, IconButton } from "@chakra-ui/react"; import { RiDeleteBinLine, RiDownload2Line } from "react-icons/ri"; -import { File, MAIN_FILE } from "../fs/fs"; +import { MAIN_FILE } from "../fs/fs"; +import { FileVersion } from "../fs/storage"; import { useProjectActions } from "../project/project-hooks"; interface FileRowProps { projectName: string; - value: File; + value: FileVersion; onClick: () => void; } diff --git a/src/files/FilesArea.tsx b/src/files/FilesArea.tsx index 00682414c..dbdf2eb73 100644 --- a/src/files/FilesArea.tsx +++ b/src/files/FilesArea.tsx @@ -1,6 +1,7 @@ import { List, ListItem, VStack } from "@chakra-ui/react"; import OpenButton from "../project/OpenButton"; import { useProject } from "../project/project-hooks"; +import UploadButton from "../project/UploadButton"; import FileRow from "./FileRow"; interface FilesProps { @@ -11,7 +12,7 @@ interface FilesProps { * The main files area, offering access to individual files. */ const FilesArea = ({ onSelectedFileChanged }: FilesProps) => { - const { files, projectName } = useProject(); + const { files, name: projectName } = useProject(); return ( @@ -25,7 +26,8 @@ const FilesArea = ({ onSelectedFileChanged }: FilesProps) => { ))} - + Open a project + Upload a file ); }; diff --git a/src/fs/fs-util.ts b/src/fs/fs-util.ts index b38f19a33..9e526302e 100644 --- a/src/fs/fs-util.ts +++ b/src/fs/fs-util.ts @@ -23,6 +23,38 @@ export const readFileAsText = async (file: File): Promise => { }); }; +/** + * Reads file as text via a FileReader. + * + * @param file A file (e.g. from a file input or drop operation). + * @returns The a promise of text from that file. + */ +export const readFileAsUint8Array = async (file: File): Promise => { + const reader = new FileReader(); + return new Promise((resolve, reject) => { + reader.onload = (e: ProgressEvent) => { + resolve(new Uint8Array(e.target!.result as ArrayBuffer)); + }; + reader.onerror = (e: ProgressEvent) => { + const error = e.target?.error || new Error("Error reading file"); + reject(error); + }; + reader.readAsArrayBuffer(file); + }); +}; + +/** + * @param str A string assumed to be ASCII. + * @returns Corresponding bytes. + */ +export const asciiToBytes = (str: string): ArrayBuffer => { + var bytes = new Uint8Array(str.length); + for (var i = 0, strLen = str.length; i < strLen; i++) { + bytes[i] = str.charCodeAt(i); + } + return bytes.buffer; +}; + /** * Detect a module using the magic comment. */ diff --git a/src/fs/fs.test.ts b/src/fs/fs.test.ts new file mode 100644 index 000000000..9362afc22 --- /dev/null +++ b/src/fs/fs.test.ts @@ -0,0 +1,198 @@ +/** + * @jest-environment node + */ +import * as fs from "fs"; +import * as fsp from "fs/promises"; +import config from "../config"; +import { NullLogging } from "../logging/null"; +import { VersionAction, VersionedData } from "./storage"; +import { EVENT_PROJECT_UPDATED, FileSystem, MAIN_FILE, Project } from "./fs"; +import { MicroPythonSource } from "./micropython"; +import { BoardId } from "../device/board-id"; + +const hexes = Promise.all([ + fs.readFileSync("src/fs/microbit-micropython-v1.hex", { + encoding: "ascii", + }), + fs.readFileSync("src/fs/microbit-micropython-v2.hex", { + encoding: "ascii", + }), +]); + +const fsMicroPythonSource: MicroPythonSource = async () => { + const [v1, v2] = await hexes; + return [ + { + boardId: 0x9900, + hex: v1, + }, + { + boardId: 0x9003, + hex: v2, + }, + ]; +}; + +describe("Filesystem", () => { + const logging = new NullLogging(); + let ufs = new FileSystem(logging, fsMicroPythonSource); + let events: Project[] = []; + + beforeEach(() => { + events = []; + ufs = new FileSystem(logging, fsMicroPythonSource); + ufs.addListener(EVENT_PROJECT_UPDATED, events.push.bind(events)); + }); + + it("has an initial blank project", async () => { + expect(ufs.project.files).toEqual([]); + expect(ufs.project.id).toBeDefined(); + + await ufs.initialize(); + + expect(ufs.project.files).toEqual([{ name: MAIN_FILE, version: 1 }]); + }); + + it("initialize", async () => { + await ufs.initialize(); + + expect(events.length).toEqual(2); + expect(events[0].files).toEqual([{ name: MAIN_FILE, version: 1 }]); + expect(events[1].files).toEqual([{ name: MAIN_FILE, version: 1 }]); + + expect(ufs.project.files).toEqual([{ name: MAIN_FILE, version: 1 }]); + }); + + it("can check for file existence", async () => { + await ufs.initialize(); + + expect(await ufs.exists(MAIN_FILE)).toEqual(true); + expect(await ufs.exists("some other file")).toEqual(false); + }); + + it("can manage the project name", async () => { + expect(ufs.project.name).toEqual(config.defaultProjectName); + await ufs.setProjectName("test 1"); + expect(ufs.project.name).toEqual("test 1"); + + await ufs.initialize(); + + expect(ufs.project.name).toEqual("test 1"); + await ufs.setProjectName("test 2"); + expect(ufs.project.name).toEqual("test 2"); + }); + + it("can read/write files", async () => { + await ufs.write(MAIN_FILE, "content1", VersionAction.INCREMENT); + + expect(await asString(ufs.read(MAIN_FILE))).toEqual("content1"); + expect(ufs.project.files).toEqual([{ name: MAIN_FILE, version: 1 }]); + + await ufs.write(MAIN_FILE, "content2", VersionAction.MAINTAIN); + + expect(await asString(ufs.read(MAIN_FILE))).toEqual("content2"); + expect(ufs.project.files).toEqual([{ name: MAIN_FILE, version: 1 }]); + + await ufs.write(MAIN_FILE, "content3", VersionAction.INCREMENT); + + expect(await asString(ufs.read(MAIN_FILE))).toEqual("content3"); + expect(ufs.project.files).toEqual([{ name: MAIN_FILE, version: 2 }]); + }); + + it("throws error attempting to read non-existent file", async () => { + await expect(() => ufs.read("non-existent file")).rejects.toThrowError( + /No such file non-existent file/ + ); + }); + + it("can remove files", async () => { + await ufs.write(MAIN_FILE, "hey", VersionAction.INCREMENT); + expect(events[0].files).toEqual([{ name: MAIN_FILE, version: 1 }]); + + await ufs.remove(MAIN_FILE); + + expect(events[1].files).toEqual([]); + expect(await ufs.exists(MAIN_FILE)).toEqual(false); + }); + + it("can replace project with a Python file", async () => { + await ufs.initialize(); + + expect(await ufs.exists(MAIN_FILE)); + expect(await ufs.write("other.txt", "content", VersionAction.INCREMENT)); + const originalId = ufs.project.id; + + await ufs.replaceWithMainContents( + "new project name", + "new project content" + ); + + expect(await asString(ufs.read(MAIN_FILE))).toEqual("new project content"); + // "other.txt" has gone + expect(ufs.project.files).toEqual([{ name: MAIN_FILE, version: 2 }]); + expect(ufs.project.name).toEqual("new project name"); + expect(ufs.project.id === originalId).toEqual(false); + }); + + it("can replace project with a hex", async () => { + await ufs.initialize(); + + expect(await ufs.exists(MAIN_FILE)); + expect(await ufs.write("other.txt", "content", VersionAction.INCREMENT)); + const originalId = ufs.project.id; + + await ufs.replaceWithHexContents( + "new project name", + await fsp.readFile("testData/1.0.1.hex", { encoding: "ascii" }) + ); + + expect(await asString(ufs.read(MAIN_FILE))).toMatch(/PASS1/); + // "other.txt" has gone + expect(ufs.project.files).toEqual([{ name: MAIN_FILE, version: 2 }]); + expect(ufs.project.name).toEqual("new project name"); + expect(ufs.project.id === originalId).toEqual(false); + }); + + it("copes if you add new large files", async () => { + await ufs.initialize(); + const data = new Uint8Array(100_000); + data.fill(128); + await ufs.write("big.dat", data, VersionAction.INCREMENT); + + // But not if you ask for the hex. + await expect(() => ufs.toHexForDownload()).rejects.toThrow( + /There is no storage space left./ + ); + }); + + it("copes if you grow existing files beyond the limit", async () => { + await ufs.initialize(); + const data = new Uint8Array(100_000); + data.fill(128); + await ufs.write(MAIN_FILE, data, VersionAction.MAINTAIN); + + // But not if you ask for the hex. + await expect(() => ufs.toHexForDownload()).rejects.toThrow( + /There is no storage space left./ + ); + }); + + it("creates a universal hex for download", async () => { + await ufs.setProjectName("test project name"); + const data = await ufs.toHexForDownload(); + + expect(data.filename).toEqual("test project name.hex"); + expect(typeof data.intelHex).toEqual("string"); + }); + + it("creates board-specific data for flashing", async () => { + const data = await ufs.toHexForFlash(BoardId.parse("9900")); + + // This is weird! + expect(data.bytes).toBeInstanceOf(Uint8Array); + expect(data.intelHex).toBeInstanceOf(ArrayBuffer); + }); +}); + +const asString = async (f: Promise) => + new TextDecoder().decode((await f).data); diff --git a/src/fs/fs.ts b/src/fs/fs.ts index a56b1ebb9..493e8e6a0 100644 --- a/src/fs/fs.ts +++ b/src/fs/fs.ts @@ -1,20 +1,23 @@ import { getIntelHexAppendedScript, - microbitBoardId, MicropythonFsHex, } from "@microbit/microbit-fs"; import EventEmitter from "events"; +import config from "../config"; import { BoardId } from "../device/board-id"; +import { Logging } from "../logging/logging"; +import { asciiToBytes, generateId } from "./fs-util"; import initialCode from "./initial-code"; -import { generateId } from "./fs-util"; -import microPythonV1HexUrl from "./microbit-micropython-v1.hex"; -import microPythonV2HexUrl from "./microbit-micropython-v2.hex"; -import { FSLocalStorage, FSStorage } from "./storage"; +import { MicroPythonSource } from "./micropython"; +import { + FileVersion, + FSStorage, + InMemoryFSStorage, + VersionAction, + VersionedData, +} from "./storage"; -export interface File { - name: string; - size: number; -} +const commonFsSize = 20 * 1024; /** * All size-related stats will be -1 until the file system @@ -24,19 +27,20 @@ export interface Project { /** * An ID for the project. */ - projectId: string; + id: string; + /** * A user-defined name for the project. */ - projectName: string; + name: string; - files: File[]; - spaceUsed: number; - spaceRemaining: number; - space: number; + /** + * The files in the project. + */ + files: FileVersion[]; } -export const EVENT_STATE = "state"; +export const EVENT_PROJECT_UPDATED = "project_updated"; export const MAIN_FILE = "main.py"; export interface FlashData { @@ -52,31 +56,43 @@ export interface DownloadData { } /** - * A MicroPython file system. + * The MicroPython file system adapted for convienient use from the UI. + * + * For now we store contents in-memory only, but we may back this + * with localStorage or IndexDB later. + * + * We version files in a way that's designed to make UI updates simple. + * If a UI action updates a file (e.g. load from disk) then we bump its version. + * If the file is simply edited in the tool then we do not change its version + * or fire any events. This plays well with uncontrolled embeddings of + * third-party text editors. */ export class FileSystem extends EventEmitter { private initializing: Promise | undefined; - private storage: FSStorage = new FSLocalStorage(); + private storage: FSStorage = new InMemoryFSStorage(); private fs: undefined | MicropythonFsHex; - state: Project = { + project: Project = { files: [], - space: -1, - spaceRemaining: -1, - spaceUsed: -1, - projectId: generateId(), - projectName: this.storage.projectName(), + id: generateId(), + name: config.defaultProjectName, }; - constructor() { + constructor( + private logging: Logging, + private microPythonSource: MicroPythonSource + ) { super(); - // Demo code. - if (!this.storage.ls().includes(MAIN_FILE)) { - this.write(MAIN_FILE, initialCode); - } + } - // Run this async as it'll download > 1MB of MicroPython. + /** + * Run an initialization asyncrounously. + * + * If it fails, we'll handle the error and attempt reinitialization on demand. + */ + async initializeInBackground() { + // It's been observed that this can be slow after the fetch on low-end devices, + // so it might be good to move the FS work to a worker if we can't make it fast. this.initialize().catch((e) => { - // Clear the promise so we'll initialize on demand later. this.initializing = undefined; }); } @@ -87,56 +103,91 @@ export class FileSystem extends EventEmitter { } if (!this.initializing) { this.initializing = (async () => { - const fs = await createInternalFileSystem(); - this.replaceFsWithStorage(fs); + // For now we always start with this. + await this.write(MAIN_FILE, initialCode, VersionAction.INCREMENT); + + const fs = await this.createInternalFileSystem(); + await this.initializeFsFromStorage(fs); this.fs = fs; this.initializing = undefined; - this.notify(); + this.logging.log("Initialized file system"); + await this.notify(); })(); } await this.initializing; return this.fs!; } - setProjectName(projectName: string) { - this.storage.setProjectName(projectName); - this.notify(); + /** + * Update the project name. + * + * @param projectName New project name. + */ + async setProjectName(projectName: string) { + await this.storage.setProjectName(projectName); + return this.notify(); } - read(filename: string): string { + /** + * Read data from a file. + * + * @param filename The filename. + * @returns The data. See class comment for detail on the versioning. + * @throws If the file does not exist. + */ + async read(filename: string): Promise { return this.storage.read(filename); } - exists(filename: string): boolean { + /** + * Check if a file exists. + * + * @param filename The filename. + * @returns The promise of existence. + */ + async exists(filename: string): Promise { return this.storage.exists(filename); } /** * Writes the file to storage. * - * No events are fired for writes. + * Editors perform in-place writes that maintain the file version. + * Other UI actions increment the file version so that editors can be updated as required. + * + * @param filename The file to write to. + * @param content The file content. Text will be serialized as UTF-8. + * @param versionAction The file version update required. */ - write(filename: string, content: string) { - this.storage.write(filename, content); + async write( + filename: string, + content: Uint8Array | string, + versionAction: VersionAction + ) { + if (typeof content === "string") { + content = new TextEncoder().encode(content); + } + await this.storage.write(filename, content, versionAction); if (this.fs) { - // We could queue them / debounce here? Though we'd need - // to make sure to sync with it when we needed the FS to - // be accurate. - this.fs.write(filename, contentForFs(content)); + this.fs.write(filename, content); + } + if (versionAction === VersionAction.INCREMENT) { + return this.notify(); + } else { + // Nothing can have changed, don't needlessly change the identity of our file objects. } - this.notify(); } - async replaceWithHexContents(filename: string, hex: string): Promise { + async replaceWithHexContents( + projectName: string, + hex: string + ): Promise { const fs = await this.initialize(); try { fs.importFilesFromHex(hex, { overwrite: true, formatFirst: true, }); - if (fs.ls().length === 0) { - fs.create(MAIN_FILE, contentForFs("")); - } } catch (e) { const code = getIntelHexAppendedScript(hex); if (!code) { @@ -146,73 +197,55 @@ export class FileSystem extends EventEmitter { fs.write(MAIN_FILE, code); } - this.state = { - ...this.state, - projectId: generateId(), + this.project = { + ...this.project, + id: generateId(), }; - this.storage.setProjectName(filename.replace(/\.hex$/i, "")); - this.replaceStorageWithFs(); - this.notify(); + await this.storage.setProjectName(projectName); + await this.overwriteStorageWithFs(); + return this.notify(); } - async replaceWithMainContents(filename: string, text: string): Promise { - await this.initialize(); - this.storage.ls().forEach((f) => this.storage.remove(f)); - this.storage.write(MAIN_FILE, text); - this.storage.setProjectName(filename.replace(/\.py$/i, "")); - this.replaceFsWithStorage(); - this.state = { - ...this.state, + async replaceWithMainContents( + projectName: string, + text: string + ): Promise { + const fs = await this.initialize(); + fs.ls().forEach((f) => fs.remove(f)); + fs.write(MAIN_FILE, text); + await this.storage.setProjectName(projectName); + await this.overwriteStorageWithFs(); + this.project = { + ...this.project, // New project, just as if we'd loaded a hex file. - projectId: generateId(), - }; - this.notify(); - } - - async addOrUpdateFile(filename: string, text: string): Promise { - this.storage.write(filename, text); - this.replaceFsWithStorage(); - this.state = { - ...this.state, - // This is too much. We could introduce a per-file id. - projectId: generateId(), + id: generateId(), }; - this.notify(); + return this.notify(); } - remove(filename: string): void { - this.storage.remove(filename); + async remove(filename: string): Promise { + await this.storage.remove(filename); if (this.fs) { this.fs.remove(filename); } - this.notify(); + return this.notify(); } - private notify(): void { - // The real file system has size information, so prefer it when available. - const source = this.fs || this.storage; - const files = source.ls().map((name) => ({ - name, - size: this.fs ? this.fs.size(name) : -1, - })); - const spaceUsed = this.fs ? this.fs.getStorageUsed() : -1; - const spaceRemaining = this.fs ? this.fs.getStorageRemaining() : -1; - const space = this.fs ? this.fs.getStorageSize() : -1; - this.state = { - ...this.state, - projectName: this.storage.projectName(), + private async notify() { + const files = await this.storage.ls(); + this.project = { + ...this.project, + name: await this.storage.projectName(), files, - spaceUsed, - spaceRemaining, - space, }; - this.emit(EVENT_STATE, this.state); + this.logging.log(this.project); + this.emit(EVENT_PROJECT_UPDATED, this.project); } async toHexForDownload(): Promise { const fs = await this.initialize(); return { - filename: `${this.state.projectName}.hex`, + filename: `${this.project.name}.hex`, intelHex: fs.getUniversalHex(), }; } @@ -239,69 +272,35 @@ export class FileSystem extends EventEmitter { return this.fs; } - private replaceFsWithStorage(fs?: MicropythonFsHex) { - fs = fs || this.assertInitialized(); + private async initializeFsFromStorage(fs: MicropythonFsHex) { fs.ls().forEach(fs.remove.bind(fs)); - for (const filename of this.storage.ls()) { - fs.write(filename, contentForFs(this.storage.read(filename))); + for (const file of await this.storage.ls()) { + const { data } = await this.storage.read(file.name); + fs.write(file.name, data); } } - private replaceStorageWithFs() { + private async overwriteStorageWithFs() { const fs = this.assertInitialized(); - this.storage.ls().forEach(this.storage.remove.bind(this.storage)); - for (const filename of fs.ls()) { - this.storage.write(filename, fs.read(filename)); - } - } -} - -const contentForFs = (content: string) => { - // The FS library barfs on empty files, so workaround until we can discuss. - const hack = content.length === 0 ? "\n" : content; - return hack; -}; - -export const microPythonVersions = [ - { url: microPythonV1HexUrl, boardId: microbitBoardId.V1, version: "1.0.1" }, - { - url: microPythonV2HexUrl, - boardId: microbitBoardId.V2, - version: "2.0.0-beta.4", - }, -]; - -const fetchValidText = async (input: RequestInfo) => { - const response = await fetch(input); - if (response.status !== 200) { - throw new Error( - `Unexpected status: ${response.statusText} ${response.status}` + const keep = new Set(fs.ls()); + await Promise.all( + (await this.storage.ls()) + .filter((f) => !keep.has(f.name)) + .map((f) => this.storage.remove(f.name)) ); + for (const filename of Array.from(keep)) { + await this.storage.write( + filename, + fs.readBytes(filename), + VersionAction.INCREMENT + ); + } } - return response.text(); -}; - -const fetchMicroPython = async () => - Promise.all( - microPythonVersions.map(async ({ boardId, url }) => { - const hex = await fetchValidText(url); - return { boardId, hex }; - }) - ); - -const commonFsSize = 20 * 1024; -export const createInternalFileSystem = async () => { - const microPython = await fetchMicroPython(); - return new MicropythonFsHex(microPython, { - maxFsSize: commonFsSize, - }); -}; - -const asciiToBytes = (str: string): ArrayBuffer => { - var bytes = new Uint8Array(str.length); - for (var i = 0, strLen = str.length; i < strLen; i++) { - bytes[i] = str.charCodeAt(i); - } - return bytes.buffer; -}; + private createInternalFileSystem = async () => { + const microPython = await this.microPythonSource(); + return new MicropythonFsHex(microPython, { + maxFsSize: commonFsSize, + }); + }; +} diff --git a/src/fs/micropython.ts b/src/fs/micropython.ts new file mode 100644 index 000000000..81b917736 --- /dev/null +++ b/src/fs/micropython.ts @@ -0,0 +1,33 @@ +import { IntelHexWithId } from "@microbit/microbit-fs"; +import { microbitBoardId } from "@microbit/microbit-universal-hex"; +import microPythonV1HexUrl from "./microbit-micropython-v1.hex"; +import microPythonV2HexUrl from "./microbit-micropython-v2.hex"; + +export const microPythonVersions = [ + { url: microPythonV1HexUrl, boardId: microbitBoardId.V1, version: "1.0.1" }, + { + url: microPythonV2HexUrl, + boardId: microbitBoardId.V2, + version: "2.0.0-beta.4", + }, +]; + +const fetchValidText = async (input: RequestInfo) => { + const response = await fetch(input); + if (response.status !== 200) { + throw new Error( + `Unexpected status: ${response.statusText} ${response.status}` + ); + } + return response.text(); +}; + +export type MicroPythonSource = () => Promise; + +export const fetchMicroPython: MicroPythonSource = async () => + Promise.all( + microPythonVersions.map(async ({ boardId, url }) => { + const hex = await fetchValidText(url); + return { boardId, hex }; + }) + ); diff --git a/src/fs/storage.ts b/src/fs/storage.ts index 83b65204f..554cedd93 100644 --- a/src/fs/storage.ts +++ b/src/fs/storage.ts @@ -1,61 +1,100 @@ import config from "../config"; +export enum VersionAction { + /** + * Don't bump the version number. + */ + MAINTAIN, + /** + * Increment the version number. + */ + INCREMENT, +} + /** * Backing storage for the file system. * * We use this to store and restore the users program. + * + * For now we just have an in-memory implementation. */ export interface FSStorage { - ls(): string[]; - exists(filename: string): boolean; - read(filename: string): string; - write(filename: string, content: string): void; - remove(filename: string): void; - setProjectName(projectName: string): void; - projectName(): string; + ls(): Promise; + exists(filename: string): Promise; + read(filename: string): Promise; + write( + filename: string, + content: Uint8Array, + versionAction: VersionAction + ): Promise; + remove(filename: string): Promise; + setProjectName(projectName: string): Promise; + projectName(): Promise; +} + +export interface FileVersion { + name: string; + version: number; +} + +export interface VersionedData { + version: number; + data: Uint8Array; } /** - * Basic local storage implementation. - * - * Needs revisiting to consider multiple tab effects. + * Basic in-memory implementation. */ -export class FSLocalStorage implements FSStorage { - private prefix = "fs/"; +export class InMemoryFSStorage implements FSStorage { + private _projectName: string = config.defaultProjectName; + private _data: Map = new Map(); - ls() { - return Object.keys(localStorage) - .filter((n) => n.startsWith(this.prefix)) - .map((n) => n.substring(this.prefix.length)); + async ls() { + return Array.from(this._data.entries()).map(([name, value]) => ({ + version: value.version, + name, + })); } - exists(filename: string) { - return localStorage.getItem(this.prefix + filename) !== null; + async exists(filename: string) { + return this._data.has(filename); } - setProjectName(projectName: string) { - // If we moved this to a file we could also roundtrip it via the hex. - localStorage.setItem("projectName", projectName); + async setProjectName(projectName: string) { + this._projectName = projectName; } - projectName(): string { - return localStorage.getItem("projectName") || config.defaultProjectName; + async projectName(): Promise { + return this._projectName; } - read(name: string): string { - const item = localStorage.getItem(this.prefix + name); - if (typeof item !== "string") { - throw new Error(`No such file ${name}`); + async read(filename: string): Promise { + if (!(await this.exists(filename))) { + throw new Error(`No such file ${filename}`); } - return item; + return this._data.get(filename)!; } - write(name: string, content: string): void { - localStorage.setItem(this.prefix + name, content); + async write( + name: string, + content: Uint8Array, + versionAction: VersionAction + ): Promise { + const existing = this._data.get(name); + let version = existing ? existing.version : 0; + if (existing === undefined && versionAction === VersionAction.MAINTAIN) { + throw new Error(`No existing file ${name}`); + } + if (versionAction === VersionAction.INCREMENT) { + version++; + } + this._data.set(name, { data: content, version }); } - remove(name: string): void { - this.read(name); - localStorage.removeItem(this.prefix + name); + async remove(name: string): Promise { + if (!this.exists(name)) { + throw new Error(`No such file ${name}`); + } + this._data.delete(name); } } diff --git a/src/project/HelpMenu.tsx b/src/project/HelpMenu.tsx index 92a0a93f9..6891e1c66 100644 --- a/src/project/HelpMenu.tsx +++ b/src/project/HelpMenu.tsx @@ -19,7 +19,7 @@ import { import Separate, { br } from "../common/Separate"; import useActionFeedback from "../common/use-action-feedback"; import config from "../config"; -import { microPythonVersions } from "../fs/fs"; +import { microPythonVersions } from "../fs/micropython"; interface HelpMenuProps extends ThemingProps<"Menu"> { size?: ThemeTypings["components"]["Button"]["sizes"]; diff --git a/src/project/OpenButton.tsx b/src/project/OpenButton.tsx index c5c407484..5d3bc0dd2 100644 --- a/src/project/OpenButton.tsx +++ b/src/project/OpenButton.tsx @@ -1,55 +1,25 @@ -import { Button, ButtonProps, Input } from "@chakra-ui/react"; -import React, { useCallback, useRef } from "react"; -import { RiFolderOpenLine } from "react-icons/ri"; +import { ButtonProps } from "@chakra-ui/react"; +import React from "react"; +import FileInputButton from "../common/FileInputButton"; import { useProjectActions } from "./project-hooks"; -interface OpenButtonProps extends ButtonProps { - text?: string; -} +interface OpenButtonProps extends ButtonProps {} /** * Open HEX button, with an associated input field. */ -const OpenButton = ({ text = "Open", ...props }: OpenButtonProps) => { +const OpenButton = ({ children, ...props }: OpenButtonProps) => { const actions = useProjectActions(); - const ref = useRef(null); - - const handleChooseFile = useCallback(() => { - ref.current && ref.current.click(); - }, []); - - const handleOpenFile = useCallback( - async (e: React.ChangeEvent) => { - const files = e.target.files; - if (files) { - const file = files.item(0); - if (file) { - await actions.open(file); - } - } - }, - [actions] - ); - return ( - <> - - - + + {children} + ); }; diff --git a/src/project/Project.tsx b/src/project/Project.tsx index 79b6fc950..9fa01c967 100644 --- a/src/project/Project.tsx +++ b/src/project/Project.tsx @@ -2,7 +2,7 @@ import Workbench from "../workbench/Workbench"; import { useProject } from "./project-hooks"; const Project = () => { - const { projectId } = useProject(); + const { id: projectId } = useProject(); // Keep it simple by throwing away everything when changing project. return ; }; diff --git a/src/project/ProjectNameEditable.tsx b/src/project/ProjectNameEditable.tsx index f3916f60e..1045ea323 100644 --- a/src/project/ProjectNameEditable.tsx +++ b/src/project/ProjectNameEditable.tsx @@ -15,7 +15,7 @@ import { useProject, useProjectActions } from "./project-hooks"; * A control to enable editing of the project name. */ const ProjectNameEditable = () => { - const { projectName } = useProject(); + const { name: projectName } = useProject(); const actions = useProjectActions(); const [keyPart, setKeyPart] = useState(0); const handleSubmit = (projectName: string) => { diff --git a/src/project/UploadButton.tsx b/src/project/UploadButton.tsx new file mode 100644 index 000000000..514d9a59c --- /dev/null +++ b/src/project/UploadButton.tsx @@ -0,0 +1,28 @@ +import { ButtonProps } from "@chakra-ui/react"; +import React from "react"; +import { RiFileUploadLine } from "react-icons/ri"; +import FileInputButton from "../common/FileInputButton"; +import { useProjectActions } from "./project-hooks"; + +interface OpenButtonProps extends ButtonProps {} + +/** + * Upload button, with an associated input field. + * + * This adds or updates files in the file system rather than switching project. + */ +const UploadButton = ({ children, ...props }: OpenButtonProps) => { + const actions = useProjectActions(); + return ( + } + {...props} + > + {children} + + ); +}; + +export default UploadButton; diff --git a/src/project/project-actions.tsx b/src/project/project-actions.tsx index 4573c23d4..f60c7397f 100644 --- a/src/project/project-actions.tsx +++ b/src/project/project-actions.tsx @@ -12,7 +12,9 @@ import { getFileExtension, isPythonMicrobitModule, readFileAsText, + readFileAsUint8Array, } from "../fs/fs-util"; +import { VersionAction } from "../fs/storage"; import { Logging } from "../logging/logging"; import translation from "../translation"; @@ -23,6 +25,9 @@ class HexGenerationError extends Error {} * * These actions all perform their own error handling and * give appropriate feedback to the user if they fail. + * + * Functions all use arrow functions so they can be directly + * used as callbacks. */ export class ProjectActions { constructor( @@ -101,14 +106,15 @@ export class ProjectActions { description: "The file was empty.", }); } else if (isPythonMicrobitModule(code)) { - const exists = this.fs.exists(file.name); + const exists = await this.fs.exists(file.name); const change = exists ? "Updated" : "Added"; - this.fs.addOrUpdateFile(file.name, code); + await this.fs.write(file.name, code, VersionAction.INCREMENT); this.actionFeedback.success({ title: `${change} module ${file.name}`, }); } else { - this.fs.replaceWithMainContents(file.name, code); + const projectName = file.name.replace(/\.py$/i, ""); + await this.fs.replaceWithMainContents(projectName, code); loadedFeedback(); } } catch (e) { @@ -120,8 +126,9 @@ export class ProjectActions { } } else if (extension === "hex") { try { + const projectName = file.name.replace(/\.hex$/i, ""); const hex = await readFileAsText(file); - await this.fs.replaceWithHexContents(file.name, hex); + await this.fs.replaceWithHexContents(projectName, hex); loadedFeedback(); } catch (e) { console.error(e); @@ -144,6 +151,23 @@ export class ProjectActions { } }; + addOrUpdateFile = async (file: File): Promise => { + // TODO: Consider special-casing Python, modules or hex files? + // At least modules make sense here. Perhaps this should + // be the only way to add a module. + try { + const exists = await this.fs.exists(file.name); + const change = exists ? "Updated" : "Added"; + const data = await readFileAsUint8Array(file); + await this.fs.write(file.name, data, VersionAction.INCREMENT); + this.actionFeedback.success({ + title: `${change} ${file.name}`, + }); + } catch (e) { + this.actionFeedback.unexpectedError(e); + } + }; + /** * Flash the device. * @@ -220,13 +244,14 @@ export class ProjectActions { action: "download-file", }); - const projectName = this.fs.state.projectName; + const projectName = this.fs.project.name; const downloadName = filename === MAIN_FILE ? `${projectName}.py` : filename; try { - const content = this.fs.read(filename); - // For now we assume the file is Python. - const blob = new Blob([content], { type: "text/x-python" }); + const content = await this.fs.read(filename); + const blob = new Blob([content.data], { + type: "application/octet-stream", + }); saveAs(blob, downloadName); } catch (e) { this.actionFeedback.unexpectedError(e); @@ -244,7 +269,7 @@ export class ProjectActions { }); try { - this.fs.remove(filename); + await this.fs.remove(filename); } catch (e) { this.actionFeedback.unexpectedError(e); } @@ -260,7 +285,7 @@ export class ProjectActions { action: "set-project-name", }); - this.fs.setProjectName(name); + return this.fs.setProjectName(name); }; private handleWebUSBError(e: any) { diff --git a/src/project/project-hooks.tsx b/src/project/project-hooks.tsx index 6a73557a1..8398dbbe0 100644 --- a/src/project/project-hooks.tsx +++ b/src/project/project-hooks.tsx @@ -3,8 +3,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import useActionFeedback from "../common/use-action-feedback"; import useIsUnmounted from "../common/use-is-unmounted"; import { useDevice } from "../device/device-hooks"; -import { EVENT_STATE, Project } from "../fs/fs"; +import { EVENT_PROJECT_UPDATED, Project } from "../fs/fs"; import { useFileSystem } from "../fs/fs-hooks"; +import { VersionAction } from "../fs/storage"; import { useLogging } from "../logging/logging-hooks"; import { ProjectActions } from "./project-actions"; @@ -31,16 +32,16 @@ export const useProjectActions = (): ProjectActions => { export const useProject = (): Project => { const fs = useFileSystem(); const isUnmounted = useIsUnmounted(); - const [state, setState] = useState(fs.state); + const [state, setState] = useState(fs.project); useEffect(() => { const listener = (x: any) => { if (!isUnmounted()) { setState(x); } }; - fs.on(EVENT_STATE, listener); + fs.on(EVENT_PROJECT_UPDATED, listener); return () => { - fs.removeListener(EVENT_STATE, listener); + fs.removeListener(EVENT_PROJECT_UPDATED, listener); }; }, [fs, isUnmounted]); return state; @@ -53,21 +54,36 @@ export const useProjectFileText = ( filename: string ): [Text | undefined, (text: Text) => void] => { const fs = useFileSystem(); + const actionFeedback = useActionFeedback(); const [initialValue, setInitialValue] = useState(); useEffect(() => { - const string = fs.read(filename); - setInitialValue(Text.of(string.split("\n"))); - }, [fs, filename]); + const loadData = async () => { + try { + if (await fs.exists(filename)) { + const { data } = await fs.read(filename); + // If this fails we should return an error. + const text = new TextDecoder().decode(data); + setInitialValue(Text.of(text.split("\n"))); + } + } catch (e) { + actionFeedback.unexpectedError(e); + } + }; + + loadData(); + }, [fs, filename, actionFeedback]); const handleChange = useCallback( (text: Text) => { - const content = text.sliceString(0, undefined, "\n"); - // If we fill up the FS it seems to cope and error when we - // ask for a hex. - fs.write(filename, content); + try { + const content = text.sliceString(0, undefined, "\n"); + fs.write(filename, content, VersionAction.MAINTAIN); + } catch (e) { + actionFeedback.unexpectedError(e); + } }, - [fs, filename] + [fs, filename, actionFeedback] ); return [initialValue, handleChange]; diff --git a/src/workbench/LeftPanel.tsx b/src/workbench/LeftPanel.tsx index ed4747aa4..72779b11c 100644 --- a/src/workbench/LeftPanel.tsx +++ b/src/workbench/LeftPanel.tsx @@ -11,20 +11,20 @@ import { import React, { ReactNode, useMemo } from "react"; import { IconType } from "react-icons"; import { - RiFile3Line, + RiFileListLine, RiLayoutMasonryFill, RiSettings2Line, } from "react-icons/ri"; import LogoBar from "../common/LogoBar"; -import PackagesArea from "../packages/PackagesArea"; -import SettingsArea from "../settings/SettingsArea"; import FilesArea from "../files/FilesArea"; -import LeftPanelTabContent from "./LeftPanelTabContent"; +import PackagesArea from "../packages/PackagesArea"; import HelpMenu from "../project/HelpMenu"; import LanguageMenu from "../project/LanguageMenu"; +import SettingsArea from "../settings/SettingsArea"; +import LeftPanelTabContent from "./LeftPanelTabContent"; interface LeftPanelProps { - onSelectedFileChanged: (filename: string) => void; + onSelectedFileChanged: (file: string) => void; } /** @@ -43,7 +43,7 @@ const LeftPanel = ({ onSelectedFileChanged }: LeftPanelProps) => { { id: "files", title: "Files", - icon: RiFile3Line, + icon: RiFileListLine, contents: , }, { diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index 3776a744e..f09cd181c 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Bottom, BottomResizable, @@ -10,6 +10,7 @@ import { ConnectionStatus } from "../device/device"; import { useConnectionStatus } from "../device/device-hooks"; import EditorArea from "../editor/EditorArea"; import { MAIN_FILE } from "../fs/fs"; +import { useProject } from "../project/project-hooks"; import ProjectActionBar from "../project/ProjectActionBar"; import SerialArea from "../serial/SerialArea"; import LeftPanel from "./LeftPanel"; @@ -18,7 +19,16 @@ import LeftPanel from "./LeftPanel"; * The main app layout with resizable panels. */ const Workbench = () => { - const [filename, setFilename] = useState(MAIN_FILE); + const [filename, setFilename] = useState(undefined); + const { files } = useProject(); + useEffect(() => { + if (!filename && files.length > 0) { + const defaultFile = files.find((x) => x.name === MAIN_FILE) || files[0]; + setFilename(defaultFile.name); + } + }, [filename, files]); + const fileVersion = files.find((f) => f.name === filename)?.version; + const serialVisible = useConnectionStatus() === ConnectionStatus.CONNECTED; return ( // https://github.com/aeagle/react-spaces @@ -33,11 +43,13 @@ const Workbench = () => { - + {filename && ( + + )}