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 (
- <>
-
- }
- onClick={handleChooseFile}
- {...props}
- >
- {text}
-
- >
+
+ {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 && (
+
+ )}