mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 17:45:22 +00:00
editor: add tabs for controlling open/active files
This commit is contained in:
+122
-29
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,4 +12,6 @@ export enum I18nId {
|
||||
SelectAll = 'selectAll',
|
||||
Undo = 'undo',
|
||||
Redo = 'redo',
|
||||
CloseFileTooltip = 'closeFile.tooltip',
|
||||
ContextMenuLabel = 'contextMenu.label',
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: '#',
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
"paste": "Paste",
|
||||
"selectAll": "Select All",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo"
|
||||
"redo": "Redo",
|
||||
"closeFile": { "tooltip": "Close {fileName}" },
|
||||
"contextMenu": { "label": "Editor context menu" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user