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
4 changes: 3 additions & 1 deletion python-editor-next.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@
"path": "../python-editor"
}
],
"settings": {}
"settings": {
"emmet.showExpandedAbbreviation": "inMarkupAndStylesheetFilesOnly"
}
}
21 changes: 12 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import ProjectDropTarget from "./project/ProjectDropTarget";
import { LoggingContext } from "./logging/logging-hooks";
import { DefaultLogging } from "./logging/default";
import { fetchMicroPython } from "./fs/micropython";
import { DialogProvider } from "./common/use-dialogs";

const logging = new DefaultLogging();
const device = new MicrobitWebUSBConnection({ logging });
Expand All @@ -42,15 +43,17 @@ const App = () => {
return (
<ChakraProvider theme={theme}>
<LoggingContext.Provider value={logging}>
<DeviceContext.Provider value={device}>
<FileSystemContext.Provider value={fs}>
<SettingsContext.Provider value={settings}>
<ProjectDropTarget>
<Project />
</ProjectDropTarget>
</SettingsContext.Provider>
</FileSystemContext.Provider>
</DeviceContext.Provider>
<SettingsContext.Provider value={settings}>
<DialogProvider>
<DeviceContext.Provider value={device}>
<FileSystemContext.Provider value={fs}>
<ProjectDropTarget>
<Project />
</ProjectDropTarget>
</FileSystemContext.Provider>
</DeviceContext.Provider>
</DialogProvider>
</SettingsContext.Provider>
</LoggingContext.Provider>
</ChakraProvider>
);
Expand Down
71 changes: 71 additions & 0 deletions src/common/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Button } from "@chakra-ui/button";
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
} from "@chakra-ui/modal";
import { ReactNode, useRef } from "react";

export interface ConfirmDialogParameters {
header: ReactNode;
body: ReactNode;
// This could get a lot more flexible but let's start simple.
actionLabel: string;
}

export interface ConfirmDialogParametersWithActions
extends ConfirmDialogParameters {
header: ReactNode;
body: ReactNode;
actionLabel: string;
onConfirm: () => void;
onCancel: () => void;
}

export interface ConfirmDialogProps extends ConfirmDialogParametersWithActions {
isOpen: boolean;
}

/**
* Confirmation dialog.
*
* Generally not used directly. Prefer the useDialogs hook.
*/
export const ConfirmDialog = ({
header,
body,
actionLabel,
isOpen,
onConfirm,
onCancel,
}: ConfirmDialogProps) => {
const leastDestructiveRef = useRef<HTMLButtonElement>(null);
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={leastDestructiveRef}
onClose={onCancel}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{header}
</AlertDialogHeader>
<AlertDialogBody>{body}</AlertDialogBody>
<AlertDialogFooter>
<Button ref={leastDestructiveRef} onClick={onCancel}>
Cancel
</Button>
<Button colorScheme="red" onClick={onConfirm} ml={3}>
{actionLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};
57 changes: 57 additions & 0 deletions src/common/use-dialogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React, { ReactNode, useContext, useMemo, useState } from "react";
import {
ConfirmDialogParameters,
ConfirmDialogParametersWithActions,
ConfirmDialog,
} from "./ConfirmDialog";

const DialogContext = React.createContext<Dialogs | undefined>(undefined);

interface DialogProviderProps {
children: ReactNode;
}

export const DialogProvider = ({ children }: DialogProviderProps) => {
const [state, setState] = useState<
ConfirmDialogParametersWithActions | undefined
>(undefined);
const dialogs = useMemo(() => new Dialogs(setState), [setState]);
return (
<DialogContext.Provider value={dialogs}>
<>
{state && <ConfirmDialog isOpen {...state} />}
{children}
</>
</DialogContext.Provider>
);
};

export class Dialogs {
constructor(
private confirmDialogSetState: (
options: ConfirmDialogParametersWithActions | undefined
) => void
) {}

async confirm(options: ConfirmDialogParameters): Promise<boolean> {
return new Promise((_resolve) => {
const resolve = (result: boolean) => {
this.confirmDialogSetState(undefined);
_resolve(result);
};
this.confirmDialogSetState({
...options,
onCancel: () => resolve(false),
onConfirm: () => resolve(true),
});
});
}
}

export const useDialogs = () => {
const dialogs = useContext(DialogContext);
if (!dialogs) {
throw new Error("Missing provider!");
}
return dialogs;
};
5 changes: 4 additions & 1 deletion src/device/device.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import EventEmitter from "events";
import { FlashDataSource } from "../fs/fs";
import { Logging } from "../logging/logging";
import { NullLogging } from "../logging/null";
import translation from "../translation";
import { BoardId } from "./board-id";
import { DAPWrapper } from "./dap-wrapper";
Expand Down Expand Up @@ -119,7 +120,9 @@ export class MicrobitWebUSBConnection extends EventEmitter {

private logging: Logging;

constructor(options: MicrobitWebUSBConnectionOptions) {
constructor(
options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() }
) {
super();
this.logging = options.logging;
}
Expand Down
79 changes: 77 additions & 2 deletions src/e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as fsp from "fs/promises";
import * as os from "os";
import * as path from "path";
import "pptr-testing-library/extend";
import puppeteer, { Page } from "puppeteer";
import puppeteer, { ElementHandle, Page } from "puppeteer";

export interface BrowserDownload {
filename: string;
Expand Down Expand Up @@ -52,6 +52,18 @@ export class App {
await openInput.uploadFile(filePath);
}

/**
* Upload a file to the file system using the file chooser.
*
* @param filePath The file on disk.
*/
async uploadFile(filePath: string): Promise<void> {
await this.selectSideBar("Files");
const document = await this.document();
const uploadInput = await document.getByTestId("upload-input");
await uploadInput.uploadFile(filePath);
}

/**
* Open a file using drag and drop.
*
Expand Down Expand Up @@ -117,13 +129,62 @@ export class App {
await editButton.click();
}

/**
* Can switch to editing a file.
*
* For now we only support editing Python files.
*
* @param filename The name of the file in the file list.
*/
async canSwitchToEditing(filename: string): Promise<boolean> {
await this.selectSideBar("Files");
const document = await this.document();
await document.findByText(filename);
const editButton = await document.getByRole("button", {
name: "Edit " + filename,
});
return editButton && !(await isDisabled(editButton));
}

/**
* Uses the Files tab to delete a file.
*
* @param filename The filename.
*/
async deleteFile(
filename: string,
dialogChoice: string = "Delete"
): Promise<void> {
await this.selectSideBar("Files");
const document = await this.document();
const button = await document.findByRole("button", {
name: "Delete " + filename,
});
await button.click();
await document.findByRole("alert");
const dialogButton = await document.findByRole("button", {
name: dialogChoice,
});
await dialogButton.click();
}

async canDeleteFile(filename: string): Promise<boolean> {
await this.selectSideBar("Files");
const document = await this.document();
const button = await document.getByRole("button", {
name: "Delete " + filename,
});

return !(await isDisabled(button));
}

/**
* 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> {
async findAlertText(title: string, description?: string): Promise<void> {
const document = await this.document();
await document.findByText(title);
if (description) {
Expand Down Expand Up @@ -273,3 +334,17 @@ export class App {
}
}
}

/**
* Checks whether an element is disabled.
*
* @param element an element handle.
* @returns true if the element exists and is marked disabled.
*/
const isDisabled = async (element: ElementHandle<Element>) => {
if (!element) {
return false;
}
const disabled = await element.getProperty("disabled");
return disabled && (await disabled.jsonValue());
};
2 changes: 1 addition & 1 deletion src/e2e/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("Browser - download", () => {
await app.findProjectName("too-large");
await app.download();

await app.alertText(
await app.findAlertText(
"Failed to build the hex file",
"There is no storage space left."
);
Expand Down
40 changes: 35 additions & 5 deletions src/e2e/multiple-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,20 @@ describe("Browser - multiple and missing file cases", () => {
beforeEach(app.reload.bind(app));
afterAll(app.dispose.bind(app));

it("Copes with hex with no Python files", async () => {});
it("Copes with hex with no Python files", async () => {
// Probably best for this to be an error or else we
// need to cope with no Python at all to display.
await app.open("src/fs/microbit-micropython-v2.hex");

await app.findAlertText(
"Cannot load file",
"No appended code found in the hex file"
);
});

it("Prevents deleting main.py", async () => {});
it("Prevents deleting main.py", async () => {
expect(await app.canDeleteFile("main.py")).toEqual(false);
});

it("Copes with currently open file being updated (module)", async () => {
await app.open("testData/module.py");
Expand All @@ -20,9 +31,28 @@ describe("Browser - multiple and missing file cases", () => {
await app.findVisibleEditorContents(/Now with documentation/);
});

it("Copes with currently open file being deleted", async () => {});
it("Copes with currently open file being deleted", async () => {
await app.open("testData/module.py");
await app.switchToEditing("module.py");

await app.deleteFile("module.py");

it("Doesn't offer editor for non-Python file", async () => {});
await app.findVisibleEditorContents(/Hello, World/);
});

it("Shows some kind of error for non-UTF-8 main.py", async () => {});
it.only("Doesn't offer editor for non-Python file", async () => {
await app.uploadFile("testData/null.dat");

expect(await app.canSwitchToEditing("null.dat")).toEqual(false);
});

it("Muddles through if given non-UTF-8 main.py", async () => {
// We could start detect this on open but not sure it's worth it introducting the error cases.
// If we need to recreate the hex then just fill the file with 0xff.
await app.open("testData/invalid-utf-8.hex");

await app.findVisibleEditorContents(
/^����������������������������������������������������������������������������������������������������$/
);
});
});
Loading