editor: add tabs for controlling open/active files

This commit is contained in:
David Lechner
2022-04-07 21:17:09 -05:00
parent 56858390ca
commit 8ac77560fb
20 changed files with 1289 additions and 118 deletions
+122 -29
View File
@@ -1,52 +1,145 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021-2022 The Pybricks Authors
import {
RenderResult,
fireEvent,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
import { Classes } from '@blueprintjs/core';
import { RenderResult, cleanup, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { monaco } from 'react-monaco-editor';
import { testRender } from '../../test';
import { defined } from '../utils';
import Editor from './Editor';
import { editorActivateFile, editorCloseFile } from './actions';
function getTextArea(editor: RenderResult): HTMLTextAreaElement {
// the textarea in ace editor doesn't actually have any contents, but
// it gets the focus for input.
return editor.getByDisplayValue('') as HTMLTextAreaElement;
}
describe('Editor', () => {
describe('tabs', () => {
it('should dispatch action when tab is clicked', async () => {
const [editor, dispatch] = testRender(<Editor />, {
editor: { openFiles: ['test.file'] },
});
it('should focus the text area', () => {
const [editor] = testRender(<Editor />);
userEvent.click(editor.getByRole('tab', { name: 'test.file' }));
expect(getTextArea(editor)).toHaveFocus();
});
expect(dispatch).toHaveBeenCalledWith(editorActivateFile('test.file'));
});
describe('context menu', () => {
it('should show the context menu', async () => {
const [editor] = testRender(<Editor />);
it('should dispatch action when close button is clicked', async () => {
const [editor, dispatch] = testRender(<Editor />, {
editor: { openFiles: ['test.file'] },
});
fireEvent.contextMenu(editor.getByText('Write your program here...'));
userEvent.click(editor.getByRole('button', { name: 'Close test.file' }));
await waitFor(() => {
expect(editor.getByText('Copy')).toBeInTheDocument();
expect(dispatch).toHaveBeenCalledWith(editorCloseFile('test.file'));
});
});
it('should hide the context menu when Escape is pressed', async () => {
const [editor] = testRender(<Editor />);
describe('context menu', () => {
let editor: RenderResult;
let code: monaco.editor.ICodeEditor;
fireEvent.contextMenu(editor.getByText('Write your program here...'));
beforeEach(async () => {
const didCreate = new Promise<monaco.editor.ICodeEditor>((resolve) =>
monaco.editor.onDidCreateEditor(resolve),
);
expect(editor.getByText('Copy')).toBeInTheDocument();
[editor] = testRender(<Editor />);
code = await didCreate;
userEvent.type(editor.getByText('Copy'), '{esc}');
code.setModel(monaco.editor.createModel('test'));
await waitForElementToBeRemoved(() => editor.queryByText('Copy'));
expect(
editor.queryByRole('menu', { name: 'Editor context menu' }),
).toBeNull();
});
// editor should be focused after context menu closes
expect(document.activeElement).toBe(getTextArea(editor));
describe('keyboard interaction', () => {
let contextMenu: HTMLElement;
beforeEach(async () => {
// HACK: monaco editor uses deprecated event fields (keyCode),
// so regular userEvent.type() doesn't work. testing library
// doesn't have ContextMenu in its keymap either.
fireEvent(
editor.getByRole('textbox', { name: /^Editor content/ }),
new KeyboardEvent('keydown', {
key: 'ContextMenu',
code: 'ContextMenu',
keyCode: 93,
}),
);
contextMenu = await editor.findByRole('menu', {
name: 'Editor context menu',
});
expect(contextMenu).toBeInTheDocument();
// a11y: first item in menu should be focused when menu opens
await waitFor(() =>
expect(
editor.getByRole('menuitem', { name: 'Copy' }),
).toHaveFocus(),
);
});
it('should hide the context menu when Escape is pressed', async () => {
userEvent.keyboard('{esc}');
await waitFor(() => expect(contextMenu).not.toBeInTheDocument());
// editor should be focused after context menu closes
expect(
editor.getByRole('textbox', { name: /^Editor content/ }),
).toHaveFocus();
});
});
describe('mouse interaction', () => {
let contextMenu: HTMLElement;
beforeEach(async () => {
userEvent.click(
editor.getByRole('textbox', { name: /^Editor content/ }),
{
button: 2,
},
);
contextMenu = await editor.findByRole('menu', {
name: 'Editor context menu',
});
expect(contextMenu).toBeInTheDocument();
// a11y: first item in menu should be focused when menu opens
await waitFor(() =>
expect(
editor.getByRole('menuitem', { name: 'Copy' }),
).toHaveFocus(),
);
});
it('should hide the context menu when clicking away', async () => {
const overlay = document
.getElementsByClassName(Classes.OVERLAY_BACKDROP)
.item(0);
defined(overlay);
userEvent.click(overlay);
await waitFor(() => expect(contextMenu).not.toBeInTheDocument());
// editor should be focused after context menu closes
expect(
editor.getByRole('textbox', { name: /^Editor content/ }),
).toHaveFocus();
});
});
});
afterEach(() => {
cleanup();
});
});
+203 -64
View File
@@ -1,7 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import { Menu, MenuDivider, MenuItem } from '@blueprintjs/core';
import './editor.scss';
import {
Button,
Classes,
IOverlayLifecycleProps,
IconName,
Menu,
MenuDivider,
MenuItem,
Tab,
TabId,
Tabs,
} from '@blueprintjs/core';
import { ContextMenu2, ResizeSensor2 } from '@blueprintjs/popover2';
import { I18n, useI18n } from '@shopify/react-i18n';
import tomorrowNightEightiesTheme from 'monaco-themes/themes/Tomorrow-Night-Eighties.json';
@@ -18,15 +30,16 @@ import { useTernaryDarkMode } from 'usehooks-ts';
import { IDisposable } from 'xterm';
import { fileStorageWriteFile } from '../fileStorage/actions';
import { compile } from '../mpy/actions';
import { useSelector } from '../reducers';
import { useSettingIsShowDocsEnabled } from '../settings/hooks';
import { isMacOS } from '../utils/os';
import { preventBrowserNativeContextMenu, useUniqueId } from '../utils/react';
import { editorActivateFile, editorCloseFile } from './actions';
import { I18nId } from './i18n';
import * as pybricksMicroPython from './pybricksMicroPython';
import { pybricksMicroPythonId } from './pybricksMicroPython';
import { UntitledHintContribution } from './untitledHint';
import './editor.scss';
const pybricksMicroPythonId = 'pybricks-micropython';
monaco.languages.register({ id: pybricksMicroPythonId });
const toDispose = new Array<IDisposable>();
@@ -46,6 +59,7 @@ toDispose.push(
);
// https://webpack.js.org/api/hot-module-replacement/
// istanbul ignore if: only used for development
if (module.hot) {
module.hot.dispose(() => {
toDispose.forEach((s) => s.dispose());
@@ -61,9 +75,46 @@ monaco.editor.defineTheme(
const xcodeId = 'xcode';
monaco.editor.defineTheme(xcodeId, xcodeTheme as monaco.editor.IStandaloneThemeData);
type EditorContextMenuItemProps = Readonly<{
/** The menu item label. */
label: string;
/** The menu item icon. */
icon: IconName;
/** The keyboard shortcut that triggers the same action. */
keyboardShortcut: string;
/** Controls the menu item disabled state. */
disabled: boolean;
/** A reference to the editor. */
editor: monaco.editor.IStandaloneCodeEditor | undefined;
/** The action handler ID passed to the editor.trigger() method. */
editorAction: string;
}>;
const EditorContextMenuItem: React.VoidFunctionComponent<
EditorContextMenuItemProps
> = ({ label, icon, keyboardShortcut, disabled, editor, editorAction }) => {
const labelId = useUniqueId('pb-editor');
return (
<MenuItem
role="menuitem"
aria-labelledby={labelId}
text={<span id={labelId}>{label}</span>}
icon={icon}
label={keyboardShortcut}
disabled={disabled}
onClick={() => {
// have to focus first or the trigger won't work
editor?.focus();
editor?.trigger(null, editorAction, null);
}}
/>
);
};
type EditorContextMenuProps = {
/** The editor. */
editor?: monaco.editor.IStandaloneCodeEditor;
editor: monaco.editor.IStandaloneCodeEditor | undefined;
/** Translation context. */
i18n: I18n;
};
@@ -72,8 +123,6 @@ const EditorContextMenu: React.VoidFunctionComponent<EditorContextMenuProps> = (
editor,
i18n,
}) => {
const hasEditor = editor !== null;
const selection = editor?.getSelection();
const hasSelection = selection && !selection.isEmpty();
@@ -82,62 +131,108 @@ const EditorContextMenu: React.VoidFunctionComponent<EditorContextMenuProps> = (
const canRedo = model && model.canRedo();
return (
<Menu>
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'editor.action.clipboardCopyAction', null);
}}
text={i18n.translate(I18nId.Copy)}
<Menu aria-label={i18n.translate(I18nId.ContextMenuLabel)} role="menu">
<EditorContextMenuItem
label={i18n.translate(I18nId.Copy)}
icon="duplicate"
label={isMacOS() ? 'Cmd-C' : 'Ctrl-C'}
keyboardShortcut={isMacOS() ? 'Cmd-C' : 'Ctrl-C'}
disabled={!hasSelection}
editor={editor}
editorAction="editor.action.clipboardCopyAction"
/>
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'editor.action.clipboardPasteAction', null);
}}
text={i18n.translate(I18nId.Paste)}
<EditorContextMenuItem
label={i18n.translate(I18nId.Paste)}
icon="clipboard"
label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'}
disabled={!hasEditor}
keyboardShortcut={isMacOS() ? 'Cmd-V' : 'Ctrl-V'}
disabled={!model}
editor={editor}
editorAction="editor.action.clipboardPasteAction"
/>
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'editor.action.selectAll', null);
}}
text={i18n.translate(I18nId.SelectAll)}
<EditorContextMenuItem
label={i18n.translate(I18nId.SelectAll)}
icon="blank"
label={isMacOS() ? 'Cmd-A' : 'Ctrl-A'}
disabled={!hasEditor}
keyboardShortcut={isMacOS() ? 'Cmd-A' : 'Ctrl-A'}
disabled={!model}
editor={editor}
editorAction="editor.action.selectAll"
/>
<MenuDivider />
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'undo', null);
}}
text={i18n.translate(I18nId.Undo)}
<EditorContextMenuItem
label={i18n.translate(I18nId.Undo)}
icon="undo"
label={isMacOS() ? 'Cmd-Z' : 'Ctrl-Z'}
keyboardShortcut={isMacOS() ? 'Cmd-Z' : 'Ctrl-Z'}
disabled={!canUndo}
editor={editor}
editorAction="undo"
/>
<MenuItem
onClick={() => {
editor?.focus();
editor?.trigger(null, 'redo', null);
}}
text={i18n.translate(I18nId.Redo)}
<EditorContextMenuItem
label={i18n.translate(I18nId.Redo)}
icon="redo"
label={isMacOS() ? 'Cmd-Shift-Z' : 'Ctrl-Shift-Z'}
keyboardShortcut={isMacOS() ? 'Cmd-Shift-Z' : 'Ctrl-Shift-Z'}
disabled={!canRedo}
editor={editor}
editorAction="redo"
/>
</Menu>
);
};
type EditorTabsProps = Readonly<{
/** Called when the selected tab changes. */
onChange?: () => void;
/** Translation context. */
i18n: I18n;
}>;
const EditorTabs: React.VoidFunctionComponent<EditorTabsProps> = ({
onChange,
i18n,
}) => {
const openFiles = useSelector((s) => s.editor.openFiles);
const activeFile = useSelector((s) => s.editor.activeFile);
const dispatch = useDispatch();
const handleChange = useCallback(
(newTabId: TabId) => {
dispatch(editorActivateFile(newTabId as string));
onChange?.();
},
[dispatch, onChange],
);
const labelId = useUniqueId('pb-editor');
return (
<Tabs
className="pb-editor-tabs"
selectedTabId={activeFile}
onChange={handleChange}
>
{openFiles.map((fileName, i) => (
<Tab
className="pb-editor-tab"
aria-labelledby={`${labelId}.${i}`}
key={i}
id={fileName}
>
<span id={`${labelId}.${i}`}>{fileName}</span>
<Button
title={i18n.translate(I18nId.CloseFileTooltip, { fileName })}
minimal={true}
small={true}
icon={'cross'}
onClick={(e) => {
dispatch(editorCloseFile(fileName));
// prevent triggering Tabs onChange
e.stopPropagation();
}}
/>
</Tab>
))}
</Tabs>
);
};
/**
* Wrapper around useEffect() hook that uses {@link maybeEditor}.
* @param maybeEditor The editor or undefined if the editor is not mounted.
@@ -193,10 +288,12 @@ const Editor: React.VFC = () => {
const options = useMemo<monaco.editor.IStandaloneEditorConstructionOptions>(
() => ({
model: null,
fontSize: 18,
minimap: { enabled: false },
contextmenu: false,
rulers: [80],
lineNumbersMinChars: 4,
}),
[],
);
@@ -254,6 +351,25 @@ const Editor: React.VFC = () => {
[],
);
useEditor(
editor,
(editor) => {
// TODO: can be removed when https://github.com/microsoft/vscode/pull/146968 is merged
// HACK: The editor eats context menu key press events event when
// the monaco context menu is disabled so we have to fake it
const subscription = editor.onKeyDown((e) => {
if (e.keyCode === monaco.KeyCode.ContextMenu) {
e.target.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true }),
);
}
});
return () => subscription.dispose();
},
[],
);
const handleEditorDidMount = useCallback<EditorDidMount>(
(editor) => {
editor.focus();
@@ -272,26 +388,49 @@ const Editor: React.VFC = () => {
[dispatch],
);
const popoverProps = useMemo<IOverlayLifecycleProps>(
() => ({
onOpened: (e) => {
// a11y: focus the first item in the menu when the menu opens
const menuItems = e.getElementsByClassName(Classes.MENU_ITEM);
const firstItem = menuItems.item(0);
// istanbul ignore if: should not be reachable
if (!(firstItem instanceof HTMLElement)) {
console.log(`bug: firstItem is not an HTMLElement: ${firstItem}`);
return;
}
firstItem.focus();
},
onClosed: () => editor?.focus(),
}),
[editor],
);
return (
<ResizeSensor2 onResize={() => editor?.layout()}>
<ContextMenu2
className="h-100"
// NB: we have to create a new context menu each time it is
// shown in order to get some state, like canUndo and canRedo
// that don't have events to monitor changes.
content={() => <EditorContextMenu editor={editor} i18n={i18n} />}
popoverProps={{ onClosed: () => editor?.focus() }}
>
<MonacoEditor
language={pybricksMicroPythonId}
theme={isDarkMode ? tomorrowNightEightiesId : xcodeId}
options={options}
editorDidMount={handleEditorDidMount}
editorWillUnmount={handleEditorWillUnmount}
onChange={handleChange}
/>
</ContextMenu2>
</ResizeSensor2>
<div className="h-100" onContextMenu={preventBrowserNativeContextMenu}>
<EditorTabs onChange={() => editor?.focus()} i18n={i18n} />
<ResizeSensor2 onResize={() => editor?.layout()}>
<ContextMenu2
className="h-100"
// NB: we have to create a new context menu each time it is
// shown in order to get some state, like canUndo and canRedo
// that don't have events to monitor changes.
content={() => <EditorContextMenu editor={editor} i18n={i18n} />}
popoverProps={popoverProps}
>
<MonacoEditor
theme={isDarkMode ? tomorrowNightEightiesId : xcodeId}
options={options}
editorDidMount={handleEditorDidMount}
editorWillUnmount={handleEditorWillUnmount}
onChange={handleChange}
/>
</ContextMenu2>
</ResizeSensor2>
</div>
);
};
+53
View File
@@ -27,6 +27,59 @@ export const editorGetValueResponse = createAction((id: number, value: string) =
id,
value,
}));
/**
* Requests to open a file in the editor.
* @param fileName the file name.
*/
export const editorOpenFile = createAction((fileName: string) => ({
type: 'editor.action.openFile',
fileName,
}));
/**
* Indicates that {@link editorOpenFile} succeeded.
* @param fileName the file name.
*/
export const editorDidOpenFile = createAction((fileName: string) => ({
type: 'editor.action.didOpenFile',
fileName,
}));
/**
* Indicates that {@link editorOpenFile} failed.
* @param fileName the file name.
* @param error the error.
*/
export const editorDidFailToOpenFile = createAction(
(fileName: string, error: Error) => ({
type: 'editor.action.didFailToOpenFile',
fileName,
error,
}),
);
/**
* Requests to close a file in the editor.
* @param fileName the file name.
*/
export const editorCloseFile = createAction((fileName: string) => ({
type: 'editor.action.closeFile',
fileName,
}));
/**
* Indicates that {@link editorCloseFile} completed.
*
* Unlike most actions, this does not have a "did fail" counterpart.
*
* @param fileName the file name.
*/
export const editorDidCloseFile = createAction((fileName: string) => ({
type: 'editor.action.didCloseFile',
fileName,
}));
/**
* Request to activate a file (open or bring to foreground if already open).
* @param fileName The file name.
+9 -1
View File
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
// Custom styling for the Editor control.
@@ -23,6 +23,14 @@
color: $pt-dark-text-color-muted;
}
.pb-editor-tab {
padding: 3px;
}
.pb-editor-tabs {
padding: 3px 6px;
}
.pb-editor-placeholder {
pointer-events: none;
width: max-content;
+2
View File
@@ -12,4 +12,6 @@ export enum I18nId {
SelectAll = 'selectAll',
Undo = 'undo',
Redo = 'redo',
CloseFileTooltip = 'closeFile.tooltip',
ContextMenuLabel = 'contextMenu.label',
}
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { mock } from 'jest-mock-extended';
import { monaco } from 'react-monaco-editor';
import { FD } from '../fileStorage/actions';
import { ActiveFileHistoryManager, OpenFileInfo, OpenFileManager } from './lib';
afterEach(() => {
sessionStorage.clear();
});
describe('ActiveFileHistoryManager', () => {
it('should handle fresh (empty) storageSession', () => {
const manager = new ActiveFileHistoryManager('test');
expect([...manager.getFromStorage()]).toEqual([]);
});
it('should return history from sessionStorage', () => {
sessionStorage.setItem(
`editor.activeFileHistory.${window.name}.test`,
'["one.file","two.file"]',
);
const manager = new ActiveFileHistoryManager('test');
expect([...manager.getFromStorage()]).toEqual(['one.file', 'two.file']);
});
it('should save to sessionStorage', () => {
const manager = new ActiveFileHistoryManager('test');
manager.push('one.file');
manager.push('two.file');
expect(
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
).toEqual('["one.file","two.file"]');
});
it('should reorder existing files', () => {
const manager = new ActiveFileHistoryManager('test');
manager.push('one.file');
manager.push('two.file');
manager.push('one.file');
expect(
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
).toEqual('["two.file","one.file"]');
});
it('should known when active file was popped', () => {
const manager = new ActiveFileHistoryManager('test');
manager.push('one.file');
manager.push('two.file');
expect(manager.pop('two.file')).toBe('one.file');
expect(
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
).toEqual('["one.file"]');
});
it('should known when not active file was popped', () => {
const manager = new ActiveFileHistoryManager('test');
manager.push('one.file');
manager.push('two.file');
expect(manager.pop('one.file')).toBe(undefined);
expect(
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
).toEqual('["two.file"]');
});
it('should known when never active file was popped', () => {
const manager = new ActiveFileHistoryManager('test');
manager.push('one.file');
manager.push('two.file');
expect(manager.pop('three.file')).toBe(undefined);
expect(
sessionStorage.getItem(`editor.activeFileHistory.${window.name}.test`),
).toEqual('["one.file","two.file"]');
});
});
describe('OpenFileManager', () => {
it('should add and remove files', () => {
const manager = new OpenFileManager();
expect(manager.has('test.file')).toBeFalsy();
expect(manager.get('test.file')).toBeUndefined();
const model = mock<monaco.editor.ITextModel>();
manager.add('test.file', 0 as FD, model, null);
expect(manager.has('test.file')).toBeTruthy();
expect(manager.get('test.file')).toEqual(<OpenFileInfo>{
fd: 0 as FD,
model,
viewState: null,
});
manager.remove('test.file');
expect(manager.has('test.file')).toBeFalsy();
expect(manager.get('test.file')).toBeUndefined();
});
it('should update viewState', () => {
const manager = new OpenFileManager();
// does not fail if key does not exist
manager.updateViewState('test.file', null);
const model = mock<monaco.editor.ITextModel>();
const viewState = mock<monaco.editor.ICodeEditorViewState>();
manager.add('test.file', 0 as FD, model, viewState);
expect(manager.get('test.file')).toHaveProperty('viewState', viewState);
manager.updateViewState('test.file', null);
expect(manager.get('test.file')).toHaveProperty('viewState', null);
});
});
+187
View File
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import dexieObservable from 'dexie-observable';
import { monaco } from 'react-monaco-editor';
import { FD } from '../fileStorage/actions';
// HACK: Using window.name to detect page reloads vs. tab duplication.
// window.name will persist across page reloads but will be set back to ''
// when a page is duplicated. This will avoid attempting to open files that
// are already open in the page that was duplicated. sessionStorage is
// duplicated when a window is duplicated, and we don't want to try to
// duplicate open files since that would just cause errors since the files
// are already open in another window.
// istanbul ignore else
if (window.name === '') {
window.name = dexieObservable.createUUID();
}
/**
* Manages the active file history for an editor.
*
* The history is stored per-editor and per-browser window in a manner such
* that it will persist across page reloads but be unique per window, including
* duplicated windows.
*/
export class ActiveFileHistoryManager {
private readonly history = new Array<string>();
private readonly storageKey: string;
public constructor(id: string) {
this.storageKey = `editor.activeFileHistory.${window.name}.${id}`;
}
/**
* Gets the stored data.
*
* This may be nothing if storage fails or storage contains invalid data
* even though there is valid history in memory. It will also return data
* different from the in-memory list if storage has been modified externally
* or if no items have been pushed yet.
*
* It only makes sense to call this right after a new
* {@link ActiveFileHistoryManager} has been created to get the old values
* from the previous window reload.
*/
public *getFromStorage(): IterableIterator<string> {
try {
const savedActiveFileHistory = JSON.parse(
sessionStorage.getItem(this.storageKey) || '[]',
);
// istanbul ignore if
if (!(savedActiveFileHistory instanceof Array)) {
throw new Error('savedActiveFileHistory is not an array');
}
// this should restore all previously open files in the same order
// the were last used (which may be different from the order in which
// they were originally opened)
for (const item of savedActiveFileHistory) {
// istanbul ignore if
if (typeof item !== 'string') {
console.error(
`ActiveFileHistoryManager: skipping non-string item: ${item}`,
);
continue;
}
yield item;
}
} catch (err) {
// istanbul ignore next: not a critical error
console.error(`failed to get ${this.storageKey}:`, err);
}
}
/**
* Pushes a file on top of the stack. If the file was already in the stack,
* it is moved to the top.
* @param fileName: the file name
*/
public push(fileName: string): void {
const index = this.history.indexOf(fileName);
if (index >= 0) {
this.history.splice(index, 1);
}
this.history.push(fileName);
try {
sessionStorage.setItem(this.storageKey, JSON.stringify(this.history));
} catch (err) {
// istanbul ignore next: not a critical failure
console.error(`failed to store ${this.storageKey}:`, err);
}
}
/**
* Pops an item from the list.
*
* The item is not necessarily the (top) active item.
* @param fileName The file to remove.
* @returns The new active file if {@link fileName} was the active file or
* undefined if {@link fileName} was not the active file (or not in the
* the history at all.)
*/
public pop(fileName: string): string | undefined {
const index = this.history.indexOf(fileName);
if (index < 0) {
// the file is not in history
return undefined;
}
const wasActiveFile = this.history.at(-1) === fileName;
this.history.splice(index, 1);
try {
sessionStorage.setItem(this.storageKey, JSON.stringify(this.history));
} catch (err) {
// istanbul ignore next: not a critical failure
console.error(`failed to store ${this.storageKey}:`, err);
}
return wasActiveFile ? this.history.at(-1) : undefined;
}
}
export type OpenFileInfo = {
/** The file descriptor. */
readonly fd: FD;
/** The model. */
readonly model: monaco.editor.ITextModel;
/** The view state. */
viewState: monaco.editor.ICodeEditorViewState | null;
};
export class OpenFileManager {
private readonly map: Map<string, OpenFileInfo> = new Map();
public add(
fileName: string,
fd: FD,
model: monaco.editor.ITextModel,
viewState: monaco.editor.ICodeEditorViewState | null,
): void {
// istanbul ignore if: bug if hit
if (this.map.has(fileName)) {
throw new Error(`bug: key '${fileName}' already exists in the mpa`);
}
this.map.set(fileName, { fd, model, viewState });
}
public remove(fileName: string): void {
this.map.delete(fileName);
}
public has(fileName: string): boolean {
return this.map.has(fileName);
}
public get(fileName: string): OpenFileInfo | undefined {
return this.map.get(fileName);
}
/**
* Modifies the view state of {@link fileName} if it is present, otherwise
* does nothing.
* @param fileName The lookup key.
* @param viewState The new view state.
*/
public updateViewState(
fileName: string,
viewState: monaco.editor.ICodeEditorViewState | null,
): void {
const info = this.map.get(fileName);
if (!info) {
return;
}
info.viewState = viewState;
}
}
+3
View File
@@ -7,6 +7,9 @@
import { monaco } from 'react-monaco-editor';
/** The Pybricks MicroPython language identifier. */
export const pybricksMicroPythonId = 'pybricks-micropython';
export const conf: monaco.languages.LanguageConfiguration = {
comments: {
lineComment: '#',
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import {
editorDidActivateFile,
editorDidCloseFile,
editorDidCreate,
editorDidOpenFile,
} from './actions';
import reducers from './reducers';
type State = ReturnType<typeof reducers>;
describe('isReady', () => {
it('should change state when editor is created', () => {
expect(
reducers({ isReady: false } as State, editorDidCreate()).isReady,
).toBeTruthy();
});
});
describe('activeFile', () => {
it('should change state when a file is activated', () => {
expect(
reducers({ activeFile: '' } as State, editorDidActivateFile('test.file'))
.activeFile,
).toBe('test.file');
});
});
describe('openFiles', () => {
it('should change state when a file is opened', () => {
expect(
reducers(
{ openFiles: [] as readonly string[] } as State,
editorDidOpenFile('test.file'),
).openFiles,
).toEqual(['test.file']);
});
it('should change state when a file is closed', () => {
expect(
reducers(
{ openFiles: ['test.file'] as readonly string[] } as State,
editorDidCloseFile('test.file'),
).openFiles,
).toEqual([]);
});
});
+33 -2
View File
@@ -2,7 +2,12 @@
// Copyright (c) 2022 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import { editorDidCreate } from './actions';
import {
editorDidActivateFile,
editorDidCloseFile,
editorDidCreate,
editorDidOpenFile,
} from './actions';
/** Indicates that the code editor is ready for use. */
const isReady: Reducer<boolean> = (state = false, action) => {
@@ -13,4 +18,30 @@ const isReady: Reducer<boolean> = (state = false, action) => {
return state;
};
export default combineReducers({ isReady });
/**
* Indicates which file out of {@link openFiles} is the currently active file.
*
* If {@link activeFile} is not in {@link openFiles}, then there is no active file.
*/
const activeFile: Reducer<string> = (state = '', action) => {
if (editorDidActivateFile.matches(action)) {
return action.fileName;
}
return state;
};
/** A list of open files in the order they should be displayed to the user. */
const openFiles: Reducer<readonly string[]> = (state = [], action) => {
if (editorDidOpenFile.matches(action)) {
return [...state, action.fileName];
}
if (editorDidCloseFile.matches(action)) {
return state.filter((f) => f !== action.fileName);
}
return state;
};
export default combineReducers({ isReady, activeFile, openFiles });
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { mock } from 'jest-mock-extended';
import { monaco } from 'react-monaco-editor';
import { AsyncSaga } from '../../test';
import {
FD,
fileStorageClose,
fileStorageDidClose,
fileStorageDidFailToOpen,
fileStorageDidFailToRead,
fileStorageDidInitialize,
fileStorageDidOpen,
fileStorageDidRead,
fileStorageOpen,
fileStorageRead,
} from '../fileStorage/actions';
import {
editorActivateFile,
editorCloseFile,
editorDidActivateFile,
editorDidCloseFile,
editorDidCreate,
editorDidFailToActivateFile,
editorDidFailToOpenFile,
editorDidOpenFile,
editorOpenFile,
} from './actions';
import { ActiveFileHistoryManager, OpenFileInfo, OpenFileManager } from './lib';
import editor from './sagas';
jest.mock('./lib');
afterEach(() => {
jest.resetAllMocks();
sessionStorage.clear();
});
it('should activate files from storage', async () => {
jest.spyOn(
ActiveFileHistoryManager.prototype,
'getFromStorage',
).mockReturnValueOnce(['test.file'].values());
const saga = new AsyncSaga(editor);
monaco.editor.create(document.createElement('div'));
saga.put(fileStorageDidInitialize([]));
await expect(saga.take()).resolves.toEqual(editorDidCreate());
// other editor actions on create should take place after editorDidCreate()
await expect(saga.take()).resolves.toEqual(editorActivateFile('test.file'));
await saga.end();
});
describe('per-editor sagas', () => {
let saga: AsyncSaga;
let monacoEditor: monaco.editor.IStandaloneCodeEditor;
beforeEach(async () => {
jest.spyOn(
ActiveFileHistoryManager.prototype,
'getFromStorage',
).mockReturnValueOnce([].values());
saga = new AsyncSaga(editor);
saga.updateState({ fileStorage: { isInitialized: true } });
monacoEditor = monaco.editor.create(document.createElement('div'));
await expect(saga.take()).resolves.toEqual(editorDidCreate());
});
describe('handleEditorOpenFile', () => {
beforeEach(async () => {
jest.spyOn(OpenFileManager.prototype, 'add');
jest.spyOn(OpenFileManager.prototype, 'remove');
saga.put(editorOpenFile('test.file'));
await expect(saga.take()).resolves.toEqual(
fileStorageOpen('test.file', 'w'),
);
});
it('should propagate error from fileStorageOpen', async () => {
const testError = new Error('test error');
saga.put(fileStorageDidFailToOpen('test.file', testError));
await expect(saga.take()).resolves.toEqual(
editorDidFailToOpenFile('test.file', testError),
);
expect(OpenFileManager.prototype.add).not.toHaveBeenCalled();
});
describe('open succeeded', () => {
beforeEach(async () => {
saga.put(fileStorageDidOpen('test.file', 0 as FD));
await expect(saga.take()).resolves.toEqual(fileStorageRead(0 as FD));
});
it('should propagate error from fileStorageRead', async () => {
const testError = new Error('test error');
saga.put(fileStorageDidFailToRead(0 as FD, testError));
// file handle should be closed before editorDidFailToOpenFile
await expect(saga.take()).resolves.toEqual(fileStorageClose(0 as FD));
saga.put(fileStorageDidClose(0 as FD));
await expect(saga.take()).resolves.toEqual(
editorDidFailToOpenFile('test.file', testError),
);
expect(OpenFileManager.prototype.add).not.toHaveBeenCalled();
});
describe('read succeeded', () => {
let model: monaco.editor.ITextModel;
beforeEach(async () => {
monaco.editor.onDidCreateModel((m) => (model = m));
saga.put(fileStorageDidRead(0 as FD, ''));
expect(model).toBeDefined();
await expect(saga.take()).resolves.toEqual(
editorDidOpenFile('test.file'),
);
expect(OpenFileManager.prototype.add).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).not.toHaveBeenCalled();
});
it('should close file if task is canceled', async () => {
jest.spyOn(model, 'dispose');
saga.cancel();
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
await expect(saga.take()).resolves.toEqual(
fileStorageClose(0 as FD),
);
saga.put(fileStorageDidClose(0 as FD));
// editorDidCloseFile is not called since we did not put editorCloseFile
});
it('should close when requested', async () => {
jest.spyOn(model, 'dispose');
saga.put(editorCloseFile('test.file'));
// model should be disposed before fileStorageClose
expect(model.dispose).toHaveBeenCalled();
expect(OpenFileManager.prototype.remove).toHaveBeenCalled();
// file handle should be closed before editorDidCloseFile
await expect(saga.take()).resolves.toEqual(
fileStorageClose(0 as FD),
);
saga.put(fileStorageDidClose(0 as FD));
await expect(saga.take()).resolves.toEqual(
editorDidCloseFile('test.file'),
);
});
});
});
});
describe('handleEditorActivateFile', () => {
describe('file is not already open', () => {
beforeEach(async () => {
jest.spyOn(OpenFileManager.prototype, 'has').mockReturnValueOnce(false);
saga.put(editorActivateFile('test.file'));
await expect(saga.take()).resolves.toEqual(editorOpenFile('test.file'));
});
it('should propagate error if open fails', async () => {
const testError = new Error('test error');
saga.put(editorDidFailToOpenFile('test.file', testError));
await expect(saga.take()).resolves.toEqual(
editorDidFailToActivateFile('test.file', testError),
);
});
it('should complete if open succeeds', async () => {
jest.spyOn(monacoEditor, 'getModel').mockReturnValueOnce(null);
jest.spyOn(monacoEditor, 'setModel').mockReturnValueOnce();
jest.spyOn(monacoEditor, 'restoreViewState').mockReturnValueOnce();
jest.spyOn(ActiveFileHistoryManager.prototype, 'push');
jest.spyOn(OpenFileManager.prototype, 'get').mockReturnValueOnce(
mock<OpenFileInfo>(),
);
jest.spyOn(OpenFileManager.prototype, 'updateViewState');
saga.put(editorDidOpenFile('test.file'));
// changes should be made before editorDidActivateFile
expect(OpenFileManager.prototype.updateViewState).toHaveBeenCalled();
expect(monacoEditor.setModel).toHaveBeenCalled();
expect(monacoEditor.restoreViewState).toHaveBeenCalled();
expect(ActiveFileHistoryManager.prototype.push).toHaveBeenCalled();
await expect(saga.take()).resolves.toEqual(
editorDidActivateFile('test.file'),
);
});
});
describe('file is already open', () => {
beforeEach(async () => {
jest.spyOn(OpenFileManager.prototype, 'has').mockReturnValueOnce(true);
jest.spyOn(OpenFileManager.prototype, 'get').mockReturnValueOnce(
mock<OpenFileInfo>(),
);
jest.spyOn(OpenFileManager.prototype, 'updateViewState');
jest.spyOn(monacoEditor, 'getModel').mockReturnValueOnce(null);
jest.spyOn(monacoEditor, 'setModel').mockReturnValueOnce();
jest.spyOn(monacoEditor, 'restoreViewState').mockReturnValueOnce();
saga.put(editorActivateFile('test.file'));
});
it('should set the model and update sessionStorage', async () => {
// changes should be made before editorDidActivateFile
expect(monacoEditor.setModel).toHaveBeenCalled();
expect(monacoEditor.restoreViewState).toHaveBeenCalled();
expect(OpenFileManager.prototype.updateViewState).toHaveBeenCalled();
await expect(saga.take()).resolves.toEqual(
editorDidActivateFile('test.file'),
);
});
});
});
describe('handleEditorDidCloseFile', () => {
it('should activate a new file if closed file was currently active', async () => {
jest.spyOn(ActiveFileHistoryManager.prototype, 'pop').mockReturnValueOnce(
'new.file',
);
saga.put(editorDidCloseFile('test.file'));
expect(ActiveFileHistoryManager.prototype.pop).toHaveBeenCalled();
await expect(saga.take()).resolves.toEqual(editorActivateFile('new.file'));
});
it('should do nothing if closed file was not currently active', () => {
jest.spyOn(ActiveFileHistoryManager.prototype, 'pop').mockReturnValueOnce(
undefined,
);
saga.put(editorDidCloseFile('test.file'));
expect(ActiveFileHistoryManager.prototype.pop).toHaveBeenCalled();
});
});
afterEach(async () => {
await saga.end();
});
});
+182 -20
View File
@@ -14,17 +14,33 @@ import {
takeEvery,
} from 'typed-redux-saga/macro';
import {
fileStorageDidFailToReadFile,
fileStorageClose,
fileStorageDidClose,
fileStorageDidFailToOpen,
fileStorageDidFailToRead,
fileStorageDidInitialize,
fileStorageDidReadFile,
fileStorageReadFile,
fileStorageDidOpen,
fileStorageDidRead,
fileStorageOpen,
fileStorageRead,
} from '../fileStorage/actions';
import { RootState } from '../reducers';
import { defined, ensureError } from '../utils';
import {
editorActivateFile,
editorCloseFile,
editorDidActivateFile,
editorDidCloseFile,
editorDidCreate,
editorDidFailToActivateFile,
editorDidFailToOpenFile,
editorDidOpenFile,
editorGetValueRequest,
editorGetValueResponse,
editorOpenFile,
} from './actions';
import { ActiveFileHistoryManager, OpenFileManager } from './lib';
import { pybricksMicroPythonId } from './pybricksMicroPython';
/**
* Saga that gets the current value from the editor.
@@ -55,6 +71,144 @@ function* handleEditorGetValueRequest(
yield* put(editorGetValueResponse(action.id, editor.getValue()));
}
function* handleEditorOpenFile(
openFiles: OpenFileManager,
action: ReturnType<typeof editorOpenFile>,
): Generator {
let closeRequested = false;
try {
yield* put(fileStorageOpen(action.fileName, 'w'));
const { didOpen, didFailToOpen } = yield* race({
didOpen: take(fileStorageDidOpen.when((a) => a.path === action.fileName)),
didFailToOpen: take(
fileStorageDidFailToOpen.when((a) => a.path === action.fileName),
),
});
if (didFailToOpen) {
throw didFailToOpen.error;
}
defined(didOpen);
const defer: Array<() => void> = [];
try {
yield* put(fileStorageRead(didOpen.fd));
const { didRead, didFailToRead } = yield* race({
didRead: take(fileStorageDidRead.when((a) => a.fd === didOpen.fd)),
didFailToRead: take(
fileStorageDidFailToRead.when((a) => a.fd === didOpen.fd),
),
});
if (didFailToRead) {
throw didFailToRead.error;
}
defined(didRead);
const model = monaco.editor.createModel(
didRead.contents,
pybricksMicroPythonId,
monaco.Uri.from({ scheme: 'pybricksCode', path: action.fileName }),
);
defer.push(() => model.dispose());
// TODO: get viewState from fileStorage
openFiles.add(action.fileName, didOpen.fd, model, null);
defer.push(() => openFiles.remove(action.fileName));
yield* put(editorDidOpenFile(action.fileName));
yield* take(editorCloseFile.when((a) => a.fileName === action.fileName));
closeRequested = true;
} finally {
for (const callback of defer.reverse()) {
callback();
}
yield* put(fileStorageClose(didOpen.fd));
yield* take(fileStorageDidClose.when((a) => a.fd === didOpen.fd));
// only send the did close action if the corresponding action requested it
if (closeRequested) {
yield* put(editorDidCloseFile(action.fileName));
}
}
} catch (err) {
yield* put(editorDidFailToOpenFile(action.fileName, ensureError(err)));
}
}
function* handleEditorActivateFile(
editor: monaco.editor.ICodeEditor,
openFiles: OpenFileManager,
activeFileHistory: ActiveFileHistoryManager,
action: ReturnType<typeof editorActivateFile>,
): Generator {
try {
if (!openFiles.has(action.fileName)) {
yield* put(editorOpenFile(action.fileName));
const { didFailToOpen } = yield* race({
didOpen: take(
editorDidOpenFile.when((a) => a.fileName == action.fileName),
),
didFailToOpen: take(
editorDidFailToOpenFile.when((a) => a.fileName == action.fileName),
),
});
if (didFailToOpen) {
throw didFailToOpen.error;
}
}
const file = openFiles.get(action.fileName);
// istanbul ignore if: this should alway be available after editorDidOpenFile
if (file === undefined) {
throw new Error('bug: could not get file from openFiles');
}
// save the current view state for later activation
const activeFile = editor.getModel()?.uri?.path ?? '';
openFiles.updateViewState(activeFile, editor.saveViewState());
// TODO: save viewState to fileStorage
editor.setModel(file.model);
editor.restoreViewState(file.viewState);
activeFileHistory.push(action.fileName);
yield* put(editorDidActivateFile(action.fileName));
} catch (err) {
yield* put(editorDidFailToActivateFile(action.fileName, ensureError(err)));
}
}
function* handleEditorDidCloseFile(
activeFileHistory: ActiveFileHistoryManager,
action: ReturnType<typeof editorDidCloseFile>,
): Generator {
// handleEditorOpenFile handles most of the closing of files.
// Here we only need to handle removing the closed file from the active
// file history.
const newActiveFile = activeFileHistory.pop(action.fileName);
// if the closed file was the active file, we need to activate a new file
// otherwise there will be no active file
if (newActiveFile) {
yield* put(editorActivateFile(newActiveFile));
}
}
function* handleDidCreateEditor(editor: monaco.editor.ICodeEditor): Generator {
// first, we need to be sure that file storage is ready
@@ -66,26 +220,28 @@ function* handleDidCreateEditor(editor: monaco.editor.ICodeEditor): Generator {
yield* take(fileStorageDidInitialize);
}
// then we can load the most recently used file
// REVISIT: should this be here or elsewhere?
yield* put(fileStorageReadFile('main.py'));
const { didRead } = yield* race({
didRead: take(fileStorageDidReadFile.when((a) => a.path === 'main.py')),
didFailToRead: take(
fileStorageDidFailToReadFile.when((a) => a.path === 'main.py'),
),
});
// TODO: what to do in case of failure?
if (didRead) {
editor.setValue(didRead.contents);
}
const openFiles = new OpenFileManager();
const activeFileHistory = new ActiveFileHistoryManager(editor.getId());
yield* takeEvery(editorGetValueRequest, handleEditorGetValueRequest, editor);
yield* takeEvery(editorOpenFile, handleEditorOpenFile, openFiles);
yield* takeEvery(
editorActivateFile,
handleEditorActivateFile,
editor,
openFiles,
activeFileHistory,
);
yield* takeEvery(editorDidCloseFile, handleEditorDidCloseFile, activeFileHistory);
yield* put(editorDidCreate());
// this should restore all previously open files in the same order
// the were last used (which may be different from the order in which
// they were originally opened)
for (const item of activeFileHistory.getFromStorage()) {
yield* put(editorActivateFile(item));
}
}
function* monitorEditors(): Generator {
@@ -94,7 +250,13 @@ function* monitorEditors(): Generator {
return () => subscription.dispose();
});
yield* takeEvery(ch, handleDidCreateEditor);
try {
yield* takeEvery(ch, handleDidCreateEditor);
yield* take('__never__');
} finally {
ch.close();
}
}
export default function* (): Generator {
+3 -1
View File
@@ -6,5 +6,7 @@
"paste": "Paste",
"selectAll": "Select All",
"undo": "Undo",
"redo": "Redo"
"redo": "Redo",
"closeFile": { "tooltip": "Close {fileName}" },
"contextMenu": { "label": "Editor context menu" }
}
+10
View File
@@ -14,6 +14,16 @@ declare module 'react-monaco-editor' {
// https://github.com/microsoft/vscode/blob/d54c705f6567958a732ac88b1c3ec4d2303fb026/src/vs/editor/common/model.ts#L1148
canRedo: () => boolean;
}
export interface ICodeEditor {
// null is allowed:
// https://github.com/microsoft/vscode/blob/b57db0fc49ee5777a4e02e8936f5b8cb9fb6182d/src/vs/editor/browser/widget/codeEditorWidget.ts#L995
// TODO: can be removed when https://github.com/microsoft/vscode/pull/146866 is merged
/**
* Restores the view state of the editor from a serializable object generated by `saveViewState`.
*/
restoreViewState(state: ICodeEditorViewState | null): void;
}
}
}
}
+1
View File
@@ -13,6 +13,7 @@ export enum I18nId {
BleGattServiceNotFound = 'ble.gattServiceNotFound',
BleNoWebBluetooth = 'ble.noWebBluetooth',
BleNoBluetooth = 'ble.noBluetooth',
EditorFailedToOpenFile = 'editor.failedToOpenFile',
EditorFailedToSaveFile = 'editor.failedToSaveFile',
ExplorerDeleteFileMessage = 'explorer.deleteFile.message',
ExplorerDeleteFileAction = 'explorer.deleteFile.action',
+2
View File
@@ -16,6 +16,7 @@ import {
BleDeviceFailToConnectReasonType,
didFailToConnect as bleDidFailToConnect,
} from '../ble/actions';
import { editorDidFailToOpenFile } from '../editor/actions';
import {
explorerDeleteFile,
explorerDidFailToArchiveAllFiles,
@@ -116,6 +117,7 @@ test.each([
explorerDidFailToImportFiles(new Error('test error')),
explorerDidFailToCreateNewFile(new Error('test error')),
explorerDidFailToExportFile('test.file', new Error('test error')),
editorDidFailToOpenFile('test.file', new Error('test error')),
])('actions that should show notification: %o', async (action: AnyAction) => {
const { toaster, saga } = createTestToasterSaga();
+9
View File
@@ -17,6 +17,7 @@ import {
BleDeviceFailToConnectReasonType,
didFailToConnect as bleDeviceDidFailToConnect,
} from '../ble/actions';
import { editorDidFailToOpenFile } from '../editor/actions';
import {
explorerDeleteFile,
explorerDidFailToArchiveAllFiles,
@@ -461,6 +462,13 @@ function* showExplorerFailToExport(
yield* showUnexpectedError(I18nId.ExplorerFailedToExport, action.error);
}
function* showEditorDidFailToOpenFile(
action: ReturnType<typeof explorerDidFailToExportFile>,
): Generator {
// TODO: add a better error message for the case where a file is already in use
yield* showUnexpectedError(I18nId.EditorFailedToOpenFile, action.error);
}
export default function* (): Generator {
yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError);
yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError);
@@ -477,4 +485,5 @@ export default function* (): Generator {
yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles);
yield* takeEvery(explorerDidFailToCreateNewFile, showExplorerFailToCreateFile);
yield* takeEvery(explorerDidFailToExportFile, showExplorerFailToExport);
yield* takeEvery(editorDidFailToOpenFile, showEditorDidFailToOpenFile);
}
+1
View File
@@ -13,6 +13,7 @@
"unexpectedError": "Unexpected error while trying to connect: {errorMessage}"
},
"editor": {
"failedToOpenFile": "Failed to open file.",
"failedToSaveFile": "Failed to save the program."
},
"explorer": {
+6 -1
View File
@@ -54,6 +54,11 @@ const specialCases: Record<string, string> = {
};
function addWhichToKeyboardEvent(e: KeyboardEvent) {
// blueprints and testing-library both don't do this one
if (e.key === 'ContextMenu') {
return;
}
const blueprintsKeyName = specialCases[e.key] ?? e.key.toLowerCase();
let which = 0;
@@ -65,7 +70,7 @@ function addWhichToKeyboardEvent(e: KeyboardEvent) {
}
if (which === 0) {
console.warn('unsupported key:', e.key);
console.warn('unsupported key:', e.key, e.code);
}
Object.defineProperty(e, 'which', { value: which });
+7
View File
@@ -80,6 +80,13 @@ export class AsyncSaga {
}
}
/**
* Cancel the saga. Useful for testing task cancellation.
*/
public cancel(): void {
this.task.cancel();
}
public async end(): Promise<void> {
this.task.cancel();
await this.task.toPromise();