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:
David Lechner
2022-02-24 16:59:49 -06:00
parent a0a1ae7126
commit e4b7a9aec7
19 changed files with 636 additions and 107 deletions
+5 -19
View File
@@ -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
View File
@@ -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;
+1 -12
View File
@@ -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
View File
@@ -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);
}