diff --git a/package-lock.json b/package-lock.json index d6a015462..b744ceb87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "code-mirror-experiment", + "name": "python-editor", "version": "3.0.0-local", "lockfileVersion": 1, "requires": true, diff --git a/src/editor/EditorContainer.tsx b/src/editor/EditorContainer.tsx index b84c141f4..5d5c6ff27 100644 --- a/src/editor/EditorContainer.tsx +++ b/src/editor/EditorContainer.tsx @@ -1,4 +1,4 @@ -import { useFileSystemBackedText } from "../fs/fs-hooks"; +import { useProjectFileText } from "../project/project-hooks"; import { useSettings } from "../settings/settings"; import Editor from "./codemirror/CodeMirror"; @@ -12,7 +12,7 @@ interface EditorContainerProps { */ const EditorContainer = ({ filename }: EditorContainerProps) => { const [{ fontSize, highlightCodeStructure }] = useSettings(); - const [defaultValue, onFileChange] = useFileSystemBackedText(filename); + const [defaultValue, onFileChange] = useProjectFileText(filename); return typeof defaultValue === "undefined" ? null : ( { const { name } = value; const isMainFile = name === MAIN_FILE; const prettyName = isMainFile ? `${projectName} (${name})` : name; - const downloadName = isMainFile ? `${projectName}.py` : name; - - const fs = useFileSystem(); - const actionFeedback = useActionFeedback(); - const handleDownload = useCallback(() => { - try { - const content = fs.read(name); - const blob = new Blob([content], { type: "text/x-python" }); - saveAs(blob, downloadName); - } catch (e) { - actionFeedback.unexpectedError(e); - } - }, [fs, name, actionFeedback, downloadName]); - - const handleDelete = useCallback(() => { - try { - fs.remove(name); - } catch (e) { - actionFeedback.unexpectedError(e); - } - }, [fs, name, actionFeedback]); + const actions = useProjectActions(); return ( @@ -60,14 +38,14 @@ const FileRow = ({ projectName, value, onClick }: FileRowProps) => { aria-label="Delete the file. The main Python file cannot be deleted." variant="ghost" disabled={isMainFile} - onClick={handleDelete} + onClick={() => actions.deleteFile(name)} /> } aria-label={`Download ${name}`} variant="ghost" - onClick={handleDownload} + onClick={() => actions.downloadFile(name)} /> diff --git a/src/files/FilesArea.tsx b/src/files/FilesArea.tsx index 62c2dd9ea..00682414c 100644 --- a/src/files/FilesArea.tsx +++ b/src/files/FilesArea.tsx @@ -1,6 +1,6 @@ import { List, ListItem, VStack } from "@chakra-ui/react"; -import { useProject } from "../fs/fs-hooks"; import OpenButton from "../project/OpenButton"; +import { useProject } from "../project/project-hooks"; import FileRow from "./FileRow"; interface FilesProps { diff --git a/src/fs/fs-hooks.ts b/src/fs/fs-hooks.ts index c4f9df560..ca7132f3c 100644 --- a/src/fs/fs-hooks.ts +++ b/src/fs/fs-hooks.ts @@ -1,18 +1,17 @@ -import { Text } from "@codemirror/state"; -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from "react"; -import useIsUnmounted from "../common/use-is-unmounted"; -import { EVENT_STATE, FileSystem, Project } from "./fs"; +import { createContext, useContext } from "react"; +import { FileSystem } from "./fs"; export const FileSystemContext = createContext( undefined ); +/** + * Hook exposing the file system. + * + * Most code should use the project instead of using the file system directly. + * + * @returns The file system. + */ export const useFileSystem = () => { const fs = useContext(FileSystemContext); if (!fs) { @@ -20,48 +19,3 @@ export const useFileSystem = () => { } return fs; }; - -export const useProject = (): Project => { - const fs = useFileSystem(); - const isUnmounted = useIsUnmounted(); - const [state, setState] = useState(fs.state); - useEffect(() => { - const listener = (x: any) => { - if (!isUnmounted()) { - setState(x); - } - }; - fs.on(EVENT_STATE, listener); - return () => { - fs.removeListener(EVENT_STATE, listener); - }; - }, [fs, isUnmounted]); - return state; -}; - -/** - * Reads an initial value from the file system and synchronises back to it. - */ -export const useFileSystemBackedText = ( - filename: string -): [Text | undefined, (text: Text) => void] => { - const fs = useFileSystem(); - const [initialValue, setInitialValue] = useState(); - - useEffect(() => { - const string = fs.read(filename); - setInitialValue(Text.of(string.split("\n"))); - }, [fs, filename]); - - 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); - }, - [fs, filename] - ); - - return [initialValue, handleChange]; -}; diff --git a/src/project/DeviceConnection.tsx b/src/project/DeviceConnection.tsx index 6e55afe09..3de6b0d46 100644 --- a/src/project/DeviceConnection.tsx +++ b/src/project/DeviceConnection.tsx @@ -1,13 +1,10 @@ import { HStack, Switch, Text, Tooltip } from "@chakra-ui/react"; import { useCallback } from "react"; -import Separate, { br } from "../common/Separate"; -import useActionFeedback, { - ActionFeedback, -} from "../common/use-action-feedback"; -import { ConnectionStatus, WebUSBError } from "../device/device"; -import { useConnectionStatus, useDevice } from "../device/device-hooks"; +import { ConnectionStatus } from "../device/device"; +import { useConnectionStatus } from "../device/device-hooks"; import DownloadButton from "./DownloadButton"; import FlashButton from "./FlashButton"; +import { useProjectActions } from "./project-hooks"; /** * The device connection area. @@ -18,31 +15,14 @@ import FlashButton from "./FlashButton"; const DeviceConnection = () => { const connectionStatus = useConnectionStatus(); const connected = connectionStatus === ConnectionStatus.CONNECTED; - const supported = connectionStatus !== ConnectionStatus.NOT_SUPPORTED; - const actionFeedback = useActionFeedback(); - const device = useDevice(); + const actions = useProjectActions(); const handleToggleConnected = useCallback(async () => { if (connected) { - try { - await device.disconnect(); - } catch (e) { - handleWebUSBError(actionFeedback, e); - } + await actions.disconnect(); } else { - if (!supported) { - actionFeedback.expectedError({ - title: "WebUSB not supported", - description: "Download the hex file or try Chrome or Microsoft Edge", - }); - } else { - try { - await device.connect(); - } catch (e) { - handleWebUSBError(actionFeedback, e); - } - } + await actions.connect(); } - }, [device, connected, actionFeedback, supported]); + }, [connected, actions]); const buttonWidth = "10rem"; return ( @@ -70,19 +50,4 @@ const DeviceConnection = () => { ); }; -const handleWebUSBError = (actionFeedback: ActionFeedback, e: any) => { - if (e instanceof WebUSBError) { - actionFeedback.expectedError({ - title: e.title, - description: ( - - {[e.message, e.description].filter(Boolean)} - - ), - }); - } else { - actionFeedback.unexpectedError(e); - } -}; - export default DeviceConnection; diff --git a/src/project/DownloadButton.tsx b/src/project/DownloadButton.tsx index 5f5ba114f..d31bc99b0 100644 --- a/src/project/DownloadButton.tsx +++ b/src/project/DownloadButton.tsx @@ -1,13 +1,9 @@ -import React, { useCallback } from "react"; +import { Tooltip } from "@chakra-ui/react"; import { RiDownload2Line } from "react-icons/ri"; -import useActionFeedback from "../common/use-action-feedback"; -import { DownloadData } from "../fs/fs"; -import { useFileSystem } from "../fs/fs-hooks"; -import { saveAs } from "file-saver"; import CollapsableButton, { CollapsibleButtonProps, } from "../common/CollapsibleButton"; -import { Tooltip } from "@chakra-ui/react"; +import { useProjectActions } from "./project-hooks"; interface DownloadButtonProps extends Omit {} @@ -21,31 +17,13 @@ interface DownloadButtonProps * Otherwise it's a more minor action. */ const DownloadButton = (props: DownloadButtonProps) => { - const fs = useFileSystem(); - const actionFeedback = useActionFeedback(); - const handleDownload = useCallback(async () => { - let download: DownloadData | undefined; - try { - download = await fs.toHexForDownload(); - } catch (e) { - actionFeedback.expectedError({ - title: "Failed to build the hex file", - description: e.message, - }); - return; - } - const blob = new Blob([download.intelHex], { - type: "application/octet-stream", - }); - saveAs(blob, download.filename); - }, [fs, actionFeedback]); - + const actions = useProjectActions(); return ( } - onClick={handleDownload} + onClick={actions.download} text="Download" /> diff --git a/src/project/FlashButton.tsx b/src/project/FlashButton.tsx index 8dc6e1f0c..47703dc5e 100644 --- a/src/project/FlashButton.tsx +++ b/src/project/FlashButton.tsx @@ -1,20 +1,11 @@ -import React, { useCallback, useState } from "react"; +import { Tooltip } from "@chakra-ui/react"; +import { useCallback, useState } from "react"; import { RiFlashlightFill } from "react-icons/ri"; -import Separate, { br } from "../common/Separate"; -import useActionFeedback, { - ActionFeedback, -} from "../common/use-action-feedback"; -import { ConnectionStatus, WebUSBError } from "../device/device"; -import { BoardId } from "../device/board-id"; -import { useConnectionStatus, useDevice } from "../device/device-hooks"; -import { useFileSystem } from "../fs/fs-hooks"; import CollapsableButton, { CollapsibleButtonProps, } from "../common/CollapsibleButton"; -import { Tooltip } from "@chakra-ui/react"; import FlashProgress from "./FlashProgress"; - -class HexGenerationError extends Error {} +import { useProjectActions } from "./project-hooks"; /** * Flash button. @@ -22,42 +13,11 @@ class HexGenerationError extends Error {} const FlashButton = ( props: Omit ) => { - const fs = useFileSystem(); - const actionFeedback = useActionFeedback(); - const device = useDevice(); - const status = useConnectionStatus(); + const actions = useProjectActions(); const [progress, setProgress] = useState(); - - const handleFlash = useCallback(async () => { - if (status === ConnectionStatus.NOT_SUPPORTED) { - actionFeedback.expectedError({ - title: "WebUSB not supported", - description: "Download the hex file or try Chrome or Microsoft Edge", - }); - return; - } - - const dataSource = async (boardId: BoardId) => { - try { - return await fs.toHexForFlash(boardId); - } catch (e) { - throw new HexGenerationError(e.message); - } - }; - - try { - await device.flash(dataSource, { partial: true, progress: setProgress }); - } catch (e) { - if (e instanceof HexGenerationError) { - actionFeedback.expectedError({ - title: "Failed to build the hex file", - description: e.message, - }); - } else { - handleWebUSBError(actionFeedback, e); - } - } - }, [fs, device, actionFeedback, status]); + const handleFlash = useCallback(() => { + actions.flash(setProgress); + }, [actions]); return ( <> @@ -78,19 +38,4 @@ const FlashButton = ( ); }; -const handleWebUSBError = (actionFeedback: ActionFeedback, e: any) => { - if (e instanceof WebUSBError) { - actionFeedback.expectedError({ - title: e.title, - description: ( - - {[e.message, e.description].filter(Boolean)} - - ), - }); - } else { - actionFeedback.unexpectedError(e); - } -}; - export default FlashButton; diff --git a/src/project/OpenButton.tsx b/src/project/OpenButton.tsx index 152bc171f..adfc9ec4c 100644 --- a/src/project/OpenButton.tsx +++ b/src/project/OpenButton.tsx @@ -1,7 +1,7 @@ import { Button, ButtonProps, Input } from "@chakra-ui/react"; import React, { useCallback, useRef } from "react"; import { RiFolderOpenLine } from "react-icons/ri"; -import { useProjectActions } from "./use-project-actions"; +import { useProjectActions } from "./project-hooks"; interface OpenButtonProps extends ButtonProps { text?: string; diff --git a/src/project/Project.tsx b/src/project/Project.tsx index 99b58989e..79b6fc950 100644 --- a/src/project/Project.tsx +++ b/src/project/Project.tsx @@ -1,5 +1,5 @@ -import { useProject } from "../fs/fs-hooks"; import Workbench from "../workbench/Workbench"; +import { useProject } from "./project-hooks"; const Project = () => { const { projectId } = useProject(); diff --git a/src/project/ProjectDropTarget.tsx b/src/project/ProjectDropTarget.tsx index 733fc84c0..678cf8127 100644 --- a/src/project/ProjectDropTarget.tsx +++ b/src/project/ProjectDropTarget.tsx @@ -1,6 +1,6 @@ import { BoxProps } from "@chakra-ui/layout"; import FileDropTarget from "../common/FileDropTarget"; -import { useProjectActions } from "./use-project-actions"; +import { useProjectActions } from "./project-hooks"; interface ProjectDropTargetProps extends BoxProps { children: React.ReactElement; diff --git a/src/project/ProjectNameEditable.tsx b/src/project/ProjectNameEditable.tsx index 0cb2d85ca..1ef9141ce 100644 --- a/src/project/ProjectNameEditable.tsx +++ b/src/project/ProjectNameEditable.tsx @@ -5,22 +5,22 @@ import { Flex, IconButton, Tooltip, - UseEditableReturn, + UseEditableReturn } from "@chakra-ui/react"; -import React, { useState } from "react"; +import { useState } from "react"; import { RiEdit2Line } from "react-icons/ri"; -import { useFileSystem, useProject } from "../fs/fs-hooks"; +import { useProject, useProjectActions } from "./project-hooks"; /** * A control to enable editing of the project name. */ const ProjectNameEditable = () => { - const fs = useFileSystem(); const { projectName } = useProject(); + const actions = useProjectActions(); const [keyPart, setKeyPart] = useState(0); const handleSubmit = (projectName: string) => { if (projectName.trim()) { - fs.setProjectName(projectName); + actions.setProjectName(projectName); } setKeyPart(keyPart + 1); }; diff --git a/src/project/project-actions.tsx b/src/project/project-actions.tsx new file mode 100644 index 000000000..7878b7d7d --- /dev/null +++ b/src/project/project-actions.tsx @@ -0,0 +1,226 @@ +import { saveAs } from "file-saver"; +import Separate, { br } from "../common/Separate"; +import { ActionFeedback } from "../common/use-action-feedback"; +import { BoardId } from "../device/board-id"; +import { + ConnectionStatus, + MicrobitWebUSBConnection, + WebUSBError, +} from "../device/device"; +import { DownloadData, FileSystem, MAIN_FILE } from "../fs/fs"; +import { + getFileExtension, + isPythonMicrobitModule, + readFileAsText, +} from "../fs/fs-util"; +import translation from "../translation"; + +class HexGenerationError extends Error {} + +/** + * Key actions. + * + * These actions all perform their own error handling and + * give appropriate feedback to the user if they fail. + */ +export class ProjectActions { + constructor( + private fs: FileSystem, + private device: MicrobitWebUSBConnection, + private actionFeedback: ActionFeedback + ) {} + + /** + * Connect to the device if possible, otherwise show feedback. + */ + connect = async () => { + if (this.device.status === ConnectionStatus.NOT_SUPPORTED) { + this.actionFeedback.expectedError({ + title: "WebUSB not supported", + description: "Download the hex file or try Chrome or Microsoft Edge", + }); + } else { + try { + await this.device.connect(); + } catch (e) { + this.handleWebUSBError(e); + } + } + }; + + /** + * Disconnect from the device. + */ + disconnect = async () => { + try { + await this.device.disconnect(); + } catch (e) { + this.handleWebUSBError(e); + } + }; + + /** + * Open a file. + * + * Replaces the open project for hex or regular Python files. + * Adds to or updates modules in the current project for micro:bit Python modules. + * + * @param file the file from drag and drop or an input element. + */ + open = async (file: File): Promise => { + const errorTitle = "Cannot load file"; + const extension = getFileExtension(file.name)?.toLowerCase(); + + if (extension === "py") { + const code = await readFileAsText(file); + if (!code) { + this.actionFeedback.expectedError({ + title: errorTitle, + description: "The file was empty.", + }); + } else if (isPythonMicrobitModule(code)) { + const exists = this.fs.exists(file.name); + const change = exists ? "Updated" : "Added"; + this.fs.addOrUpdateModule(file.name, code); + this.actionFeedback.success({ + title: `${change} module ${file.name}`, + }); + } else { + this.fs.replaceWithMainContents(code); + this.actionFeedback.success({ + title: "Loaded " + file.name, + }); + } + } else if (extension === "hex") { + const hex = await readFileAsText(file); + await this.fs.replaceWithHexContents(hex); + this.actionFeedback.success({ + title: "Loaded " + file.name, + }); + } else if (extension === "mpy") { + this.actionFeedback.warning({ + title: errorTitle, + description: translation.load["mpy-warning"], + }); + } else { + this.actionFeedback.warning({ + title: errorTitle, + description: translation.load["extension-warning"], + }); + } + }; + + /** + * Flash the device. + * + * @param progress Progress handler called with 0..1 then undefined. + */ + flash = async ( + progress: (value: number | undefined) => void + ): Promise => { + if (this.device.status === ConnectionStatus.NOT_SUPPORTED) { + this.actionFeedback.expectedError({ + title: "WebUSB not supported", + description: "Download the hex file or try Chrome or Microsoft Edge", + }); + return; + } + + const dataSource = async (boardId: BoardId) => { + try { + return await this.fs.toHexForFlash(boardId); + } catch (e) { + throw new HexGenerationError(e.message); + } + }; + + try { + await this.device.flash(dataSource, { partial: true, progress }); + } catch (e) { + if (e instanceof HexGenerationError) { + this.actionFeedback.expectedError({ + title: "Failed to build the hex file", + description: e.message, + }); + } else { + this.handleWebUSBError(e); + } + } + }; + + /** + * Trigger a browser download with a universal hex file. + */ + download = async () => { + let download: DownloadData | undefined; + try { + download = await this.fs.toHexForDownload(); + } catch (e) { + this.actionFeedback.expectedError({ + title: "Failed to build the hex file", + description: e.message, + }); + return; + } + const blob = new Blob([download.intelHex], { + type: "application/octet-stream", + }); + saveAs(blob, download.filename); + }; + + /** + * Download an individual file. + * + * @param filename the file to download. + */ + downloadFile = async (filename: string) => { + const projectName = this.fs.state.projectName; + 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" }); + saveAs(blob, downloadName); + } catch (e) { + this.actionFeedback.unexpectedError(e); + } + }; + + /** + * Delete a file. + * + * @param filename the file to delete. + */ + deleteFile = async (filename: string) => { + try { + this.fs.remove(filename); + } catch (e) { + this.actionFeedback.unexpectedError(e); + } + }; + + /** + * Set the project name. + * + * @param name The new name. + */ + setProjectName = async (name: string) => { + this.fs.setProjectName(name); + }; + + private handleWebUSBError(e: any) { + if (e instanceof WebUSBError) { + this.actionFeedback.expectedError({ + title: e.title, + description: ( + + {[e.message, e.description].filter(Boolean)} + + ), + }); + } else { + this.actionFeedback.unexpectedError(e); + } + } +} diff --git a/src/project/project-hooks.tsx b/src/project/project-hooks.tsx new file mode 100644 index 000000000..0463c3b68 --- /dev/null +++ b/src/project/project-hooks.tsx @@ -0,0 +1,72 @@ +import { Text } from "@codemirror/state"; +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 { useFileSystem } from "../fs/fs-hooks"; +import { ProjectActions } from "./project-actions"; + +/** + * Hook exposing the main UI actions. + */ +export const useProjectActions = (): ProjectActions => { + const fs = useFileSystem(); + const actionFeedback = useActionFeedback(); + const device = useDevice(); + const actions = useMemo( + () => new ProjectActions(fs, device, actionFeedback), + [fs, device, actionFeedback] + ); + return actions; +}; + +/** + * Hook exposing the project state. + * + * This is quite coarse-grained and might need to be split in future. + */ +export const useProject = (): Project => { + const fs = useFileSystem(); + const isUnmounted = useIsUnmounted(); + const [state, setState] = useState(fs.state); + useEffect(() => { + const listener = (x: any) => { + if (!isUnmounted()) { + setState(x); + } + }; + fs.on(EVENT_STATE, listener); + return () => { + fs.removeListener(EVENT_STATE, listener); + }; + }, [fs, isUnmounted]); + return state; +}; + +/** + * Reads an initial value from the project file system and synchronises back to it. + */ +export const useProjectFileText = ( + filename: string +): [Text | undefined, (text: Text) => void] => { + const fs = useFileSystem(); + const [initialValue, setInitialValue] = useState(); + + useEffect(() => { + const string = fs.read(filename); + setInitialValue(Text.of(string.split("\n"))); + }, [fs, filename]); + + 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); + }, + [fs, filename] + ); + + return [initialValue, handleChange]; +}; diff --git a/src/project/use-project-actions.ts b/src/project/use-project-actions.ts deleted file mode 100644 index 03dec9284..000000000 --- a/src/project/use-project-actions.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useMemo } from "react"; -import useActionFeedback from "../common/use-action-feedback"; -import { useFileSystem } from "../fs/fs-hooks"; -import { - getFileExtension, - isPythonMicrobitModule, - readFileAsText, -} from "../fs/fs-util"; -import translation from "../translation"; - -const errorTitle = "Cannot load file"; - -interface ProjectActions { - open(file: File): Promise; -} - -export const useProjectActions = (): ProjectActions => { - const fs = useFileSystem(); - const actionFeedback = useActionFeedback(); - const actions = useMemo( - () => ({ - open: async (file: File) => { - const extension = getFileExtension(file.name)?.toLowerCase(); - if (extension === "py") { - const code = await readFileAsText(file); - if (!code) { - actionFeedback.expectedError({ - title: errorTitle, - description: "The file was empty.", - }); - } else if (isPythonMicrobitModule(code)) { - const exists = fs.exists(file.name); - const change = exists ? "Updated" : "Added"; - fs.addOrUpdateModule(file.name, code); - actionFeedback.success({ - title: `${change} module ${file.name}`, - }); - } else { - fs.replaceWithMainContents(code); - actionFeedback.success({ - title: "Loaded " + file.name, - }); - } - } else if (extension === "hex") { - const hex = await readFileAsText(file); - await fs.replaceWithHexContents(hex); - actionFeedback.success({ - title: "Loaded " + file.name, - }); - } else if (extension === "mpy") { - actionFeedback.warning({ - title: errorTitle, - description: translation.load["mpy-warning"], - }); - } else { - actionFeedback.warning({ - title: errorTitle, - description: translation.load["extension-warning"], - }); - } - }, - }), - [actionFeedback, fs] - ); - return actions; -};