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
5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
65 changes: 65 additions & 0 deletions src/common/FileInputButton.tsx
Original file line number Diff line number Diff line change
@@ -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 = <RiFolderOpenLine />,
children,
...props
}: OpenButtonProps) => {
const ref = useRef<HTMLInputElement>(null);

const handleChooseFile = useCallback(() => {
ref.current && ref.current.click();
}, []);

const handleOpenFile = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<Input
data-testid={
(props as any)["data-testid"]
? (props as any)["data-testid"] + "-input"
: undefined
}
type="file"
accept={accept}
display="none"
onChange={handleOpenFile}
ref={ref}
/>
<Button leftIcon={leftIcon} onClick={handleChooseFile} {...props}>
{children}
</Button>
</>
);
};

export default FileInputButton;
68 changes: 68 additions & 0 deletions src/e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,26 @@ export class App {
})();
}

/**
* Open a file using the file chooser.
*
* @param filePath The file on disk.
*/
async open(filePath: string): Promise<void> {
await this.selectSideBar("Files");
const document = await this.document();
const openInput = await document.getByTestId("open-input");
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<void> {
const page = await this.page;
// Puppeteer doesn't have file drio support but we can use an input
Expand Down Expand Up @@ -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<void> {
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<void> {
const document = await this.document();
await document.findByText(title);
Expand All @@ -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<void> {
const document = await this.document();
const text = () =>
Expand All @@ -112,6 +152,11 @@ export class App {
});
}

/**
* Edit the project name.
*
* @param projectName The new name.
*/
async setProjectName(projectName: string): Promise<void> {
const document = await this.document();
const editButton = await document.getByRole("button", {
Expand All @@ -123,6 +168,12 @@ export class App {
await input.press("Enter");
}

/**
* Wait for the project name
*
* @param match
* @returns
*/
async findProjectName(match: string): Promise<void> {
const text = async () => {
const document = await this.document();
Expand All @@ -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<void> {
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<BrowserDownload> {
return this.waitForDownloadOnDisk(() => this.download());
}

/**
* Reload the page after clearing local storage.
*/
async reload() {
const page = await this.page;
await page.evaluate(() => {
Expand All @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions src/e2e/multiple-files.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {});
});
11 changes: 0 additions & 11 deletions src/e2e/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
5 changes: 3 additions & 2 deletions src/files/FileRow.tsx
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
6 changes: 4 additions & 2 deletions src/files/FilesArea.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<VStack alignItems="stretch" padding={2} spacing={5}>
<List>
Expand All @@ -25,7 +26,8 @@ const FilesArea = ({ onSelectedFileChanged }: FilesProps) => {
</ListItem>
))}
</List>
<OpenButton text="Open a project" variant="outline" />
<OpenButton variant="outline">Open a project</OpenButton>
<UploadButton variant="outline">Upload a file</UploadButton>
</VStack>
);
};
Expand Down
32 changes: 32 additions & 0 deletions src/fs/fs-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,38 @@ export const readFileAsText = async (file: File): Promise<string> => {
});
};

/**
* 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<Uint8Array> => {
const reader = new FileReader();
return new Promise((resolve, reject) => {
reader.onload = (e: ProgressEvent<FileReader>) => {
resolve(new Uint8Array(e.target!.result as ArrayBuffer));
};
reader.onerror = (e: ProgressEvent<FileReader>) => {
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.
*/
Expand Down
Loading