diff --git a/package.json b/package.json index 0e9d8784..9dfabd50 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^10.0.4", "@testing-library/user-event": "^10.3.4", + "@types/file-saver": "^2.0.1", "@types/jest": "^25.2.3", "@types/jszip": "^3.1.7", "@types/node": "^12.0.0", @@ -18,6 +19,7 @@ "@types/web-bluetooth": "^0.0.6", "ace-builds": "^1.4.9", "bootstrap": "^4.5.0", + "file-saver": "^2.0.2", "jszip": "^3.4.0", "node-sass": "^4.14.1", "react": "^16.13.1", diff --git a/src/actions/editor.ts b/src/actions/editor.ts index 829382af..b1863256 100644 --- a/src/actions/editor.ts +++ b/src/actions/editor.ts @@ -6,6 +6,14 @@ export enum EditorActionType { * The current (active) editor changed. */ Current = 'editor.action.current', + /** + * Save the current file to disk. + */ + Save = 'editor.action.save', + /** + * Open a file. + */ + Open = 'editor.action.open', } export interface CurrentEditorAction extends Action { @@ -21,3 +29,31 @@ export function setEditSession( ): CurrentEditorAction { return { type: EditorActionType.Current, editSession }; } + +/** + * Action that saves the current file. + */ +export type EditorSaveAction = Action; + +/** + * Creates an action to save the current file + */ +export function save(): EditorSaveAction { + return { type: EditorActionType.Save }; +} + +/** + * Action that opens a file. + */ +export interface EditorOpenAction extends Action { + /** The data to save */ + data: ArrayBuffer; +} + +/** + * Creates an action to save a file + * @param data The file data + */ +export function open(data: ArrayBuffer): EditorOpenAction { + return { type: EditorActionType.Open, data }; +} diff --git a/src/components/LoadButton.tsx b/src/components/LoadButton.tsx new file mode 100644 index 00000000..7672c331 --- /dev/null +++ b/src/components/LoadButton.tsx @@ -0,0 +1,40 @@ +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import * as editor from '../actions/editor'; +import * as notification from '../actions/notification'; +import { RootState } from '../reducers'; +import OpenFileButton, { OpenFileButtonProps } from './OpenFileButton'; + +type StateProps = Pick; +type DispatchProps = Pick; +type OwnProps = Pick; + +const mapStateToProps = (state: RootState): StateProps => ({ + enabled: state.editor.current !== null, +}); + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onFile: (data): void => { + dispatch(editor.open(data)); + }, + onReject: (file): void => { + dispatch( + notification.add('error', `'${file.name}' is not a valid python file.`), + ); + }, +}); + +const mergeProps = ( + stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: OwnProps, +): OpenFileButtonProps => ({ + fileExtension: '.py', + tooltip: 'Load file', + icon: 'open.svg', + ...ownProps, + ...stateProps, + ...dispatchProps, +}); + +export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(OpenFileButton); diff --git a/src/components/SaveButton.tsx b/src/components/SaveButton.tsx new file mode 100644 index 00000000..9bc26601 --- /dev/null +++ b/src/components/SaveButton.tsx @@ -0,0 +1,33 @@ +import { connect } from 'react-redux'; +import { Dispatch } from 'redux'; +import * as editor from '../actions/editor'; +import { RootState } from '../reducers'; +import ActionButton, { ActionButtonProps } from './ActionButton'; + +type StateProps = Pick; +type DispatchProps = Pick; +type OwnProps = Pick; + +const mapStateToProps = (state: RootState): StateProps => ({ + enabled: state.editor.current !== null, +}); + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onAction: (): void => { + dispatch(editor.save()); + }, +}); + +const mergeProps = ( + stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: OwnProps, +): ActionButtonProps => ({ + tooltip: 'Save file', + icon: 'download.svg', + ...ownProps, + ...stateProps, + ...dispatchProps, +}); + +export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(ActionButton); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index e3848a2e..c0907887 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -3,14 +3,20 @@ import ButtonGroup from 'react-bootstrap/ButtonGroup'; import ButtonToolbar from 'react-bootstrap/ButtonToolbar'; import BluetoothButton from './BluetoothButton'; import FlashButton from './FlashButton'; +import LoadButton from './LoadButton'; import ReplButton from './ReplButton'; import RunButton from './RunButton'; +import SaveButton from './SaveButton'; import StopButton from './StopButton'; class Toolbar extends React.Component { render(): JSX.Element { return ( + + + + diff --git a/src/services/editor.ts b/src/services/editor.ts new file mode 100644 index 00000000..0bf44a03 --- /dev/null +++ b/src/services/editor.ts @@ -0,0 +1,44 @@ +import * as FileSaver from 'file-saver'; +import { Action, Dispatch } from 'redux'; +import { EditorActionType, EditorOpenAction } from '../actions/editor'; +import { RootState } from '../reducers'; +import { combineServices } from '.'; + +const decoder = new TextDecoder(); + +async function open( + action: Action, + _dispatch: Dispatch, + state: RootState, +): Promise { + if (action.type !== EditorActionType.Open) { + return; + } + // istanbul ignore next: currently, it is a bug if there is no current editor + if (state.editor.current === null) { + console.error('No current editor'); + return; + } + const text = decoder.decode((action as EditorOpenAction).data); + state.editor.current.getDocument().setValue(text); +} + +async function save( + action: Action, + _dispatch: Dispatch, + state: RootState, +): Promise { + if (action.type !== EditorActionType.Save) { + return; + } + // istanbul ignore next: currently, it is a bug if there is no current editor + if (state.editor.current === null) { + console.error('No current editor'); + return; + } + const data = state.editor.current.getDocument().getValue(); + const blob = new Blob([data], { type: 'text/x-python;charset=utf-8' }); + FileSaver.saveAs(blob, 'main.py'); +} + +export default combineServices(open, save); diff --git a/src/services/index.ts b/src/services/index.ts index c6d28c93..75a7b7f2 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,25 +1,32 @@ import { Action, Dispatch, Middleware } from 'redux'; +import { RootState } from '../reducers'; import bootloader from './bootloader'; +import editor from './editor'; -type Service = (action: Action, dispatch: Dispatch) => Promise; +type Service = (action: Action, dispatch: Dispatch, state: RootState) => Promise; -function runService(service: Service, action: Action, dispatch: Dispatch): void { - service(action, dispatch).catch((err) => +function runService( + service: Service, + action: Action, + dispatch: Dispatch, + state: RootState, +): void { + service(action, dispatch, state).catch((err) => console.log(`Unhandled exception in service: ${err}`), ); } export function combineServices(...services: Service[]): Service { - return (a, d): Promise => { - services.forEach((s) => runService(s, a, d)); + return (a, d, s): Promise => { + services.forEach((x) => runService(x, a, d, s)); return Promise.resolve(); }; } -const rootService = combineServices(bootloader); +const rootService = combineServices(bootloader, editor); const serviceMiddleware: Middleware = (store) => (next) => (action): unknown => { - runService(rootService, action, store.dispatch); + runService(rootService, action, store.dispatch, store.getState()); return next(action); }; diff --git a/yarn.lock b/yarn.lock index 6bc8f14c..ee087922 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1508,6 +1508,11 @@ resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== +"@types/file-saver@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.1.tgz#e18eb8b069e442f7b956d313f4fadd3ef887354e" + integrity sha512-g1QUuhYVVAamfCifK7oB7G3aIl4BbOyzDOqVyUfEr4tfBKrXfeH+M+Tg7HKCXSrbzxYdhyCP7z9WbKo0R2hBCw== + "@types/glob@^7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" @@ -4715,6 +4720,11 @@ file-loader@4.3.0: loader-utils "^1.2.3" schema-utils "^2.5.0" +file-saver@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.2.tgz#06d6e728a9ea2df2cce2f8d9e84dfcdc338ec17a" + integrity sha512-Wz3c3XQ5xroCxd1G8b7yL0Ehkf0TC9oYC6buPFkNnU9EnaPlifeAFCyCh+iewXTyFRcg0a6j3J7FmJsIhlhBdw== + file-selector@^0.1.12: version "0.1.12" resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.1.12.tgz#fe726547be219a787a9dcc640575a04a032b1fd0"