diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 867d55ce..33d6b36b 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -5,7 +5,7 @@ import { monaco } from 'react-monaco-editor'; import { createAction } from '../actions'; /** - * Sets the current (active) edit session. + * Requests to set the current (active) edit session. * @param editSession The new edit session. */ export const setEditSession = createAction( @@ -15,6 +15,17 @@ export const setEditSession = createAction( }), ); +/** + * Indicates that setting the edit session has completed. + * @param editSession The new edit session. + */ +export const didSetEditSession = createAction( + (editSession: monaco.editor.ICodeEditor | undefined) => ({ + type: 'editor.action.didSetEditSession', + editSession, + }), +); + /** * Creates an action to save the current file */ diff --git a/src/editor/reducers.test.ts b/src/editor/reducers.test.ts index 48d88bf7..070c0c9b 100644 --- a/src/editor/reducers.test.ts +++ b/src/editor/reducers.test.ts @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { monaco } from 'react-monaco-editor'; import { AnyAction } from 'redux'; -import { setEditSession } from './actions'; +import { didSetEditSession, setEditSession } from './actions'; import reducers from './reducers'; type State = ReturnType; @@ -18,10 +18,18 @@ test('initial state', () => { test('current', () => { const session = {} as monaco.editor.ICodeEditor; + + // setEditSession doesn't change the state expect(reducers({ current: null } as State, setEditSession(session)).current).toBe( - session, + null, ); + + // only didSetEditSession changes the state expect( - reducers({ current: session } as State, setEditSession(undefined)).current, + reducers({ current: null } as State, didSetEditSession(session)).current, + ).toBe(session); + + expect( + reducers({ current: session } as State, didSetEditSession(undefined)).current, ).toBe(null); }); diff --git a/src/editor/reducers.ts b/src/editor/reducers.ts index e6b8fe65..c3c8d51e 100644 --- a/src/editor/reducers.ts +++ b/src/editor/reducers.ts @@ -3,10 +3,10 @@ import { monaco } from 'react-monaco-editor'; import { Reducer, combineReducers } from 'redux'; -import { setEditSession } from './actions'; +import { didSetEditSession } from './actions'; const current: Reducer = (state = null, action) => { - if (setEditSession.matches(action)) { + if (didSetEditSession.matches(action)) { return action.editSession || null; } diff --git a/src/editor/sagas.test.ts b/src/editor/sagas.test.ts index d95143bf..afa73f0f 100644 --- a/src/editor/sagas.test.ts +++ b/src/editor/sagas.test.ts @@ -1,11 +1,23 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import FileSaver from 'file-saver'; import { mock } from 'jest-mock-extended'; import { monaco } from 'react-monaco-editor'; import { AsyncSaga } from '../../test'; -import { didFailToSaveAs, didSaveAs, open, saveAs } from './actions'; +import { + fileStorageDidInitialize, + fileStorageDidReadFile, + fileStorageReadFile, +} from '../fileStorage/actions'; +import { + didFailToSaveAs, + didSaveAs, + didSetEditSession, + open, + saveAs, + setEditSession, +} from './actions'; import editor from './sagas'; jest.mock('react-monaco-editor'); @@ -123,3 +135,54 @@ describe('saveAs', () => { mockFileSaverSaveAs.mockRestore(); }); }); + +describe('setEditSession', () => { + it('should wait for storage to be initialized', async () => { + const mockEditor = mock(); + const saga = new AsyncSaga(editor, { + fileStorage: { isInitialized: false, fileNames: new Set() }, + }); + + saga.put(setEditSession(mockEditor)); + saga.put(fileStorageDidInitialize([])); + + const action = await saga.take(); + expect(action).toEqual(didSetEditSession(mockEditor)); + + await saga.end(); + }); + + it('should load main.py', async () => { + const mockEditor = mock(); + const saga = new AsyncSaga(editor, { + fileStorage: { isInitialized: true, fileNames: new Set(['main.py']) }, + }); + + saga.put(setEditSession(mockEditor)); + + const action = await saga.take(); + expect(action).toEqual(fileStorageReadFile('main.py')); + + saga.put(fileStorageDidReadFile('main.py', '# test file')); + + const action2 = await saga.take(); + expect(action2).toEqual(didSetEditSession(mockEditor)); + expect(mockEditor.setValue).toHaveBeenCalled(); + + await saga.end(); + }); + + it('should not raise error if main.py does not exist', async () => { + const mockEditor = mock(); + const saga = new AsyncSaga(editor, { + fileStorage: { isInitialized: true, fileNames: new Set() }, + }); + + saga.put(setEditSession(mockEditor)); + + const action = await saga.take(); + expect(action).toEqual(didSetEditSession(mockEditor)); + + await saga.end(); + }); +}); diff --git a/src/editor/sagas.ts b/src/editor/sagas.ts index 69a3678a..f977bdff 100644 --- a/src/editor/sagas.ts +++ b/src/editor/sagas.ts @@ -19,7 +19,14 @@ import { } from '../fileStorage/actions'; import { RootState } from '../reducers'; import { ensureError } from '../utils'; -import { didFailToSaveAs, didSaveAs, open, saveAs, setEditSession } from './actions'; +import { + didFailToSaveAs, + didSaveAs, + didSetEditSession, + open, + saveAs, + setEditSession, +} from './actions'; const decoder = new TextDecoder(); @@ -86,8 +93,10 @@ function* handleSaveAs(): Generator { } function* handleSetEditSession(action: ReturnType): Generator { - if (action.editSession === null) { - // there is not current edit session, nothing to do + if (action.editSession === undefined) { + // REVISIT: this should probably do something, but currently we don't + // expect this to happen + yield* put(didSetEditSession(action.editSession)); return; } @@ -104,6 +113,14 @@ function* handleSetEditSession(action: ReturnType): Gener // TODO: get current file from state const currentFileName = 'main.py'; + const fileList = yield* select((s: RootState) => s.fileStorage.fileNames); + + if (!fileList.has(currentFileName)) { + // The file doesn't exist in storage, so don't try to open it. + yield* put(didSetEditSession(action.editSession)); + return; + } + // TODO: implement locking to ensure exclusive access to file yield* put(fileStorageReadFile(currentFileName)); @@ -117,8 +134,10 @@ function* handleSetEditSession(action: ReturnType): Gener }); if (result) { - action.editSession?.setValue(result.fileContents); + action.editSession.setValue(result.fileContents); } + + yield* put(didSetEditSession(action.editSession)); } export default function* (): Generator { diff --git a/src/fileStorage/actions.ts b/src/fileStorage/actions.ts index 63e00b62..f6cf0c2c 100644 --- a/src/fileStorage/actions.ts +++ b/src/fileStorage/actions.ts @@ -3,9 +3,13 @@ import { createAction } from '../actions'; -/** Action that indicates that the storage backend is ready to use. */ -export const fileStorageDidInitialize = createAction(() => ({ +/** + * Action that indicates that the storage backend is ready to use. + * @param fileNames List of all files currently in storage. + */ +export const fileStorageDidInitialize = createAction((fileNames: string[]) => ({ type: 'fileStorage.action.didInitialize', + fileNames, })); /** Action that indicates that the storage backend failed to initialize. */ diff --git a/src/fileStorage/reducers.test.ts b/src/fileStorage/reducers.test.ts index cd01389a..cc7e2017 100644 --- a/src/fileStorage/reducers.test.ts +++ b/src/fileStorage/reducers.test.ts @@ -22,7 +22,7 @@ test('initial state', () => { test('isInitialized', () => { expect( - reducers({ isInitialized: false } as State, fileStorageDidInitialize()) + reducers({ isInitialized: false } as State, fileStorageDidInitialize([])) .isInitialized, ).toBeTruthy(); }); @@ -30,6 +30,14 @@ test('isInitialized', () => { test('fileNames', () => { const testFileName = 'test.file'; + // initialization populates file list + expect( + reducers( + { fileNames: new Set() } as State, + fileStorageDidInitialize([testFileName]), + ).fileNames, + ).toEqual(new Set([testFileName])); + // if item is not in set, add it expect( reducers( diff --git a/src/fileStorage/reducers.ts b/src/fileStorage/reducers.ts index fd624628..5986405a 100644 --- a/src/fileStorage/reducers.ts +++ b/src/fileStorage/reducers.ts @@ -17,6 +17,10 @@ const isInitialized: Reducer = (state = false, action) => { }; const fileNames: Reducer> = (state = new Set(), action) => { + if (fileStorageDidInitialize.matches(action)) { + return new Set(action.fileNames); + } + if (fileStorageDidChangeItem.matches(action)) { return new Set([...state, action.fileName]); } diff --git a/src/fileStorage/sagas.test.ts b/src/fileStorage/sagas.test.ts index 414fa2e5..7b05a712 100644 --- a/src/fileStorage/sagas.test.ts +++ b/src/fileStorage/sagas.test.ts @@ -29,15 +29,12 @@ it('should migrate old program from local storage during initialization', async const saga = new AsyncSaga(fileStorage); - // initialization should remove the localStorage entry - let action = await saga.take(); - expect(action).toEqual(fileStorageDidInitialize()); + // initialization should remove the localStorage entry and add add it to + // new storage backend + const action = await saga.take(); + expect(action).toEqual(fileStorageDidInitialize(['main.py'])); expect(localStorage.getItem(oldProgramKey)).toBeNull(); - // and add it to the new storage backend - action = await saga.take(); - expect(action).toEqual(fileStorageDidChangeItem('main.py')); - await saga.end(); }); @@ -46,7 +43,7 @@ it('should read and write files', async () => { let action = await saga.take(); - expect(action).toEqual(fileStorageDidInitialize()); + expect(action).toEqual(fileStorageDidInitialize([])); const testFileName = 'test.file'; const testFileContents = 'test file contents'; @@ -75,7 +72,7 @@ it('should dispatch fail action if file does not exist', async () => { const saga = new AsyncSaga(fileStorage); let action = await saga.take(); - expect(action).toEqual(fileStorageDidInitialize()); + expect(action).toEqual(fileStorageDidInitialize([])); const testFileName = 'test.file'; diff --git a/src/fileStorage/sagas.ts b/src/fileStorage/sagas.ts index 3158c266..e12f0bb2 100644 --- a/src/fileStorage/sagas.ts +++ b/src/fileStorage/sagas.ts @@ -96,13 +96,23 @@ function* initialize(): Generator { yield* call(() => files.ready()); + // migrate from old storage + + // Previous versions of pybricks code used local storage to save a single program. + const oldProgram = localStorage.getItem('program'); + + if (oldProgram !== null) { + yield* call(() => files.setItem('main.py', oldProgram)); + localStorage.removeItem('program'); + } + + // wire storage observable to redux-sagas + files.configObservables({ crossTabNotification: true, crossTabChangeDetection: true, }); - // wire storage observable to redux-sagas - const localForageChannel = eventChannel((emit) => { const filesObservable = files.newObservable({ crossTabNotification: true, @@ -121,17 +131,9 @@ function* initialize(): Generator { yield* takeEvery(fileStorageReadFile, handleReadFile, files); yield* takeEvery(fileStorageWriteFile, handleWriteFile, files); - // migrate from old storage + const fileNames = yield* call(() => files.keys()); - // Previous versions of pybricks code used local storage to save a single program. - const oldProgram = localStorage.getItem('program'); - - if (oldProgram !== null) { - yield* call(() => files.setItem('main.py', oldProgram)); - localStorage.removeItem('program'); - } - - yield* put(fileStorageDidInitialize()); + yield* put(fileStorageDidInitialize(fileNames)); } catch (err) { yield* put(fileStorageDidFailToInitialize(ensureError(err))); }