diff --git a/CHANGELOG.md b/CHANGELOG.md index 797ddc16..058e6b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### Changed +- Changed file storage backend. + ### Fixed - Fix tooltips not closing when expected ([pybricks-code#275]). diff --git a/package.json b/package.json index 2f6d0d33..c82a29d1 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "file-saver": "^2.0.5", "jszip": "^3.7.1", "license-webpack-plugin": "^3.0.0", + "localforage": "^1.10.0", + "localforage-observable": "^2.1.1", "monaco-editor": "^0.30.1", "monaco-editor-webpack-plugin": "^6.0.0", "monaco-themes": "^0.4.0", diff --git a/src/actions.ts b/src/actions.ts index 33b1a6b3..dd4ea67b 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { useDispatch as useReduxDispatch } from 'react-redux'; import { Dispatch as ReduxDispatch } from 'redux'; @@ -13,6 +13,7 @@ import { } from './ble-pybricks-service/actions'; import { BLEAction, BLEConnectAction } from './ble/actions'; import { EditorAction } from './editor/actions'; +import { FileStorageAction } from './fileStorage/actions'; import { FlashFirmwareAction } from './firmware/actions'; import { HubAction, HubMessageAction } from './hub/actions'; import { LicenseAction } from './licenses/actions'; @@ -46,6 +47,7 @@ export type Action = | BootloaderDidFailToRequestAction | BootloaderRequestAction | BootloaderResponseAction + | FileStorageAction | EditorAction | FlashFirmwareAction | HubAction diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 99f777cf..e014ca55 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -10,16 +10,17 @@ import { import { useI18n } from '@shopify/react-i18n'; import tomorrowNightEightiesTheme from 'monaco-themes/themes/Tomorrow-Night-Eighties.json'; import xcodeTheme from 'monaco-themes/themes/Xcode_default.json'; -import React, { useEffect, useRef } from 'react'; +import React, { useRef } from 'react'; import MonacoEditor, { monaco } from 'react-monaco-editor'; import { IDisposable } from 'xterm'; import { useDispatch } from '../actions'; +import { fileStorageWriteFile } from '../fileStorage/actions'; import { compile } from '../mpy/actions'; import { useSelector } from '../reducers'; import { toggleBoolean } from '../settings/actions'; import { BooleanSettingId } from '../settings/defaults'; import { isMacOS } from '../utils/os'; -import { setEditSession, storageChanged } from './actions'; +import { setEditSession } from './actions'; import { EditorStringId } from './i18n'; import en from './i18n.en.json'; import * as pybricksMicroPython from './pybricksMicroPython'; @@ -128,21 +129,6 @@ const Editor: React.FunctionComponent = (_props) => { const editorRef = useRef(null); const dispatch = useDispatch(); - const onStorage = (e: StorageEvent): void => { - if ( - e.key === 'program' && - e.newValue && - e.newValue !== editorRef.current?.editor?.getValue() - ) { - dispatch(storageChanged(e.newValue)); - } - }; - - useEffect(() => { - window.addEventListener('storage', onStorage); - return () => window.removeEventListener('storage', onStorage); - }); - const darkMode = useSelector((s) => s.settings.darkMode); const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en }); @@ -166,7 +152,6 @@ const Editor: React.FunctionComponent = (_props) => { contextmenu: false, rulers: [80], }} - value={localStorage.getItem('program')} editorDidMount={(editor, _monaco) => { const subscriptions = new Array(); // FIXME: editor does not respond to changes in i18n @@ -221,7 +206,8 @@ const Editor: React.FunctionComponent = (_props) => { editor.focus(); dispatch(setEditSession(editor)); }} - onChange={(v) => localStorage.setItem('program', v)} + // REVIST: need to ensure we have exclusive access to file + onChange={(v) => dispatch(fileStorageWriteFile('main.py', v))} /> diff --git a/src/editor/actions.ts b/src/editor/actions.ts index f258e6eb..ce25fade 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -15,10 +15,6 @@ export enum EditorActionType { DidFailToSaveAs = 'editor.action.didFailToSaveAs', /** Open a file. */ Open = 'editor.action.open', - /** Storage was changed outside of the app. */ - StorageChanged = 'editor.action.storageChanged', - /** Reload program from local storage. */ - ReloadProgram = 'editor.action.reloadProgram', } export type CurrentEditorAction = Action & { @@ -81,27 +77,6 @@ export function open(data: ArrayBuffer): EditorOpenAction { return { type: EditorActionType.Open, data }; } -/**Action that indicates the local storage has changed. */ -export type EditorStorageChangedAction = Action & { - newValue: string; -}; - -/** - * Creates an action that indicates the local storage has changed. - * @param newValue The new program. - */ -export function storageChanged(newValue: string): EditorStorageChangedAction { - return { type: EditorActionType.StorageChanged, newValue }; -} - -/** Action to request reloading the program from local storage. */ -export type EditorReloadProgramAction = Action; - -/** Creates and action to request reloading the program from local storage. */ -export function reloadProgram(): EditorReloadProgramAction { - return { type: EditorActionType.ReloadProgram }; -} - /** * Common type for all editor actions. */ @@ -110,6 +85,4 @@ export type EditorAction = | EditorOpenAction | EditorSaveAsAction | EditorDidSaveAsAction - | EditorDidFailToSaveAsAction - | EditorStorageChangedAction - | EditorReloadProgramAction; + | EditorDidFailToSaveAsAction; diff --git a/src/editor/sagas.test.ts b/src/editor/sagas.test.ts index ffe67216..d95143bf 100644 --- a/src/editor/sagas.test.ts +++ b/src/editor/sagas.test.ts @@ -5,7 +5,7 @@ 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, reloadProgram, saveAs } from './actions'; +import { didFailToSaveAs, didSaveAs, open, saveAs } from './actions'; import editor from './sagas'; jest.mock('react-monaco-editor'); @@ -123,14 +123,3 @@ describe('saveAs', () => { mockFileSaverSaveAs.mockRestore(); }); }); - -test('reloadProgram', async () => { - const mockEditor = mock(); - const saga = new AsyncSaga(editor, { editor: { current: mockEditor } }); - - saga.put(reloadProgram()); - - expect(mockEditor.setValue).toHaveBeenCalled(); - - await saga.end(); -}); diff --git a/src/editor/sagas.ts b/src/editor/sagas.ts index 49a47ca3..696f99eb 100644 --- a/src/editor/sagas.ts +++ b/src/editor/sagas.ts @@ -1,14 +1,29 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import FileSaver from 'file-saver'; -import { call, put, select, takeEvery } from 'typed-redux-saga/macro'; +import { + call, + put, + race, + select, + take, + takeEvery, + takeLatest, +} from 'typed-redux-saga/macro'; +import { Action } from '../actions'; +import { + FileStorageActionType, + FileStorageDidFailToReadFileAction, + FileStorageDidReadFileAction, + fileStorageReadFile, +} from '../fileStorage/actions'; import { RootState } from '../reducers'; import { ensureError } from '../utils'; import { + CurrentEditorAction, EditorActionType, EditorOpenAction, - EditorReloadProgramAction, EditorSaveAsAction, didFailToSaveAs, didSaveAs, @@ -78,20 +93,48 @@ function* saveAs(_action: EditorSaveAsAction): Generator { yield* put(didSaveAs()); } -function* reloadProgram(_action: EditorReloadProgramAction): Generator { - const editor = yield* select((s: RootState) => s.editor.current); - - // istanbul ignore next: it is a bug to dispatch this action with no current editor - if (editor === null) { - console.error('reloadProgram: No current editor'); +function* handleEditSession(action: CurrentEditorAction): Generator { + if (action.editSession === null) { + // there is not current edit session, nothing to do return; } - editor.setValue(localStorage.getItem('program') || ''); + // ensure storage has been initialized + + const isStorageInitialized = yield* select( + (s: RootState) => s.fileStorage.isInitialized, + ); + + if (!isStorageInitialized) { + yield* take(FileStorageActionType.DidInitialize); + } + + // TODO: get current file from state + const currentFileName = 'main.py'; + + // TODO: implement locking to ensure exclusive access to file + + yield* put(fileStorageReadFile(currentFileName)); + const { result } = yield* race({ + result: take( + (a: Action) => + a.type === FileStorageActionType.DidReadFile && + a.fileName === currentFileName, + ), + error: take( + (a: Action) => + a.type === FileStorageActionType.DidFailToReadFile && + a.fileName === currentFileName, + ), + }); + + if (result) { + action.editSession?.setValue(result.fileContents); + } } export default function* (): Generator { yield* takeEvery(EditorActionType.Open, open); yield* takeEvery(EditorActionType.SaveAs, saveAs); - yield* takeEvery(EditorActionType.ReloadProgram, reloadProgram); + yield* takeLatest(EditorActionType.Current, handleEditSession); } diff --git a/src/fileStorage/actions.ts b/src/fileStorage/actions.ts new file mode 100644 index 00000000..a3b5f392 --- /dev/null +++ b/src/fileStorage/actions.ts @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Action } from 'redux'; + +export enum FileStorageActionType { + /** Action that indicates that the storage backend is ready to use. */ + DidInitialize = 'fileStorage.action.didInitialize', + /** Action that indicates that the storage backend failed to initialize. */ + DidFailToInitialize = 'fileStorage.action.didFailToInitialize', + /** Action that indicates that an item in the storage was created or changed by us or in another tab. */ + DidChangeItem = 'fileStorage.action.didChangeItem', + /** Action that indicates that an item in the storage was removed by us or in another tab. */ + DidRemoveItem = 'fileStorage.action.didRemoveItem', + /** Requests to read a file from storage. */ + ReadFile = 'fileStorage.action.readFile', + /** Response to read file request indicating success. */ + DidReadFile = 'fileStorage.action.didReadFile', + /** Response to read file request indicating failure. */ + DidFailToReadFile = 'fileStorage.action.didFailToReadFile', + /** Requests to write a file to storage. */ + WriteFile = 'fileStorage.action.writeFile', + /** Response to write file request indicating success. */ + DidWriteFile = 'fileStorage.action.didWriteFile', + /** Response to write file request indicating failure. */ + DidFailToWriteFile = 'fileStorage.action.didFailToWriteFile', +} + +/** Action that indicates that the storage backend is ready to use. */ +export type FileStorageDidInitializeAction = + Action; + +/** Action that indicates that the storage backend is ready to use. */ +export function fileStorageDidInitialize(): FileStorageDidInitializeAction { + return { type: FileStorageActionType.DidInitialize }; +} + +/** Action that indicates that the storage backend failed to initialize. */ +export type FileStorageDidFailToInitializeAction = + Action & { error: Error }; + +/** Action that indicates that the storage backend failed to initialize. */ +export function fileStorageDidFailToInitialize( + err: Error, +): FileStorageDidFailToInitializeAction { + return { type: FileStorageActionType.DidFailToInitialize, error: err }; +} + +/** Action that indicates that an item in the storage was created or changed by us or in another tab. */ +export type FileStorageDidChangeItemAction = + Action & { + fileName: string; + }; + +/** Action that indicates that an item in the storage was created or changed by us or in another tab. */ +export function fileStorageDidChangeItem( + fileName: string, +): FileStorageDidChangeItemAction { + return { type: FileStorageActionType.DidChangeItem, fileName }; +} + +/** Action that indicates that an item in the storage was removed by us or in another tab. */ +export type FileStorageDidRemoveItemAction = + Action & { + fileName: string; + }; + +/** Action that indicates that an item in the storage was removed by us or in another tab. */ +export function fileStorageDidRemoveItem( + fileName: string, +): FileStorageDidRemoveItemAction { + return { type: FileStorageActionType.DidRemoveItem, fileName }; +} + +/** Requests to read a file from storage. */ +export type FileStorageReadFileAction = Action & { + fileName: string; +}; + +/** Requests to read a file from storage. */ +export function fileStorageReadFile(fileName: string): FileStorageReadFileAction { + return { type: FileStorageActionType.ReadFile, fileName }; +} + +/** Response to read file request indicating success. */ +export type FileStorageDidReadFileAction = Action & { + fileName: string; + fileContents: string; +}; + +/** Response to read file request indicating success. */ +export function fileStorageDidReadFile( + fileName: string, + fileContents: string, +): FileStorageDidReadFileAction { + return { type: FileStorageActionType.DidReadFile, fileName, fileContents }; +} + +/** Response to read file request indicating failure. */ +export type FileStorageDidFailToReadFileAction = + Action & { + fileName: string; + error: Error; + }; + +/** Response to read file request indicating failure. */ +export function fileStorageDidFailToReadFile( + fileName: string, + error: Error, +): FileStorageDidFailToReadFileAction { + return { type: FileStorageActionType.DidFailToReadFile, fileName, error }; +} + +/** Requests to write a file to storage. */ +export type FileStorageWriteFileAction = Action & { + fileName: string; + fileContents: string; +}; + +/** Requests to write a file to storage. */ +export function fileStorageWriteFile( + fileName: string, + fileContents: string, +): FileStorageWriteFileAction { + return { type: FileStorageActionType.WriteFile, fileName, fileContents }; +} + +/** Response to write file request indicating success. */ +export type FileStorageDidWriteFileAction = + Action & { + fileName: string; + }; + +/** Response to write file request indicating success. */ +export function fileStorageDidWriteFile( + fileName: string, +): FileStorageDidWriteFileAction { + return { type: FileStorageActionType.DidWriteFile, fileName }; +} + +/** Response to write file request indicating failure. */ +export type FileStorageDidFailToWriteFileAction = + Action & { + fileName: string; + error: Error; + }; + +/** Response to write file request indicating failure. */ +export function fileStorageDidFailToWriteFile( + fileName: string, + error: Error, +): FileStorageDidFailToWriteFileAction { + return { type: FileStorageActionType.DidFailToWriteFile, fileName, error }; +} + +/** + * Common type for all file storage actions. + */ +export type FileStorageAction = + | FileStorageDidInitializeAction + | FileStorageDidFailToInitializeAction + | FileStorageDidChangeItemAction + | FileStorageDidRemoveItemAction + | FileStorageReadFileAction + | FileStorageDidReadFileAction + | FileStorageDidFailToReadFileAction + | FileStorageWriteFileAction + | FileStorageDidWriteFileAction + | FileStorageDidFailToWriteFileAction; diff --git a/src/fileStorage/reducers.test.ts b/src/fileStorage/reducers.test.ts new file mode 100644 index 00000000..2587bb96 --- /dev/null +++ b/src/fileStorage/reducers.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Action } from '../actions'; +import { + fileStorageDidChangeItem, + fileStorageDidInitialize, + fileStorageDidRemoveItem, +} from './actions'; +import reducers from './reducers'; + +type State = ReturnType; + +test('initial state', () => { + expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(` + Object { + "fileNames": Set {}, + "isInitialized": false, + } + `); +}); + +test('isInitialized', () => { + expect( + reducers({ isInitialized: false } as State, fileStorageDidInitialize()) + .isInitialized, + ).toBeTruthy(); +}); + +test('fileNames', () => { + const testFileName = 'test.file'; + + // if item is not in set, add it + expect( + reducers( + { fileNames: new Set() } as State, + fileStorageDidChangeItem(testFileName), + ).fileNames, + ).toEqual(new Set([testFileName])); + + // if item is already in set, there should not be duplicates + expect( + reducers( + { fileNames: new Set([testFileName]) } as State, + fileStorageDidChangeItem(testFileName), + ).fileNames, + ).toEqual(new Set([testFileName])); + + // if item is in set, it should be removed + expect( + reducers( + { fileNames: new Set([testFileName]) } as State, + fileStorageDidRemoveItem(testFileName), + ).fileNames, + ).not.toContain(testFileName); +}); diff --git a/src/fileStorage/reducers.ts b/src/fileStorage/reducers.ts new file mode 100644 index 00000000..8fcdd3ee --- /dev/null +++ b/src/fileStorage/reducers.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Reducer, combineReducers } from 'redux'; +import { Action } from '../actions'; +import { FileStorageActionType } from './actions'; + +const isInitialized: Reducer = (state = false, action) => { + switch (action.type) { + case FileStorageActionType.DidInitialize: + return true; + default: + return state; + } +}; + +const fileNames: Reducer, Action> = (state = new Set(), action) => { + switch (action.type) { + case FileStorageActionType.DidChangeItem: + return new Set([...state, action.fileName]); + case FileStorageActionType.DidRemoveItem: + return new Set([...state].filter((value) => value !== action.fileName)); + default: + return state; + } +}; + +export default combineReducers({ isInitialized, fileNames }); diff --git a/src/fileStorage/sagas.test.ts b/src/fileStorage/sagas.test.ts new file mode 100644 index 00000000..af10aaa1 --- /dev/null +++ b/src/fileStorage/sagas.test.ts @@ -0,0 +1,85 @@ +import { AsyncSaga } from '../../test'; +import { + FileStorageActionType, + fileStorageDidChangeItem, + fileStorageDidInitialize, + fileStorageDidReadFile, + fileStorageDidWriteFile, + fileStorageReadFile, + fileStorageWriteFile, +} from './actions'; +import fileStorage from './sagas'; + +beforeEach(() => { + // localForge uses localStorage as backend in test environment, so we need + // to start with a clean slate in each test + localStorage.clear(); +}); + +it('should migrate old program from local storage during initialization', async () => { + const oldProgramKey = 'program'; + const oldProgramContents = '# test program'; + + // add item to localStorage to simulate an existing program + localStorage.setItem(oldProgramKey, oldProgramContents); + expect(localStorage.getItem(oldProgramKey)).toBe(oldProgramContents); + + const saga = new AsyncSaga(fileStorage); + + // initialization should remove the localStorage entry + let action = await saga.take(); + expect(action).toEqual(fileStorageDidInitialize()); + 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(); +}); + +it('should read and write files', async () => { + const saga = new AsyncSaga(fileStorage); + + let action = await saga.take(); + + expect(action).toEqual(fileStorageDidInitialize()); + + const testFileName = 'test.file'; + const testFileContents = 'test file contents'; + + // test writing a file + saga.put(fileStorageWriteFile(testFileName, testFileContents)); + + // writing file triggers response + action = await saga.take(); + expect(action).toEqual(fileStorageDidWriteFile(testFileName)); + + // and as a side-effect, triggers item change as well + action = await saga.take(); + expect(action).toEqual(fileStorageDidChangeItem(testFileName)); + + // test reading the same file back + saga.put(fileStorageReadFile(testFileName)); + + action = await saga.take(); + expect(action).toEqual(fileStorageDidReadFile(testFileName, testFileContents)); + + await saga.end(); +}); + +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()); + + const testFileName = 'test.file'; + + saga.put(fileStorageReadFile(testFileName)); + + action = await saga.take(); + expect(action).toHaveProperty('type', FileStorageActionType.DidFailToReadFile); + + await saga.end(); +}); diff --git a/src/fileStorage/sagas.ts b/src/fileStorage/sagas.ts new file mode 100644 index 00000000..5bc59715 --- /dev/null +++ b/src/fileStorage/sagas.ts @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import localForage from 'localforage'; +import { extendPrototype } from 'localforage-observable'; +import { eventChannel } from 'redux-saga'; +import { call, fork, put, takeEvery } from 'typed-redux-saga/macro'; +import Observable from 'zen-observable'; +import { ensureError } from '../utils'; +import { + FileStorageActionType, + FileStorageReadFileAction, + FileStorageWriteFileAction, + fileStorageDidChangeItem, + fileStorageDidFailToInitialize, + fileStorageDidFailToReadFile, + fileStorageDidFailToWriteFile, + fileStorageDidInitialize, + fileStorageDidReadFile, + fileStorageDidRemoveItem, + fileStorageDidWriteFile, +} from './actions'; + +/** + * Converts localForage change events to redux actions. + * @param change The storage change event. + */ +function* handleFileStorageDidChange(change: LocalForageObservableChange): Generator { + switch (change.methodName) { + case 'setItem': + if (change.success) { + yield* put(fileStorageDidChangeItem(change.key)); + } + break; + case 'removeItem': + if (change.success) { + yield* put(fileStorageDidRemoveItem(change.key)); + } + break; + } +} + +/** + * Handles requests to read a file. + * @param files The storage instance. + * @param action The requested action. + */ +function* handleReadFile( + files: LocalForage, + action: FileStorageReadFileAction, +): Generator { + try { + const value = yield* call(() => files.getItem(action.fileName)); + + if (value === null) { + throw new Error('file does not exist'); + } + + yield* put(fileStorageDidReadFile(action.fileName, value)); + } catch (err) { + yield* put(fileStorageDidFailToReadFile(action.fileName, ensureError(err))); + } +} + +/** + * Saves the file contents to storage. + * @param files The localForage instance. + * @param action The action that triggered this saga. + */ +function* handleWriteFile(files: LocalForage, action: FileStorageWriteFileAction) { + try { + yield* call(() => files.setItem(action.fileName, action.fileContents)); + yield* put(fileStorageDidWriteFile(action.fileName)); + } catch (err) { + yield* put(fileStorageDidFailToWriteFile(action.fileName, ensureError(err))); + } +} + +/** + * Initializes the storage backend. + */ +function* initialize(): Generator { + try { + // set up storage + + const files = extendPrototype( + localForage.createInstance({ name: 'fileStorage' }), + ); + + files.newObservable.factory = (subscribe) => + // @ts-expect-error localforage-observable Subscription is missing + // closed property compared to zen-observable Subscription. + new Observable(subscribe); + + yield* call(() => files.ready()); + + files.configObservables({ + crossTabNotification: true, + crossTabChangeDetection: true, + }); + + // wire storage observable to redux-sagas + + const localForageChannel = eventChannel((emit) => { + const filesObservable = files.newObservable({ + crossTabNotification: true, + }); + + const subscription = filesObservable.subscribe({ + next: (value) => emit(value), + }); + + return () => subscription.unsubscribe(); + }); + + // subscribe to events + + yield* takeEvery(localForageChannel, handleFileStorageDidChange); + yield* takeEvery(FileStorageActionType.ReadFile, handleReadFile, files); + yield* takeEvery(FileStorageActionType.WriteFile, handleWriteFile, files); + + // 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'); + } + + yield* put(fileStorageDidInitialize()); + } catch (err) { + yield* put(fileStorageDidFailToInitialize(ensureError(err))); + } +} + +export default function* (): Generator { + yield* fork(initialize); +} diff --git a/src/notifications/i18n.en.json b/src/notifications/i18n.en.json index 93ee9da2..8a9f682c 100644 --- a/src/notifications/i18n.en.json +++ b/src/notifications/i18n.en.json @@ -12,12 +12,13 @@ "unexpectedError": "Unexpected error while trying to connect: {errorMessage}" }, "editor": { - "programChanged": { - "message": "The program was changed in another window.\nDo you want to delete this program and replace it with the new program?", - "action": "Reload" - }, "failedToSaveFile": "Failed to save the program." }, + "fileStorage": { + "failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved.", + "failedToRead": "Failed to read file.", + "failedToWrite": "Failed to write file." + }, "flashFirmware": { "timedOut": "The hub took too long to respond. Restart the hub and try again.", "bleError": "There was a problem with Bluetooth.", diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index 490d6a98..1b9e90df 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Notification translation keys. @@ -13,6 +13,9 @@ export enum MessageId { BleNoWebBluetooth = 'ble.noWebBluetooth', BleNoBluetooth = 'ble.noBluetooth', EditorFailedToSaveFile = 'editor.failedToSaveFile', + FileStorageFailedToInitialize = 'fileStorage.failedToInitialize', + FileStorageFailedToRead = 'fileStorage.failedToRead', + FileStorageFailedToWrite = 'fileStorage.failedToWrite', FlashFirmwareTimedOut = 'flashFirmware.timedOut', FlashFirmwareBleError = 'flashFirmware.bleError', FlashFirmwareDisconnected = 'flashFirmware.disconnected', @@ -25,8 +28,6 @@ export enum MessageId { FlashFirmwareCompileError = 'flashFirmware.compileError', FlashFirmwareSizeTooBig = 'flashFirmware.sizeTooBig', FlashFirmwareUnexpectedError = 'flashFirmware.unexpectedError', - ProgramChangedMessage = 'editor.programChanged.message', - ProgramChangedAction = 'editor.programChanged.action', ServiceWorkerUpdateMessage = 'serviceWorker.update.message', ServiceWorkerUpdateAction = 'serviceWorker.update.action', MpyError = 'mpy.error', diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 977301b5..e555cf25 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { IToaster } from '@blueprintjs/core'; import { @@ -15,7 +15,12 @@ import { BleDeviceFailToConnectReasonType, didFailToConnect as bleDidFailToConnect, } from '../ble/actions'; -import { didFailToSaveAs, storageChanged } from '../editor/actions'; +import { didFailToSaveAs } from '../editor/actions'; +import { + fileStorageDidFailToInitialize, + fileStorageDidFailToReadFile, + fileStorageDidFailToWriteFile, +} from '../fileStorage/actions'; import { FailToFinishReasonType, HubError, @@ -50,7 +55,6 @@ test.each([ bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoWebBluetooth), bootloaderDidFailToConnect(BootloaderConnectionFailureReason.NoBluetooth), bootloaderDidFailToConnect(BootloaderConnectionFailureReason.GattServiceNotFound), - storageChanged('test'), didFailToCompile(['reason']), add('warning', 'message'), add('error', 'message', 'url'), @@ -83,6 +87,9 @@ test.each([ didCheckForUpdate(false), bleDIServiceDidReceiveFirmwareRevision('3.0.0'), didFailToSaveAs(new DOMException('test message', 'NotAllowedError')), + fileStorageDidFailToInitialize(new Error('test error')), + fileStorageDidFailToReadFile('test.file', new Error('test error')), + fileStorageDidFailToWriteFile('test.file', new Error('test error')), ])('actions that should show notification: %o', async (action: Action) => { const getToasts = jest.fn().mockReturnValue([]); const show = jest.fn(); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 4bd22855..d4240157 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // Saga for managing notifications (toasts) @@ -21,11 +21,13 @@ import { BleDeviceDidFailToConnectAction, BleDeviceFailToConnectReasonType, } from '../ble/actions'; +import { EditorActionType, EditorDidFailToSaveAsAction } from '../editor/actions'; import { - EditorActionType, - EditorDidFailToSaveAsAction, - reloadProgram, -} from '../editor/actions'; + FileStorageActionType, + FileStorageDidFailToInitializeAction, + FileStorageDidFailToReadFileAction, + FileStorageDidFailToWriteFileAction, +} from '../fileStorage/actions'; import { FailToFinishReasonType, FlashFirmwareActionType, @@ -246,24 +248,6 @@ function* showEditorFailToSaveFile(action: EditorDidFailToSaveAsAction): Generat yield* showUnexpectedError(MessageId.EditorFailedToSaveFile, action.err); } -function* showEditorStorageChanged(): Generator { - const ch = channel>(); - - yield* showSingleton( - Level.Info, - MessageId.ProgramChangedMessage, - undefined, - dispatchAction(MessageId.ProgramChangedAction, ch.put, 'tick'), - ch.close, - ); - - // if the notification is dismissed without clicking on the action, the - // saga will be cancelled here - yield* take(ch); - - yield* put(reloadProgram()); -} - function* showFlashFirmwareError( action: FlashFirmwareDidFailToFinishAction, ): Generator { @@ -411,6 +395,24 @@ function* checkVersion( } } +function* showFileStorageFailToInitialize( + action: FileStorageDidFailToInitializeAction, +): Generator { + yield* showUnexpectedError(MessageId.FileStorageFailedToInitialize, action.error); +} + +function* showFileStorageFailToRead( + action: FileStorageDidFailToReadFileAction, +): Generator { + yield* showUnexpectedError(MessageId.FileStorageFailedToRead, action.error); +} + +function* showFileStorageFailToWrite( + action: FileStorageDidFailToWriteFileAction, +): Generator { + yield* showUnexpectedError(MessageId.FileStorageFailedToWrite, action.error); +} + export default function* (): Generator { yield* takeEvery( BleDeviceActionType.DidFailToConnect, @@ -421,7 +423,6 @@ export default function* (): Generator { showBootloaderDidFailToConnectError, ); yield* takeEvery(EditorActionType.DidFailToSaveAs, showEditorFailToSaveFile); - yield* takeEvery(EditorActionType.StorageChanged, showEditorStorageChanged); yield* takeEvery(FlashFirmwareActionType.DidFailToFinish, showFlashFirmwareError); yield* takeEvery(MpyActionType.DidCompile, dismissCompilerError); yield* takeEvery(MpyActionType.DidFailToCompile, showCompilerError); @@ -429,4 +430,16 @@ export default function* (): Generator { yield* takeEvery(ServiceWorkerActionType.DidUpdate, showServiceWorkerUpdate); yield* takeEvery(AppActionType.DidCheckForUpdate, showNoUpdateInfo); yield* takeEvery(BleDIServiceActionType.DidReceiveFirmwareRevision, checkVersion); + yield* takeEvery( + FileStorageActionType.DidFailToInitialize, + showFileStorageFailToInitialize, + ); + yield* takeEvery( + FileStorageActionType.DidFailToReadFile, + showFileStorageFailToRead, + ); + yield* takeEvery( + FileStorageActionType.DidFailToWriteFile, + showFileStorageFailToWrite, + ); } diff --git a/src/reducers.ts b/src/reducers.ts index d01683df..76448a19 100644 --- a/src/reducers.ts +++ b/src/reducers.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { TypedUseSelectorHook, useSelector as useReduxSelector } from 'react-redux'; import { Reducer, combineReducers } from 'redux'; import app from './app/reducers'; import ble from './ble/reducers'; import editor from './editor/reducers'; +import fileStorage from './fileStorage/reducers'; import firmware from './firmware/reducers'; import hub from './hub/reducers'; import licenses from './licenses/reducers'; @@ -20,6 +21,7 @@ export const rootReducer = combineReducers({ bootloader, ble, editor, + fileStorage, firmware, hub, licenses, diff --git a/src/sagas.ts b/src/sagas.ts index 412c14d6..0e9a8747 100644 --- a/src/sagas.ts +++ b/src/sagas.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { all, put } from 'typed-redux-saga/macro'; import { didStart } from './app/actions'; @@ -8,6 +8,7 @@ import blePybricksService from './ble-pybricks-service/sagas'; import ble from './ble/sagas'; import editor from './editor/sagas'; import errorLog from './error-log/sagas'; +import fileStorage from './fileStorage/sagas'; import flashFirmware from './firmware/sagas'; import hub from './hub/sagas'; import licenses from './licenses/sagas'; @@ -24,6 +25,7 @@ export default function* (): Generator { app(), blePybricksService(), ble(), + fileStorage(), lwp3BootloaderBle(), lwp3BootloaderProtocol(), editor(), diff --git a/yarn.lock b/yarn.lock index 11066f7b..5afe44bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7862,6 +7862,13 @@ license-webpack-plugin@^3.0.0: resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-3.0.0.tgz#530fb297cee402cdf19a80f59e1c1ec1720dbc7e" integrity sha512-Owp0mXaJu/09h9hvZTazMni/Ni7bjh4R4xIfLhWP1O2wrhhKtezAA8U42TTeNDpyDMUD2ljeGC8Jh9xSFnyq4Q== +lie@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" + integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4= + dependencies: + immediate "~3.0.5" + lie@~3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" @@ -7906,6 +7913,21 @@ loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0: emojis-list "^3.0.0" json5 "^1.0.1" +localforage-observable@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/localforage-observable/-/localforage-observable-2.1.1.tgz#a30c8a8aec72d59a1b138238f3c4b9356264fc99" + integrity sha512-p4E9FtopJlOa2ENk9Pw5puVOaOJ7tnLCJ+54iJXlG76wrmDAifDdbIcQc6QNAHJNIGLr+YKg0O3PeA77dEgD/w== + dependencies: + localforage "^1.5.0" + zen-observable "^0.2.1" + +localforage@^1.10.0, localforage@^1.5.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4" + integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg== + dependencies: + lie "3.1.1" + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -13039,6 +13061,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zen-observable@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.2.1.tgz#c47676a64132b8475a61aa49e514755b5b9663f3" + integrity sha1-xHZ2pkEyuEdaYapJ5RR1W1uWY/M= + zen-observable@^0.7.0: version "0.7.1" resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.7.1.tgz#f84075c0ee085594d3566e1d6454207f126411b3"