Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/common/FileDropTarget.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>) => {
const file = event.dataTransfer.files[0];
Expand All @@ -23,7 +27,7 @@ const FileDropTarget = ({ children, onFileDrop }: FileDropTargetProps) => {
event.dataTransfer.dropEffect = "copy";
}, []);
return (
<Box height="100%" onDrop={handleDrop} onDragOver={handleDragOver}>
<Box {...props} onDrop={handleDrop} onDragOver={handleDragOver}>
{children}
</Box>
);
Expand Down
13 changes: 12 additions & 1 deletion src/fs/fs-util.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { getFileExtension, isPythonMicrobitModule } from "./fs-util";
import {
generateId,
getFileExtension,
isPythonMicrobitModule,
} from "./fs-util";

describe("getFileExtension", () => {
it("gets extension", () => {
Expand Down Expand Up @@ -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);
});
});
4 changes: 4 additions & 0 deletions src/fs/fs-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
97 changes: 39 additions & 58 deletions src/fs/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -104,7 +53,7 @@ export interface DownloadData {
*/
export class FileSystem extends EventEmitter {
private initializing: Promise<void> | undefined;
private storage = new LocalStorage();
private storage: FSStorage = new FSLocalStorage();
private fs: undefined | MicropythonFsHex;
state: Project = {
files: [],
Expand Down Expand Up @@ -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();
}
Expand All @@ -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) {
Expand All @@ -171,15 +128,13 @@ export class FileSystem extends EventEmitter {

async replaceWithHexContents(hex: string): Promise<void> {
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(),
Expand All @@ -190,6 +145,32 @@ export class FileSystem extends EventEmitter {
this.notify();
}

async replaceWithMainContents(text: string): Promise<void> {
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<void> {
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) {
Expand Down
61 changes: 61 additions & 0 deletions src/fs/storage.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 8 additions & 3 deletions src/project/ProjectDropTarget.tsx
Original file line number Diff line number Diff line change
@@ -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 <FileDropTarget onFileDrop={actions.open}>{children}</FileDropTarget>;
return (
<FileDropTarget {...props} onFileDrop={actions.open}>
{children}
</FileDropTarget>
);
};

export default ProjectDropTarget;
11 changes: 6 additions & 5 deletions src/project/use-project-actions.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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") {
Expand Down