(
+ () => ({
+ 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 (
- editor?.layout()}>
- }
- popoverProps={{ onClosed: () => editor?.focus() }}
- >
-
-
-
+
+ editor?.focus()} i18n={i18n} />
+ editor?.layout()}>
+ }
+ popoverProps={popoverProps}
+ >
+
+
+
+
);
};
diff --git a/src/editor/actions.ts b/src/editor/actions.ts
index dff40ca5..b1e075a8 100644
--- a/src/editor/actions.ts
+++ b/src/editor/actions.ts
@@ -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.
diff --git a/src/editor/editor.scss b/src/editor/editor.scss
index b3f77b17..9a1d8f9b 100644
--- a/src/editor/editor.scss
+++ b/src/editor/editor.scss
@@ -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;
diff --git a/src/editor/i18n.ts b/src/editor/i18n.ts
index bc55aee8..f4a08fef 100644
--- a/src/editor/i18n.ts
+++ b/src/editor/i18n.ts
@@ -12,4 +12,6 @@ export enum I18nId {
SelectAll = 'selectAll',
Undo = 'undo',
Redo = 'redo',
+ CloseFileTooltip = 'closeFile.tooltip',
+ ContextMenuLabel = 'contextMenu.label',
}
diff --git a/src/editor/lib.test.ts b/src/editor/lib.test.ts
new file mode 100644
index 00000000..a8130d30
--- /dev/null
+++ b/src/editor/lib.test.ts
@@ -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();
+
+ manager.add('test.file', 0 as FD, model, null);
+
+ expect(manager.has('test.file')).toBeTruthy();
+ expect(manager.get('test.file')).toEqual({
+ 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();
+ const viewState = mock();
+
+ 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);
+ });
+});
diff --git a/src/editor/lib.ts b/src/editor/lib.ts
new file mode 100644
index 00000000..0c6bdca3
--- /dev/null
+++ b/src/editor/lib.ts
@@ -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();
+ 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 {
+ 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 = 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;
+ }
+}
diff --git a/src/editor/pybricksMicroPython.ts b/src/editor/pybricksMicroPython.ts
index c47e938c..60b82307 100644
--- a/src/editor/pybricksMicroPython.ts
+++ b/src/editor/pybricksMicroPython.ts
@@ -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: '#',
diff --git a/src/editor/reducers.test.ts b/src/editor/reducers.test.ts
new file mode 100644
index 00000000..849d8b6c
--- /dev/null
+++ b/src/editor/reducers.test.ts
@@ -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;
+
+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([]);
+ });
+});
diff --git a/src/editor/reducers.ts b/src/editor/reducers.ts
index 7a711942..513e1522 100644
--- a/src/editor/reducers.ts
+++ b/src/editor/reducers.ts
@@ -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 = (state = false, action) => {
@@ -13,4 +18,30 @@ const isReady: Reducer = (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 = (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 = (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 });
diff --git a/src/editor/sagas.test.ts b/src/editor/sagas.test.ts
new file mode 100644
index 00000000..a985e042
--- /dev/null
+++ b/src/editor/sagas.test.ts
@@ -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(),
+ );
+ 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(),
+ );
+ 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();
+ });
+});
diff --git a/src/editor/sagas.ts b/src/editor/sagas.ts
index fcc3ab0f..585841c4 100644
--- a/src/editor/sagas.ts
+++ b/src/editor/sagas.ts
@@ -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,
+): 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,
+): 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,
+): 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 {
diff --git a/src/editor/translations/en.json b/src/editor/translations/en.json
index 76b7742c..b8f45a63 100644
--- a/src/editor/translations/en.json
+++ b/src/editor/translations/en.json
@@ -6,5 +6,7 @@
"paste": "Paste",
"selectAll": "Select All",
"undo": "Undo",
- "redo": "Redo"
+ "redo": "Redo",
+ "closeFile": { "tooltip": "Close {fileName}" },
+ "contextMenu": { "label": "Editor context menu" }
}
diff --git a/src/monaco-extension.d.ts b/src/monaco-extension.d.ts
index e8520fd0..ab209b1f 100644
--- a/src/monaco-extension.d.ts
+++ b/src/monaco-extension.d.ts
@@ -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;
+ }
}
}
}
diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts
index 779f846e..dd8bf92b 100644
--- a/src/notifications/i18n.ts
+++ b/src/notifications/i18n.ts
@@ -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',
diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts
index 90688d7e..523fff64 100644
--- a/src/notifications/sagas.test.ts
+++ b/src/notifications/sagas.test.ts
@@ -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();
diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts
index 1db475e5..9f8f93d6 100644
--- a/src/notifications/sagas.ts
+++ b/src/notifications/sagas.ts
@@ -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,
+): 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);
}
diff --git a/src/notifications/translations/en.json b/src/notifications/translations/en.json
index 6095537a..83db5cc2 100644
--- a/src/notifications/translations/en.json
+++ b/src/notifications/translations/en.json
@@ -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": {
diff --git a/src/setupTests.ts b/src/setupTests.ts
index e54db6c7..c57ff374 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -54,6 +54,11 @@ const specialCases: Record = {
};
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 });
diff --git a/test/index.tsx b/test/index.tsx
index dcac9ca0..ae66ea2b 100644
--- a/test/index.tsx
+++ b/test/index.tsx
@@ -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 {
this.task.cancel();
await this.task.toPromise();