mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
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:
+54
-32
@@ -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
@@ -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))}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user