From e09fd8713faeb027bb99ca5453fff863318a9612 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Wed, 10 Jun 2020 00:24:27 -0500 Subject: [PATCH] convert terminal from epic to saga --- package.json | 4 +- src/actions/terminal.ts | 44 +++++++++++--- src/components/Terminal.tsx | 22 +++++-- src/epics/index.ts | 3 +- src/epics/terminal.ts | 32 ---------- src/reducers/index.ts | 12 +++- src/reducers/terminal.ts | 24 ++++++++ src/sagas/index.ts | 3 +- src/sagas/terminal.test.ts | 117 ++++++++++++++++++++++++++++++++++++ src/sagas/terminal.ts | 31 ++++++++++ yarn.lock | 24 ++++++++ 11 files changed, 264 insertions(+), 52 deletions(-) delete mode 100644 src/epics/terminal.ts create mode 100644 src/reducers/terminal.ts create mode 100644 src/sagas/terminal.test.ts create mode 100644 src/sagas/terminal.ts diff --git a/package.json b/package.json index a647dfd8..6e5dad2f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@types/react-splitter-layout": "^3.0.0", "@types/redux-logger": "^3.0.8", "@types/web-bluetooth": "^0.0.6", + "@types/zen-push": "^0.1.1", "ace-builds": "^1.4.11", "file-saver": "^2.0.2", "jszip": "^3.4.0", @@ -36,7 +37,8 @@ "redux-saga": "^1.1.3", "typescript": "~3.9.5", "xterm": "^4.6.0", - "xterm-addon-fit": "^0.4.0" + "xterm-addon-fit": "^0.4.0", + "zen-push": "^0.2.1" }, "scripts": { "prepare": "mkdir -p public/static/js && cp node_modules/@pybricks/mpy-cross-v5/build/mpy-cross.wasm public/static/js/", diff --git a/src/actions/terminal.ts b/src/actions/terminal.ts index 60f450ee..f93d9dfb 100644 --- a/src/actions/terminal.ts +++ b/src/actions/terminal.ts @@ -1,27 +1,53 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020 The Pybricks Authors -import { Action } from 'redux'; +import { Action, Observable } from 'redux'; -export enum TerminalDataActionType { +export enum TerminalActionType { + /** + * Set the current data source. + */ + SetDataSource = 'terminal.action.setDataSource', /** * Send data. */ - SendData = 'terminal.data.send', + SendData = 'terminal.action.sendData', /** * Data was received. */ - ReceivedData = 'terminal.data.receive', + ReceivedData = 'terminal.action.receiveData', } -export interface TerminalDataAction extends Action { +export interface TerminalSetDataSourceAction + extends Action { + dataSource: Observable; +} + +export function setDataSource( + dataSource: Observable, +): TerminalSetDataSourceAction { + return { type: TerminalActionType.SetDataSource, dataSource }; +} + +export interface TerminalDataSendDataAction + extends Action { value: string; } -export function sendData(data: string): TerminalDataAction { - return { type: TerminalDataActionType.SendData, value: data }; +export function sendData(data: string): TerminalDataSendDataAction { + return { type: TerminalActionType.SendData, value: data }; } -export function receiveData(data: string): TerminalDataAction { - return { type: TerminalDataActionType.ReceivedData, value: data }; +export interface TerminalDataReceiveDataAction + extends Action { + value: string; } + +export function receiveData(data: string): TerminalDataReceiveDataAction { + return { type: TerminalActionType.ReceivedData, value: data }; +} + +export type TerminalDataAction = + | TerminalSetDataSourceAction + | TerminalDataSendDataAction + | TerminalDataReceiveDataAction; diff --git a/src/components/Terminal.tsx b/src/components/Terminal.tsx index fbdd9b21..8ba463a2 100644 --- a/src/components/Terminal.tsx +++ b/src/components/Terminal.tsx @@ -4,26 +4,30 @@ import { ResizeSensor } from '@blueprintjs/core'; import React from 'react'; import { connect } from 'react-redux'; -import { Subscription } from 'rxjs'; +import { Observable, Unsubscribe } from 'redux'; import { Terminal as XTerm } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import { Dispatch } from '../actions'; import { receiveData } from '../actions/terminal'; -import { terminalOutput } from '../epics/terminal'; +import { RootState } from '../reducers'; import 'xterm/css/xterm.css'; +interface StateProps { + dataSource: Observable | null; +} + interface DispatchProps { onData: (data: string) => void; } -type TerminalProps = DispatchProps; +type TerminalProps = StateProps & DispatchProps; class Terminal extends React.Component { private xterm: XTerm; private fitAddon: FitAddon; private terminalRef: React.RefObject; - private subscription?: Subscription; + private subscription?: { unsubscribe: Unsubscribe }; constructor(props: TerminalProps) { super(props); @@ -52,7 +56,9 @@ class Terminal extends React.Component { } this.xterm.open(this.terminalRef.current); this.fitAddon.fit(); - this.subscription = terminalOutput.subscribe((v) => this.xterm.write(v)); + this.subscription = this.props.dataSource?.subscribe({ + next: (d) => this.xterm.write(d), + }); } componentWillUnmount(): void { @@ -73,10 +79,14 @@ class Terminal extends React.Component { } } +const mapStateToProps = (state: RootState): StateProps => ({ + dataSource: state.terminal.dataSource, +}); + const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ onData: (d): void => { dispatch(receiveData(d)); }, }); -export default connect(null, mapDispatchToProps)(Terminal); +export default connect(mapStateToProps, mapDispatchToProps)(Terminal); diff --git a/src/epics/index.ts b/src/epics/index.ts index 5e1b04b4..03ae0e07 100644 --- a/src/epics/index.ts +++ b/src/epics/index.ts @@ -5,10 +5,9 @@ import { Epic, combineEpics } from 'redux-observable'; import { catchError } from 'rxjs/operators'; import ble from './ble'; import hub from './hub'; -import terminal from './terminal'; const rootEpic: Epic = (action$, store$, dependencies) => - combineEpics(ble, hub, terminal)(action$, store$, dependencies).pipe( + combineEpics(ble, hub)(action$, store$, dependencies).pipe( catchError((error, source) => { console.error(error); return source; diff --git a/src/epics/terminal.ts b/src/epics/terminal.ts deleted file mode 100644 index d5b6d4d7..00000000 --- a/src/epics/terminal.ts +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors - -import { AnyAction } from 'redux'; -import { Epic, combineEpics, ofType } from 'redux-observable'; -import { Subject } from 'rxjs'; -import { ignoreElements, map, tap } from 'rxjs/operators'; -import { write } from '../actions/ble'; -import { TerminalDataAction, TerminalDataActionType } from '../actions/terminal'; - -const encoder = new TextEncoder(); - -const terminalOutputSubject = new Subject(); -export const terminalOutput = terminalOutputSubject.asObservable(); - -// When terminal has focus this receives the input. All input is sent over BLE connection. -const receiveTerminalData: Epic = (action$) => - action$.pipe( - ofType(TerminalDataActionType.ReceivedData), - map((a) => write(encoder.encode(a.value))), - ); - -// Request to write to the terminal are handled by an observable rather than -// using redux state. -const sendTerminalData: Epic = (action$) => - action$.pipe( - ofType(TerminalDataActionType.SendData), - tap((a) => terminalOutputSubject.next(a.value)), - ignoreElements(), - ); - -export default combineEpics(receiveTerminalData, sendTerminalData); diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 08777cd7..4b918336 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -8,6 +8,7 @@ import editor, { EditorState } from './editor'; import hub, { HubState } from './hub'; import notification, { NotificationState } from './notification'; import status, { StatusState } from './status'; +import terminal, { TerminalState } from './terminal'; /** * Root state for redux store. @@ -19,6 +20,15 @@ export interface RootState { readonly hub: HubState; readonly notification: NotificationState; readonly status: StatusState; + readonly terminal: TerminalState; } -export default combineReducers({ bootloader, ble, editor, hub, notification, status }); +export default combineReducers({ + bootloader, + ble, + editor, + hub, + notification, + status, + terminal, +}); diff --git a/src/reducers/terminal.ts b/src/reducers/terminal.ts new file mode 100644 index 00000000..2b2c4170 --- /dev/null +++ b/src/reducers/terminal.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors + +import { Reducer } from 'react'; +import { Observable, combineReducers } from 'redux'; +import { Action } from '../actions'; +import { TerminalActionType } from '../actions/terminal'; + +type DataSource = Observable | null; + +const dataSource: Reducer = (state = null, action) => { + switch (action.type) { + case TerminalActionType.SetDataSource: + return action.dataSource; + default: + return state; + } +}; + +export interface TerminalState { + readonly dataSource: DataSource; +} + +export default combineReducers({ dataSource }); diff --git a/src/sagas/index.ts b/src/sagas/index.ts index 1ae8206e..89c7628c 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -6,8 +6,9 @@ import editor from './editor'; import flashFirmware from './flash-firmare'; import bootloader from './lwp3-bootloader'; import mpy from './mpy'; +import terminal from './terminal'; /* istanbul ignore next */ export default function* (): Generator { - yield all([bootloader(), editor(), flashFirmware(), mpy()]); + yield all([bootloader(), editor(), flashFirmware(), mpy(), terminal()]); } diff --git a/src/sagas/terminal.test.ts b/src/sagas/terminal.test.ts new file mode 100644 index 00000000..e4702ce3 --- /dev/null +++ b/src/sagas/terminal.test.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors + +import { END, MulticastChannel, Saga, Task, runSaga, stdChannel } from 'redux-saga'; +import { Action } from '../actions'; +import { BLEDataActionType, BLEDataWriteAction } from '../actions/ble'; +import { + TerminalActionType, + TerminalSetDataSourceAction, + receiveData, + sendData, +} from '../actions/terminal'; +import terminal from './terminal'; + +class AsyncSaga { + private dispatches: (Action | END)[]; + private takers: { put: (action: Action | END) => void }[]; + private channel: MulticastChannel; + private task: Task; + + public constructor(saga: Saga) { + this.dispatches = []; + this.takers = []; + this.channel = stdChannel(); + this.task = runSaga( + { channel: this.channel, dispatch: this.dispatch.bind(this) }, + saga, + ); + } + + public put(action: Action): void { + this.channel.put(action); + } + + public take(): Promise { + const next = this.dispatches.shift(); + if (next === undefined) { + // if there are no dispatches queued, then queue the taker to be + // completed later + return new Promise((resolve, reject) => { + this.takers.push({ + put: (a: Action | END): void => { + if (a.type === END.type) { + reject(); + } else { + resolve(a); + } + }, + }); + }); + } + // otherwise complete immediately + if (next.type === END.type) { + return Promise.reject(); + } + return Promise.resolve(next); + } + + public async end(): Promise { + this.task.cancel(); + await this.task.toPromise(); + if (this.dispatches.some((x) => x.type !== END.type)) { + fail(`unhandled dispatches remain: ${this.dispatches}`); + } + } + + private dispatch(action: Action | END): void { + const taker = this.takers.shift(); + if (taker === undefined) { + // if there are no takers waiting, the queue the action + this.dispatches.push(action); + } else { + // otherwise complete the promise + taker.put(action); + } + } +} + +test('Terminal data source responds to send data actions', async () => { + const saga = new AsyncSaga(terminal); + + const dataSourceAction = await saga.take(); + expect(dataSourceAction.type).toBe(TerminalActionType.SetDataSource); + + const dataSource = (dataSourceAction as TerminalSetDataSourceAction).dataSource; + const data = new Array(); + dataSource.subscribe({ next: (v) => data.push(v) }); + + saga.put(sendData('1')); + saga.put(sendData('2')); + saga.put(sendData('3')); + + expect(data.length).toBe(3); + expect(data[0]).toBe('1'); + expect(data[1]).toBe('2'); + expect(data[2]).toBe('3'); + + await saga.end(); +}); + +test('Terminal data source responds to receive data actions', async () => { + const saga = new AsyncSaga(terminal); + + // set data source is always first action so we have to take it + const dataSourceAction = await saga.take(); + expect(dataSourceAction.type).toBe(TerminalActionType.SetDataSource); + + saga.put(receiveData('test1234')); + + const action = await saga.take(); + expect(action.type).toBe(BLEDataActionType.Write); + expect((action as BLEDataWriteAction).value).toEqual( + new Uint8Array([0x74, 0x65, 0x73, 0x74, 0x31, 0x32, 0x33, 0x34]), + ); + + await saga.end(); +}); diff --git a/src/sagas/terminal.ts b/src/sagas/terminal.ts new file mode 100644 index 00000000..aa15f380 --- /dev/null +++ b/src/sagas/terminal.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020 The Pybricks Authors + +import { put, takeEvery } from 'redux-saga/effects'; +import PushStream from 'zen-push'; +import { write } from '../actions/ble'; +import { + TerminalActionType, + TerminalDataReceiveDataAction, + TerminalDataSendDataAction, + setDataSource, +} from '../actions/terminal'; + +const encoder = new TextEncoder(); +const terminalDataSource = new PushStream(); + +function* receiveTerminalData(action: TerminalDataSendDataAction): Generator { + // stdin gets piped to BLE connection + yield put(write(encoder.encode(action.value))); +} + +function sendTerminalData(action: TerminalDataReceiveDataAction): void { + // This is used to provide a data source for the Terminal component + terminalDataSource.next(action.value); +} + +export default function* (): Generator { + yield takeEvery(TerminalActionType.ReceivedData, receiveTerminalData); + yield takeEvery(TerminalActionType.SendData, sendTerminalData); + yield put(setDataSource(terminalDataSource.observable)); +} diff --git a/yarn.lock b/yarn.lock index 42e507eb..f1bbb390 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1836,6 +1836,18 @@ dependencies: "@types/yargs-parser" "*" +"@types/zen-observable@*": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" + integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== + +"@types/zen-push@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@types/zen-push/-/zen-push-0.1.1.tgz#a058a080d4488e4fa88492b2bb0c8447b5d272c0" + integrity sha512-lc6wO0vtv3LFNXj1U8cDrtB5hajXzXCWYO0cztPf5xzHKbciVPf5H5uccVLgApE1KlXb4z0X1bjvYkLwhXIQfA== + dependencies: + "@types/zen-observable" "*" + "@typescript-eslint/eslint-plugin@^2.10.0", "@typescript-eslint/eslint-plugin@^2.34.0": version "2.34.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.34.0.tgz#6f8ce8a46c7dea4a6f1d171d2bb8fbae6dac2be9" @@ -12081,3 +12093,15 @@ yargs@^13.3.0, yargs@^13.3.2: which-module "^2.0.0" y18n "^4.0.0" yargs-parser "^13.1.2" + +zen-observable@^0.7.0: + version "0.7.1" + resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.7.1.tgz#f84075c0ee085594d3566e1d6454207f126411b3" + integrity sha512-OI6VMSe0yeqaouIXtedC+F55Sr6r9ppS7+wTbSexkYdHbdt4ctTuPNXP/rwm7GTVI63YBc+EBT0b0tl7YnJLRg== + +zen-push@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/zen-push/-/zen-push-0.2.1.tgz#ddc33b90f66f9a84237d5f1893970f6be60c3c28" + integrity sha512-Qv4qvc8ZIue51B/0zmeIMxpIGDVhz4GhJALBvnKs/FRa2T7jy4Ori9wFwaHVt0zWV7MIFglKAHbgnVxVTw7U1w== + dependencies: + zen-observable "^0.7.0"