mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
fileStorage: new file storage backend
This replaces the localStorage file storage with a new backend that uses localForage (indexeddb) for storing the file contents. This will also allow storing multiple files.
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- Changed file storage backend.
|
||||
|
||||
### Fixed
|
||||
- Fix tooltips not closing when expected ([pybricks-code#275]).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+5
-19
@@ -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<MonacoEditor>(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<IDisposable>();
|
||||
// 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))}
|
||||
/>
|
||||
</ContextMenu2>
|
||||
</ResizeSensor2>
|
||||
|
||||
+1
-28
@@ -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<EditorActionType.Current> & {
|
||||
@@ -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<EditorActionType.StorageChanged> & {
|
||||
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<EditorActionType.ReloadProgram>;
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -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<monaco.editor.ICodeEditor>();
|
||||
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
|
||||
|
||||
saga.put(reloadProgram());
|
||||
|
||||
expect(mockEditor.setValue).toHaveBeenCalled();
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
+54
-11
@@ -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<FileStorageDidReadFileAction>(
|
||||
(a: Action) =>
|
||||
a.type === FileStorageActionType.DidReadFile &&
|
||||
a.fileName === currentFileName,
|
||||
),
|
||||
error: take<FileStorageDidFailToReadFileAction>(
|
||||
(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);
|
||||
}
|
||||
|
||||
@@ -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<FileStorageActionType.DidInitialize>;
|
||||
|
||||
/** 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<FileStorageActionType.DidFailToInitialize> & { 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<FileStorageActionType.DidChangeItem> & {
|
||||
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<FileStorageActionType.DidRemoveItem> & {
|
||||
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<FileStorageActionType.ReadFile> & {
|
||||
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<FileStorageActionType.DidReadFile> & {
|
||||
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<FileStorageActionType.DidFailToReadFile> & {
|
||||
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<FileStorageActionType.WriteFile> & {
|
||||
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<FileStorageActionType.DidWriteFile> & {
|
||||
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<FileStorageActionType.DidFailToWriteFile> & {
|
||||
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;
|
||||
@@ -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<typeof reducers>;
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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<boolean, Action> = (state = false, action) => {
|
||||
switch (action.type) {
|
||||
case FileStorageActionType.DidInitialize:
|
||||
return true;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const fileNames: Reducer<Set<string>, 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 });
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<string>(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<LocalForageObservableChange>((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);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
+37
-24
@@ -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<React.MouseEvent<HTMLElement>>();
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+3
-1
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user