editor: don't put editor in redux store

The redux store should not have non-serializable objects like the editor
in it.
This commit is contained in:
David Lechner
2022-03-02 18:56:47 -06:00
parent fca7d0f253
commit 569217d5dc
21 changed files with 184 additions and 291 deletions
+54 -32
View File
@@ -2,10 +2,10 @@
// Copyright (c) 2020-2021 The Pybricks Authors
import { Classes } from '@blueprintjs/core';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import SplitterLayout from 'react-splitter-layout';
import Editor from '../editor/Editor';
import Editor, { EditorContext, EditorContextType, EditorType } from '../editor/Editor';
import { useSelector } from '../reducers';
import { toggleBoolean } from '../settings/actions';
import { BooleanSettingId } from '../settings/defaults';
@@ -121,10 +121,30 @@ const Docs: React.FunctionComponent = (_props) => {
);
};
const App: React.FunctionComponent = (_props) => {
type AppProps = {
/** Called when the editor is initialized. */
onEditorChanged?: (editor: EditorType) => void;
};
const App: React.VoidFunctionComponent<AppProps> = (props) => {
const darkMode = useSelector((s): boolean => s.settings.darkMode);
const showDocs = useSelector((s): boolean => s.settings.showDocs);
const [isDragging, setIsDragging] = useState(false);
const [editor, setEditor] = useState<EditorType>(null);
const editorContext = useMemo<EditorContextType>(
() => ({
editor,
setEditor: (editor) => {
setEditor(editor);
if (props.onEditorChanged) {
props.onEditorChanged(editor);
}
},
}),
[editor],
);
// darkMode class has to be applied to body element, otherwise it won't
// affect portals
@@ -139,44 +159,46 @@ const App: React.FunctionComponent = (_props) => {
}, [darkMode]);
return (
<div className="pb-app h-100 w-100 p-absolute">
<Toolbar />
<SplitterLayout
customClassName={`pb-app-body ${
showDocs ? 'pb-show-docs' : 'pb-hide-docs'
}`}
onDragStart={(): void => setIsDragging(true)}
onDragEnd={(): void => setIsDragging(false)}
percentage={true}
secondaryInitialSize={Number(
localStorage.getItem('app-docs-split') || 30,
)}
onSecondaryPaneSizeChange={(value): void =>
localStorage.setItem('app-docs-split', String(value))
}
>
<EditorContext.Provider value={editorContext}>
<div className="pb-app h-100 w-100 p-absolute">
<Toolbar />
<SplitterLayout
vertical={true}
customClassName={`pb-app-body ${
showDocs ? 'pb-show-docs' : 'pb-hide-docs'
}`}
onDragStart={(): void => setIsDragging(true)}
onDragEnd={(): void => setIsDragging(false)}
percentage={true}
secondaryInitialSize={Number(
localStorage.getItem('app-terminal-split') || 30,
localStorage.getItem('app-docs-split') || 30,
)}
onSecondaryPaneSizeChange={(value): void =>
localStorage.setItem('app-terminal-split', String(value))
localStorage.setItem('app-docs-split', String(value))
}
>
<Editor />
<div className="pb-app-terminal-padding h-100">
<Terminal />
<SplitterLayout
vertical={true}
percentage={true}
secondaryInitialSize={Number(
localStorage.getItem('app-terminal-split') || 30,
)}
onSecondaryPaneSizeChange={(value): void =>
localStorage.setItem('app-terminal-split', String(value))
}
>
<Editor />
<div className="pb-app-terminal-padding h-100">
<Terminal />
</div>
</SplitterLayout>
<div className="h-100 w-100">
{isDragging && <div className="h-100 w-100 p-absolute" />}
<Docs />
</div>
</SplitterLayout>
<div className="h-100 w-100">
{isDragging && <div className="h-100 w-100 p-absolute" />}
<Docs />
</div>
</SplitterLayout>
<StatusBar />
</div>
<StatusBar />
</div>
</EditorContext.Provider>
);
};
+26 -5
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import { Menu, MenuDivider, MenuItem } from '@blueprintjs/core';
import {
@@ -10,7 +10,7 @@ import {
import { useI18n } from '@shopify/react-i18n';
import tomorrowNightEightiesTheme from 'monaco-themes/themes/Tomorrow-Night-Eighties.json';
import xcodeTheme from 'monaco-themes/themes/Xcode_default.json';
import React, { useRef } from 'react';
import React, { createContext, useContext, useRef } from 'react';
import MonacoEditor, { monaco } from 'react-monaco-editor';
import { useDispatch } from 'react-redux';
import { IDisposable } from 'xterm';
@@ -20,7 +20,6 @@ import { useSelector } from '../reducers';
import { toggleBoolean } from '../settings/actions';
import { BooleanSettingId } from '../settings/defaults';
import { isMacOS } from '../utils/os';
import { setEditSession } from './actions';
import { EditorStringId } from './i18n';
import en from './i18n.en.json';
import * as pybricksMicroPython from './pybricksMicroPython';
@@ -28,6 +27,27 @@ import { UntitledHintContribution } from './untitledHint';
import './editor.scss';
/**
* The editor type. Null indicates no current editor.
*/
export type EditorType = monaco.editor.ICodeEditor | null;
/**
* The type for the value of EditorContext.
*/
export type EditorContextType = {
editor: EditorType;
setEditor: (editor: EditorType) => void;
};
/**
* Editor context for getting access to the current editor.
*/
export const EditorContext = createContext<EditorContextType>({
editor: null,
setEditor: () => undefined,
});
const pybricksMicroPythonId = 'pybricks-micropython';
monaco.languages.register({ id: pybricksMicroPythonId });
@@ -64,7 +84,7 @@ const xcodeId = 'xcode';
monaco.editor.defineTheme(xcodeId, xcodeTheme as monaco.editor.IStandaloneThemeData);
const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => {
const editor = useSelector((s) => s.editor.current);
const { editor } = useContext(EditorContext);
const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en });
@@ -128,6 +148,7 @@ const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => {
const Editor: React.FunctionComponent = (_props) => {
const editorRef = useRef<MonacoEditor>(null);
const dispatch = useDispatch();
const { setEditor } = useContext(EditorContext);
const darkMode = useSelector((s) => s.settings.darkMode);
@@ -204,7 +225,7 @@ const Editor: React.FunctionComponent = (_props) => {
subscriptions.forEach((s) => s.dispose()),
);
editor.focus();
dispatch(setEditSession(editor));
setEditor(editor);
}}
// REVIST: need to ensure we have exclusive access to file
onChange={(v) => dispatch(fileStorageWriteFile('main.py', v))}
+4 -4
View File
@@ -1,19 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import React from 'react';
import React, { useContext } from 'react';
import { useDispatch } from 'react-redux';
import * as notificationActions from '../notifications/actions';
import { useSelector } from '../reducers';
import OpenFileButton, { OpenFileButtonProps } from '../toolbar/OpenFileButton';
import { TooltipId } from '../toolbar/i18n';
import { EditorContext } from './Editor';
import * as editorActions from './actions';
import openIcon from './open.svg';
type OpenButtonProps = Pick<OpenFileButtonProps, 'id'>;
const OpenButton: React.FunctionComponent<OpenButtonProps> = (props) => {
const editor = useSelector((s) => s.editor.current);
const { editor } = useContext(EditorContext);
const dispatch = useDispatch();
return (
+4 -4
View File
@@ -1,19 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import React from 'react';
import React, { useContext } from 'react';
import { useDispatch } from 'react-redux';
import * as editorActions from '../editor/actions';
import { useSelector } from '../reducers';
import ActionButton, { ActionButtonProps } from '../toolbar/ActionButton';
import { TooltipId } from '../toolbar/i18n';
import { EditorContext } from './Editor';
import downloadIcon from './save.svg';
type SaveAsButtonProps = Pick<ActionButtonProps, 'id'> &
Pick<ActionButtonProps, 'keyboardShortcut'>;
const SaveAsButton: React.FunctionComponent<SaveAsButtonProps> = (props) => {
const editor = useSelector((s) => s.editor.current);
const { editor } = useContext(EditorContext);
const dispatch = useDispatch();
-23
View File
@@ -1,31 +1,8 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import { monaco } from 'react-monaco-editor';
import { createAction } from '../actions';
/**
* Requests to set the current (active) edit session.
* @param editSession The new edit session.
*/
export const setEditSession = createAction(
(editSession: monaco.editor.ICodeEditor | undefined) => ({
type: 'editor.action.setEditSession',
editSession,
}),
);
/**
* Indicates that setting the edit session has completed.
* @param editSession The new edit session.
*/
export const didSetEditSession = createAction(
(editSession: monaco.editor.ICodeEditor | undefined) => ({
type: 'editor.action.didSetEditSession',
editSession,
}),
);
/**
* Creates an action to save the current file
*/
-35
View File
@@ -1,35 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
import { monaco } from 'react-monaco-editor';
import { AnyAction } from 'redux';
import { didSetEditSession, setEditSession } from './actions';
import reducers from './reducers';
type State = ReturnType<typeof reducers>;
test('initial state', () => {
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
Object {
"current": null,
}
`);
});
test('current', () => {
const session = {} as monaco.editor.ICodeEditor;
// setEditSession doesn't change the state
expect(reducers({ current: null } as State, setEditSession(session)).current).toBe(
null,
);
// only didSetEditSession changes the state
expect(
reducers({ current: null } as State, didSetEditSession(session)).current,
).toBe(session);
expect(
reducers({ current: session } as State, didSetEditSession(undefined)).current,
).toBe(null);
});
-16
View File
@@ -1,16 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import { monaco } from 'react-monaco-editor';
import { Reducer, combineReducers } from 'redux';
import { didSetEditSession } from './actions';
const current: Reducer<monaco.editor.ICodeEditor | null> = (state = null, action) => {
if (didSetEditSession.matches(action)) {
return action.editSession || null;
}
return state;
};
export default combineReducers({ current });
+6 -69
View File
@@ -5,19 +5,7 @@ import FileSaver from 'file-saver';
import { mock } from 'jest-mock-extended';
import { monaco } from 'react-monaco-editor';
import { AsyncSaga } from '../../test';
import {
fileStorageDidInitialize,
fileStorageDidReadFile,
fileStorageReadFile,
} from '../fileStorage/actions';
import {
didFailToSaveAs,
didSaveAs,
didSetEditSession,
open,
saveAs,
setEditSession,
} from './actions';
import { didFailToSaveAs, didSaveAs, open, saveAs } from './actions';
import editor from './sagas';
jest.mock('react-monaco-editor');
@@ -25,7 +13,7 @@ jest.mock('file-saver');
test('open', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
const saga = new AsyncSaga(editor, {}, { editor: mockEditor });
const data = new Uint8Array().buffer;
saga.put(open(data));
@@ -38,7 +26,7 @@ test('open', async () => {
describe('saveAs', () => {
test('web file system api can succeed', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
const saga = new AsyncSaga(editor, {}, { editor: mockEditor });
// window.showSaveFilePicker is not defined in the test environment
// so we can't use spyOn().
@@ -67,7 +55,7 @@ describe('saveAs', () => {
test('web file system api can fail', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
const saga = new AsyncSaga(editor, {}, { editor: mockEditor });
// window.showSaveFilePicker is not defined in the test environment
// so we can't use spyOn().
@@ -94,7 +82,7 @@ describe('saveAs', () => {
test('fallback can succeed', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
const saga = new AsyncSaga(editor, {}, { editor: mockEditor });
const mockFileSaverSaveAs = jest.spyOn(FileSaver, 'saveAs');
@@ -113,7 +101,7 @@ describe('saveAs', () => {
test('fallback can fail', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
const saga = new AsyncSaga(editor, {}, { editor: mockEditor });
const testError = new Error('test error');
const mockFileSaverSaveAs = jest
@@ -135,54 +123,3 @@ describe('saveAs', () => {
mockFileSaverSaveAs.mockRestore();
});
});
describe('setEditSession', () => {
it('should wait for storage to be initialized', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, {
fileStorage: { isInitialized: false, fileNames: [] },
});
saga.put(setEditSession(mockEditor));
saga.put(fileStorageDidInitialize([]));
const action = await saga.take();
expect(action).toEqual(didSetEditSession(mockEditor));
await saga.end();
});
it('should load main.py', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, {
fileStorage: { isInitialized: true, fileNames: ['main.py'] },
});
saga.put(setEditSession(mockEditor));
const action = await saga.take();
expect(action).toEqual(fileStorageReadFile('main.py'));
saga.put(fileStorageDidReadFile('main.py', '# test file'));
const action2 = await saga.take();
expect(action2).toEqual(didSetEditSession(mockEditor));
expect(mockEditor.setValue).toHaveBeenCalled();
await saga.end();
});
it('should not raise error if main.py does not exist', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, {
fileStorage: { isInitialized: true, fileNames: [] },
});
saga.put(setEditSession(mockEditor));
const action = await saga.take();
expect(action).toEqual(didSetEditSession(mockEditor));
await saga.end();
});
});
+10 -75
View File
@@ -2,36 +2,20 @@
// Copyright (c) 2020-2022 The Pybricks Authors
import FileSaver from 'file-saver';
import {
call,
put,
race,
select,
take,
takeEvery,
takeLatest,
} from 'typed-redux-saga/macro';
import {
fileStorageDidFailToReadFile,
fileStorageDidInitialize,
fileStorageDidReadFile,
fileStorageReadFile,
} from '../fileStorage/actions';
import { RootState } from '../reducers';
import { call, getContext, put, takeEvery } from 'typed-redux-saga/macro';
import { ensureError } from '../utils';
import {
didFailToSaveAs,
didSaveAs,
didSetEditSession,
open,
saveAs,
setEditSession,
} from './actions';
import { EditorType } from './Editor';
import { didFailToSaveAs, didSaveAs, open, saveAs } from './actions';
/**
* Partial saga context type for context used in the editor sagas.
*/
export type EditorSagaContext = { editor: EditorType };
const decoder = new TextDecoder();
function* handleOpen(action: ReturnType<typeof open>): Generator {
const editor = yield* select((s: RootState) => s.editor.current);
const editor = yield* getContext<EditorType>('editor');
// istanbul ignore next: it is a bug to dispatch this action with no current editor
if (editor === null) {
@@ -44,7 +28,7 @@ function* handleOpen(action: ReturnType<typeof open>): Generator {
}
function* handleSaveAs(): Generator {
const editor = yield* select((s: RootState) => s.editor.current);
const editor = yield* getContext<EditorType>('editor');
// istanbul ignore next: it is a bug to dispatch this action with no current editor
if (editor === null) {
@@ -92,56 +76,7 @@ function* handleSaveAs(): Generator {
yield* put(didSaveAs());
}
function* handleSetEditSession(action: ReturnType<typeof setEditSession>): Generator {
if (action.editSession === undefined) {
// REVISIT: this should probably do something, but currently we don't
// expect this to happen
yield* put(didSetEditSession(action.editSession));
return;
}
// ensure storage has been initialized
const isStorageInitialized = yield* select(
(s: RootState) => s.fileStorage.isInitialized,
);
if (!isStorageInitialized) {
yield* take(fileStorageDidInitialize);
}
// TODO: get current file from state
const currentFileName = 'main.py';
const fileList = yield* select((s: RootState) => s.fileStorage.fileNames);
if (!fileList.includes(currentFileName)) {
// The file doesn't exist in storage, so don't try to open it.
yield* put(didSetEditSession(action.editSession));
return;
}
// TODO: implement locking to ensure exclusive access to file
yield* put(fileStorageReadFile(currentFileName));
const { result } = yield* race({
result: take(
fileStorageDidReadFile.when((a) => a.fileName === currentFileName),
),
error: take(
fileStorageDidFailToReadFile.when((a) => a.fileName === currentFileName),
),
});
if (result) {
action.editSession.setValue(result.fileContents);
}
yield* put(didSetEditSession(action.editSession));
}
export default function* (): Generator {
yield* takeEvery(open, handleOpen);
yield* takeEvery(saveAs, handleSaveAs);
yield* takeLatest(setEditSession, handleSetEditSession);
}
+16 -1
View File
@@ -6,8 +6,9 @@ import JSZip from 'jszip';
import localForage from 'localforage';
import { extendPrototype } from 'localforage-observable';
import { eventChannel } from 'redux-saga';
import { call, fork, put, takeEvery } from 'typed-redux-saga/macro';
import { call, fork, getContext, put, takeEvery } from 'typed-redux-saga/macro';
import Observable from 'zen-observable';
import { EditorType } from '../editor/Editor';
import { ensureError, timestamp } from '../utils';
import {
fileStorageArchiveAllFiles,
@@ -246,6 +247,20 @@ function* initialize(): Generator {
const fileNames = yield* call(() => files.keys());
// TODO: we should not be loading main.py here
// HACK: This assumes that editor is loaded before storage!
const editor = yield* getContext<EditorType>('editor');
if (editor) {
const main = yield* call(() => files.getItem<string>('main.py'));
if (main) {
editor.setValue(main);
}
} else if (process.env.NODE_ENV !== 'test') {
console.error('editor was not loaded, so main.py was not loaded');
}
yield* put(fileStorageDidInitialize(fileNames));
} catch (err) {
yield* put(fileStorageDidFailToInitialize(ensureError(err)));
+6 -5
View File
@@ -1,13 +1,15 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import {
FirmwareMetadata,
FirmwareReaderError,
FirmwareReaderErrorCode,
} from '@pybricks/firmware';
import { mock } from 'jest-mock-extended';
import JSZip from 'jszip';
import { AsyncSaga } from '../../test';
import { EditorType } from '../editor/Editor';
import {
BootloaderConnectionFailureReason,
checksumRequest,
@@ -1840,18 +1842,17 @@ describe('flashFirmware', () => {
new Response(await zip.generateAsync({ type: 'blob' })),
);
const editor = {
const editor = mock<EditorType>({
getValue: () => 'print("test")',
};
});
const saga = new AsyncSaga(
flashFirmware,
{
bootloader: { connection: BootloaderConnectionState.Disconnected },
editor: { current: editor },
settings: { flashCurrentProgram: true },
},
{ nextMessageId: createCountFunc() },
{ editor, nextMessageId: createCountFunc() },
);
// saga is triggered by this action
+13 -2
View File
@@ -25,6 +25,7 @@ import {
take,
takeEvery,
} from 'typed-redux-saga/macro';
import { EditorType } from '../editor/Editor';
import {
checksumRequest,
checksumResponse,
@@ -64,6 +65,16 @@ import {
flashFirmware,
} from './actions';
/**
* Function that returns the next unused message ID.
*/
type NextMessageIdFunc = () => number;
/**
* Partial saga context type for context used in the firmware sagas.
*/
export type FirmwareSagaContext = { nextMessageId: NextMessageIdFunc };
const firmwareZipMap = new Map<HubType, string>([
[HubType.CityHub, cityHubZip],
[HubType.TechnicHub, technicHubZip],
@@ -280,7 +291,7 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
);
if (flashCurrentProgram) {
const editor = yield* select((s: RootState) => s.editor.current);
const editor = yield* getContext<EditorType>('editor');
// istanbul ignore if: it is a bug to dispatch this action with no current editor
if (editor === null) {
@@ -303,7 +314,7 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
return;
}
const nextMessageId = yield* getContext<() => number>('nextMessageId');
const nextMessageId = yield* getContext<NextMessageIdFunc>('nextMessageId');
const infoAction = yield* put(infoRequest(nextMessageId()));
const { info } = yield* all({
+4 -3
View File
@@ -1,8 +1,9 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import React from 'react';
import React, { useContext } from 'react';
import { useDispatch } from 'react-redux';
import { EditorContext } from '../editor/Editor';
import { useSelector } from '../reducers';
import ActionButton, { ActionButtonProps } from '../toolbar/ActionButton';
import { TooltipId } from '../toolbar/i18n';
@@ -14,7 +15,7 @@ type RunButtonProps = Pick<ActionButtonProps, 'id'> &
Pick<ActionButtonProps, 'keyboardShortcut'>;
const RunButton: React.FunctionComponent<RunButtonProps> = (props) => {
const editor = useSelector((s) => s.editor.current);
const { editor } = useContext(EditorContext);
const downloadProgress = useSelector((s) => s.hub.downloadProgress);
const runtime = useSelector((s) => s.hub.runtime);
+5 -2
View File
@@ -29,8 +29,11 @@ describe('downloadAndRun', () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(
hub,
{ editor: { current: mockEditor } },
{ nextMessageId: createCountFunc() },
{},
{
editor: mockEditor,
nextMessageId: createCountFunc(),
},
);
saga.put(downloadAndRun());
+2 -3
View File
@@ -8,7 +8,6 @@ import {
getContext,
put,
race,
select,
take,
takeEvery,
} from 'typed-redux-saga/macro';
@@ -20,8 +19,8 @@ import {
sendStopUserProgramCommand,
} from '../ble-pybricks-service/actions';
import { didConnect } from '../ble/actions';
import { EditorType } from '../editor/Editor';
import { compile, didCompile, didFailToCompile } from '../mpy/actions';
import { RootState } from '../reducers';
import { defined } from '../utils';
import { xor8 } from '../utils/math';
import {
@@ -48,7 +47,7 @@ function* waitForWrite(id: number): SagaGenerator<{
}
function* handleDownloadAndRun(): Generator {
const editor = yield* select((s: RootState) => s.editor.current);
const editor = yield* getContext<EditorType>('editor');
// istanbul ignore next: it is a bug to dispatch this action with no current editor
if (editor === null) {
+7 -4
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import { I18nContext } from '@shopify/react-i18n';
import React from 'react';
@@ -15,7 +15,7 @@ import { i18nManager } from './i18n';
import * as I18nToaster from './notifications/I18nToaster';
import { rootReducer } from './reducers';
import reportWebVitals from './reportWebVitals';
import rootSaga from './sagas';
import rootSaga, { RootSagaContext } from './sagas';
import { didSucceed, didUpdate } from './service-worker/actions';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import { defaultTerminalContext } from './terminal/TerminalContext';
@@ -24,8 +24,9 @@ import { createCountFunc } from './utils/iter';
const toaster = I18nToaster.create(i18nManager);
const sagaMiddleware = createSagaMiddleware({
const sagaMiddleware = createSagaMiddleware<RootSagaContext>({
context: {
editor: null,
nextMessageId: createCountFunc(),
notification: { toaster },
terminal: defaultTerminalContext,
@@ -52,7 +53,9 @@ ReactDOM.render(
<Provider store={store}>
<I18nContext.Provider value={i18nManager}>
<ViewHeightSensor />
<App />
<App
onEditorChanged={(editor) => sagaMiddleware.setContext({ editor })}
/>
</I18nContext.Provider>
</Provider>
</React.StrictMode>,
+7
View File
@@ -43,6 +43,13 @@ type NotificationContext = {
toaster: IToaster;
};
/**
* Partial saga context type for context used in the notification sagas.
*/
export type NotificationSagaContext = {
notification: NotificationContext;
};
/** Severity level of notification. */
enum Level {
/** This is an error (requires user action to resolve). */
-2
View File
@@ -5,7 +5,6 @@ import { TypedUseSelectorHook, useSelector as useReduxSelector } from 'react-red
import { Reducer, combineReducers } from 'redux';
import app from './app/reducers';
import ble from './ble/reducers';
import editor from './editor/reducers';
import fileStorage from './fileStorage/reducers';
import firmware from './firmware/reducers';
import hub from './hub/reducers';
@@ -20,7 +19,6 @@ export const rootReducer = combineReducers({
app,
bootloader,
ble,
editor,
fileStorage,
firmware,
hub,
+12 -4
View File
@@ -6,18 +6,18 @@ import { didStart } from './app/actions';
import app from './app/sagas';
import blePybricksService from './ble-pybricks-service/sagas';
import ble from './ble/sagas';
import editor from './editor/sagas';
import editor, { EditorSagaContext } from './editor/sagas';
import errorLog from './error-log/sagas';
import fileStorage from './fileStorage/sagas';
import flashFirmware from './firmware/sagas';
import flashFirmware, { FirmwareSagaContext } from './firmware/sagas';
import hub from './hub/sagas';
import licenses from './licenses/sagas';
import lwp3BootloaderProtocol from './lwp3-bootloader/sagas';
import lwp3BootloaderBle from './lwp3-bootloader/sagas-ble';
import mpy from './mpy/sagas';
import notifications from './notifications/sagas';
import notifications, { NotificationSagaContext } from './notifications/sagas';
import settings from './settings/sagas';
import terminal from './terminal/sagas';
import terminal, { TerminalSagaContext } from './terminal/sagas';
/* istanbul ignore next */
export default function* (): Generator {
@@ -40,3 +40,11 @@ export default function* (): Generator {
put(didStart()),
]);
}
/**
* Combined type for all saga contexts.
*/
export type RootSagaContext = EditorSagaContext &
FirmwareSagaContext &
NotificationSagaContext &
TerminalSagaContext;
+5
View File
@@ -27,6 +27,11 @@ import { defined } from '../utils';
import { TerminalContextValue } from './TerminalContext';
import { receiveData, sendData } from './actions';
/**
* Partial saga context type for context used in the terminal sagas.
*/
export type TerminalSagaContext = { terminal: TerminalContextValue };
const encoder = new TextEncoder();
const decoder = new TextDecoder();
+3 -2
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import { AnyAction } from 'redux';
import { END, MulticastChannel, Saga, Task, runSaga, stdChannel } from 'redux-saga';
import { RootState } from '../src/reducers';
import { RootSagaContext } from '../src/sagas';
type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
@@ -19,7 +20,7 @@ export class AsyncSaga {
public constructor(
saga: Saga,
state: RecursivePartial<RootState> = {},
context?: Record<string, unknown>,
context?: Partial<RootSagaContext>,
) {
this.channel = stdChannel();
this.dispatches = [];