From 8ac77560fb1a44d6d85d8d107b764dd24c47ee8d Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 7 Apr 2022 21:17:09 -0500 Subject: [PATCH] editor: add tabs for controlling open/active files --- src/editor/Editor.test.tsx | 151 +++++++++++--- src/editor/Editor.tsx | 267 ++++++++++++++++++------ src/editor/actions.ts | 53 +++++ src/editor/editor.scss | 10 +- src/editor/i18n.ts | 2 + src/editor/lib.test.ts | 133 ++++++++++++ src/editor/lib.ts | 187 +++++++++++++++++ src/editor/pybricksMicroPython.ts | 3 + src/editor/reducers.test.ts | 49 +++++ src/editor/reducers.ts | 35 +++- src/editor/sagas.test.ts | 274 +++++++++++++++++++++++++ src/editor/sagas.ts | 202 ++++++++++++++++-- src/editor/translations/en.json | 4 +- src/monaco-extension.d.ts | 10 + src/notifications/i18n.ts | 1 + src/notifications/sagas.test.ts | 2 + src/notifications/sagas.ts | 9 + src/notifications/translations/en.json | 1 + src/setupTests.ts | 7 +- test/index.tsx | 7 + 20 files changed, 1289 insertions(+), 118 deletions(-) create mode 100644 src/editor/lib.test.ts create mode 100644 src/editor/lib.ts create mode 100644 src/editor/reducers.test.ts create mode 100644 src/editor/sagas.test.ts diff --git a/src/editor/Editor.test.tsx b/src/editor/Editor.test.tsx index 32406c66..9d6541fc 100644 --- a/src/editor/Editor.test.tsx +++ b/src/editor/Editor.test.tsx @@ -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: { openFiles: ['test.file'] }, + }); -it('should focus the text area', () => { - const [editor] = testRender(); + 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(); + it('should dispatch action when close button is clicked', async () => { + const [editor, dispatch] = testRender(, { + 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(); + 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((resolve) => + monaco.editor.onDidCreateEditor(resolve), + ); - expect(editor.getByText('Copy')).toBeInTheDocument(); + [editor] = testRender(); + 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(); }); }); diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 4fe74159..ffc9a504 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -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(); @@ -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 ( + {label}} + 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 = ( editor, i18n, }) => { - const hasEditor = editor !== null; - const selection = editor?.getSelection(); const hasSelection = selection && !selection.isEmpty(); @@ -82,62 +131,108 @@ const EditorContextMenu: React.VoidFunctionComponent = ( const canRedo = model && model.canRedo(); return ( - - { - editor?.focus(); - editor?.trigger(null, 'editor.action.clipboardCopyAction', null); - }} - text={i18n.translate(I18nId.Copy)} + + - { - editor?.focus(); - editor?.trigger(null, 'editor.action.clipboardPasteAction', null); - }} - text={i18n.translate(I18nId.Paste)} + - { - editor?.focus(); - editor?.trigger(null, 'editor.action.selectAll', null); - }} - text={i18n.translate(I18nId.SelectAll)} + - { - editor?.focus(); - editor?.trigger(null, 'undo', null); - }} - text={i18n.translate(I18nId.Undo)} + - { - editor?.focus(); - editor?.trigger(null, 'redo', null); - }} - text={i18n.translate(I18nId.Redo)} + ); }; +type EditorTabsProps = Readonly<{ + /** Called when the selected tab changes. */ + onChange?: () => void; + /** Translation context. */ + i18n: I18n; +}>; + +const EditorTabs: React.VoidFunctionComponent = ({ + 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 ( + + {openFiles.map((fileName, i) => ( + + {fileName} +