diff --git a/src/common/FileDropTarget.tsx b/src/common/FileDropTarget.tsx index f5405aa25..c508ee9b7 100644 --- a/src/common/FileDropTarget.tsx +++ b/src/common/FileDropTarget.tsx @@ -1,12 +1,16 @@ -import { Box } from "@chakra-ui/layout"; +import { Box, BoxProps } from "@chakra-ui/layout"; import { ReactNode, useCallback } from "react"; -interface FileDropTargetProps { +interface FileDropTargetProps extends BoxProps { children: ReactNode; onFileDrop: (file: File) => void; } -const FileDropTarget = ({ children, onFileDrop }: FileDropTargetProps) => { +const FileDropTarget = ({ + children, + onFileDrop, + ...props +}: FileDropTargetProps) => { const handleDrop = useCallback( (event: React.DragEvent) => { const file = event.dataTransfer.files[0]; @@ -23,7 +27,7 @@ const FileDropTarget = ({ children, onFileDrop }: FileDropTargetProps) => { event.dataTransfer.dropEffect = "copy"; }, []); return ( - + {children} ); diff --git a/src/fs/fs-util.test.ts b/src/fs/fs-util.test.ts index bc3f1a3ec..47231321c 100644 --- a/src/fs/fs-util.test.ts +++ b/src/fs/fs-util.test.ts @@ -1,4 +1,8 @@ -import { getFileExtension, isPythonMicrobitModule } from "./fs-util"; +import { + generateId, + getFileExtension, + isPythonMicrobitModule, +} from "./fs-util"; describe("getFileExtension", () => { it("gets extension", () => { @@ -47,3 +51,10 @@ describe("isPythonMicrobitModule", () => { expect(isPythonMicrobitModule("\n\n\n# microbit-module:")).toEqual(false); }); }); + +describe("generateId", () => { + it("returns different ids", () => { + // We don't really care much about these ids. They're just react keys at the moment. + expect(generateId() === generateId()).toEqual(false); + }); +}); diff --git a/src/fs/fs-util.ts b/src/fs/fs-util.ts index 68d1affa6..b38f19a33 100644 --- a/src/fs/fs-util.ts +++ b/src/fs/fs-util.ts @@ -33,3 +33,7 @@ export const isPythonMicrobitModule = (code: string) => { firstThreeLines.find((line) => line.indexOf("# microbit-module:") === 0) ); }; + +export const generateId = () => + Math.random().toString(36).substring(2) + + Math.random().toString(36).substring(2); diff --git a/src/fs/fs.ts b/src/fs/fs.ts index 9a48c4f2e..9db95f3bb 100644 --- a/src/fs/fs.ts +++ b/src/fs/fs.ts @@ -3,12 +3,10 @@ import EventEmitter from "events"; import config from "../config"; import { BoardId } from "../device/board-id"; import chuckADuck from "../samples/chuck-a-duck"; +import { generateId } from "./fs-util"; import microPythonV1HexUrl from "./microbit-micropython-v1.hex"; import microPythonV2HexUrl from "./microbit-micropython-v2.hex"; - -const generateId = () => - Math.random().toString(36).substring(2) + - Math.random().toString(36).substring(2); +import { FSLocalStorage, FSStorage } from "./storage"; export interface File { name: string; @@ -36,57 +34,8 @@ export interface Project { } export const EVENT_STATE = "state"; - export const MAIN_FILE = "main.py"; -interface Storage { - ls(): string[]; - read(filename: string): string; - write(filename: string, content: string): void; - remove(filename: string): void; -} - -/** - * At some point this will need to deal with multiple tabs. - * - * At the moment both tabs will overwrite each other's main.py, - * but it's even more confusing if they have other files. - */ -class LocalStorage implements Storage { - private prefix = "fs/"; - - ls() { - return Object.keys(localStorage) - .filter((n) => n.startsWith(this.prefix)) - .map((n) => n.substring(this.prefix.length)); - } - - setProjectName(projectName: string) { - localStorage.setItem("projectName", projectName); - } - - projectName(): string { - return localStorage.getItem("projectName") || config.defaultProjectName; - } - - read(name: string): string { - const item = localStorage.getItem(this.prefix + name); - if (typeof item !== "string") { - throw new Error(`No such file ${name}`); - } - return item; - } - - write(name: string, content: string): void { - localStorage.setItem(this.prefix + name, content); - } - - remove(name: string): void { - this.read(name); - localStorage.removeItem(this.prefix + name); - } -} - export interface FlashData { bytes: Uint8Array; intelHex: ArrayBuffer; @@ -104,7 +53,7 @@ export interface DownloadData { */ export class FileSystem extends EventEmitter { private initializing: Promise | undefined; - private storage = new LocalStorage(); + private storage: FSStorage = new FSLocalStorage(); private fs: undefined | MicropythonFsHex; state: Project = { files: [], @@ -149,7 +98,6 @@ export class FileSystem extends EventEmitter { } setProjectName(projectName: string) { - // Or we could put it in a special project file? this.storage.setProjectName(projectName); this.notify(); } @@ -158,6 +106,15 @@ export class FileSystem extends EventEmitter { return this.storage.read(filename); } + exists(filename: string): boolean { + return this.storage.exists(filename); + } + + /** + * Writes the file to storage. + * + * No events are fired for writes. + */ write(filename: string, content: string) { this.storage.write(filename, content); if (this.fs) { @@ -171,15 +128,13 @@ export class FileSystem extends EventEmitter { async replaceWithHexContents(hex: string): Promise { const fs = await this.initialize(); - // TODO: consider error recovery. Is it cheap to create a new fs? const files = fs.importFilesFromHex(hex, { overwrite: true, formatFirst: true, }); if (files.length === 0) { - throw new Error("The filesystem in the hex file was empty"); + fs.create(MAIN_FILE, contentForFs("")); } - this.state = { ...this.state, projectId: generateId(), @@ -190,6 +145,32 @@ export class FileSystem extends EventEmitter { this.notify(); } + async replaceWithMainContents(text: string): Promise { + await this.initialize(); + this.storage.ls().forEach((f) => this.storage.remove(f)); + this.storage.write(MAIN_FILE, text); + // For now this isn't stored, so clear it. + this.storage.setProjectName(config.defaultProjectName); + this.replaceFsWithStorage(); + this.state = { + ...this.state, + // New project, just as if we'd loaded a hex file. + projectId: generateId(), + }; + this.notify(); + } + + async addOrUpdateModule(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(), + }; + this.notify(); + } + remove(filename: string): void { this.storage.remove(filename); if (this.fs) { diff --git a/src/fs/storage.ts b/src/fs/storage.ts new file mode 100644 index 000000000..83b65204f --- /dev/null +++ b/src/fs/storage.ts @@ -0,0 +1,61 @@ +import config from "../config"; + +/** + * Backing storage for the file system. + * + * We use this to store and restore the users program. + */ +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; +} + +/** + * Basic local storage implementation. + * + * Needs revisiting to consider multiple tab effects. + */ +export class FSLocalStorage implements FSStorage { + private prefix = "fs/"; + + ls() { + return Object.keys(localStorage) + .filter((n) => n.startsWith(this.prefix)) + .map((n) => n.substring(this.prefix.length)); + } + + exists(filename: string) { + return localStorage.getItem(this.prefix + filename) !== null; + } + + setProjectName(projectName: string) { + // If we moved this to a file we could also roundtrip it via the hex. + localStorage.setItem("projectName", projectName); + } + + projectName(): string { + return localStorage.getItem("projectName") || config.defaultProjectName; + } + + read(name: string): string { + const item = localStorage.getItem(this.prefix + name); + if (typeof item !== "string") { + throw new Error(`No such file ${name}`); + } + return item; + } + + write(name: string, content: string): void { + localStorage.setItem(this.prefix + name, content); + } + + remove(name: string): void { + this.read(name); + localStorage.removeItem(this.prefix + name); + } +} diff --git a/src/project/ProjectDropTarget.tsx b/src/project/ProjectDropTarget.tsx index f176a3691..733fc84c0 100644 --- a/src/project/ProjectDropTarget.tsx +++ b/src/project/ProjectDropTarget.tsx @@ -1,13 +1,18 @@ +import { BoxProps } from "@chakra-ui/layout"; import FileDropTarget from "../common/FileDropTarget"; import { useProjectActions } from "./use-project-actions"; -interface ProjectDropTargetProps { +interface ProjectDropTargetProps extends BoxProps { children: React.ReactElement; } -const ProjectDropTarget = ({ children }: ProjectDropTargetProps) => { +const ProjectDropTarget = ({ children, ...props }: ProjectDropTargetProps) => { const actions = useProjectActions(); - return {children}; + return ( + + {children} + + ); }; export default ProjectDropTarget; diff --git a/src/project/use-project-actions.ts b/src/project/use-project-actions.ts index 863f0174e..03dec9284 100644 --- a/src/project/use-project-actions.ts +++ b/src/project/use-project-actions.ts @@ -1,6 +1,5 @@ import { useMemo } from "react"; import useActionFeedback from "../common/use-action-feedback"; -import { MAIN_FILE } from "../fs/fs"; import { useFileSystem } from "../fs/fs-hooks"; import { getFileExtension, @@ -30,14 +29,16 @@ export const useProjectActions = (): ProjectActions => { description: "The file was empty.", }); } else if (isPythonMicrobitModule(code)) { - fs.write(file.name, code); + const exists = fs.exists(file.name); + const change = exists ? "Updated" : "Added"; + fs.addOrUpdateModule(file.name, code); actionFeedback.success({ - title: "Added module " + file.name, + title: `${change} module ${file.name}`, }); } else { - fs.write(MAIN_FILE, code); + fs.replaceWithMainContents(code); actionFeedback.success({ - title: "Your program has been updated.", + title: "Loaded " + file.name, }); } } else if (extension === "hex") {