editor: use file system api for save as

This makes use of the new web file system api for a better user
experience when saving a file. It now shows a proper save as dialog
instead of just downloading the file automatically.

Fixes: https://github.com/pybricks/support/issues/84
This commit is contained in:
David Lechner
2021-12-27 12:38:51 -06:00
parent 653c03404d
commit f704d4d612
10 changed files with 199 additions and 28 deletions
+29 -15
View File
@@ -5,25 +5,19 @@ import { monaco } from 'react-monaco-editor';
import { Action } from 'redux';
export enum EditorActionType {
/**
* The current (active) editor changed.
*/
/** The current (active) editor changed. */
Current = 'editor.action.current',
/**
* Save the current file to disk.
*/
/** Save the current file to disk. */
SaveAs = 'editor.action.saveAs',
/**
* Open a file.
*/
/** Saving the file succeeded. */
DidSaveAs = 'editor.action.didSaveAs',
/** Saving the file failed. */
DidFailToSaveAs = 'editor.action.didFailToSaveAs',
/** Open a file. */
Open = 'editor.action.open',
/**
* Storage was changed outside of the app.
*/
/** Storage was changed outside of the app. */
StorageChanged = 'editor.action.storageChanged',
/**
* Reload program from local storage.
*/
/** Reload program from local storage. */
ReloadProgram = 'editor.action.reloadProgram',
}
@@ -53,6 +47,24 @@ export function saveAs(): EditorSaveAsAction {
return { type: EditorActionType.SaveAs };
}
/** Action that indicates saving a file succeeded. */
export type EditorDidSaveAsAction = Action<EditorActionType.DidSaveAs>;
/** Action that indicates saving a file succeeded. */
export function didSaveAs(): EditorDidSaveAsAction {
return { type: EditorActionType.DidSaveAs };
}
/** Action that indicates saving a file failed. */
export type EditorDidFailToSaveAsAction = Action<EditorActionType.DidFailToSaveAs> & {
err: Error;
};
/** Action that indicates saving a file failed. */
export function didFailToSaveAs(err: Error): EditorDidFailToSaveAsAction {
return { type: EditorActionType.DidFailToSaveAs, err };
}
/**
* Action that opens a file.
*/
@@ -97,5 +109,7 @@ export type EditorAction =
| CurrentEditorAction
| EditorOpenAction
| EditorSaveAsAction
| EditorDidSaveAsAction
| EditorDidFailToSaveAsAction
| EditorStorageChangedAction
| EditorReloadProgramAction;
+98 -7
View File
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 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 { open, reloadProgram, saveAs } from './actions';
import { didFailToSaveAs, didSaveAs, open, reloadProgram, saveAs } from './actions';
import editor from './sagas';
jest.mock('react-monaco-editor');
@@ -22,15 +23,105 @@ test('open', async () => {
await saga.end();
});
test('saveAs', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
describe('saveAs', () => {
test('web file system api can succeed', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
saga.put(saveAs());
// window.showSaveFilePicker is not defined in the test environment
// so we can't use spyOn().
const mockWriteable = mock<FileSystemWritableFileStream>();
const originalShowSaveFilePicker = window.showSaveFilePicker;
window.showSaveFilePicker = jest.fn().mockResolvedValue(
mock<FileSystemFileHandle>({
createWritable: jest.fn().mockResolvedValue(mockWriteable),
}),
);
expect(mockEditor.getValue).toBeCalled();
saga.put(saveAs());
await saga.end();
expect(mockEditor.getValue).toHaveBeenCalled();
const action = await saga.take();
expect(action).toEqual(didSaveAs());
expect(window.showSaveFilePicker).toHaveBeenCalled();
expect(mockWriteable.write).toHaveBeenCalled();
expect(mockWriteable.close).toHaveBeenCalled();
await saga.end();
window.showSaveFilePicker = originalShowSaveFilePicker;
});
test('web file system api can fail', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
// window.showSaveFilePicker is not defined in the test environment
// so we can't use spyOn().
const testError = new Error('test error');
const originalShowSaveFilePicker = window.showSaveFilePicker;
window.showSaveFilePicker = jest.fn().mockResolvedValue(
mock<FileSystemFileHandle>({
createWritable: jest.fn().mockRejectedValue(testError),
}),
);
saga.put(saveAs());
expect(mockEditor.getValue).toHaveBeenCalled();
const action = await saga.take();
expect(action).toEqual(didFailToSaveAs(testError));
expect(window.showSaveFilePicker).toHaveBeenCalled();
await saga.end();
window.showSaveFilePicker = originalShowSaveFilePicker;
});
test('fallback can succeed', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
const mockFileSaverSaveAs = jest.spyOn(FileSaver, 'saveAs');
saga.put(saveAs());
expect(mockEditor.getValue).toHaveBeenCalled();
const action = await saga.take();
expect(action).toEqual(didSaveAs());
expect(mockFileSaverSaveAs).toHaveBeenCalled();
await saga.end();
mockFileSaverSaveAs.mockRestore();
});
test('fallback can fail', async () => {
const mockEditor = mock<monaco.editor.ICodeEditor>();
const saga = new AsyncSaga(editor, { editor: { current: mockEditor } });
const testError = new Error('test error');
const mockFileSaverSaveAs = jest
.spyOn(FileSaver, 'saveAs')
.mockImplementation(() => {
throw testError;
});
saga.put(saveAs());
expect(mockEditor.getValue).toHaveBeenCalled();
const action = await saga.take();
expect(action).toEqual(didFailToSaveAs(testError));
expect(mockFileSaverSaveAs).toHaveBeenCalled();
await saga.end();
mockFileSaverSaveAs.mockRestore();
});
});
test('reloadProgram', async () => {
+41 -3
View File
@@ -1,14 +1,17 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020 The Pybricks Authors
// Copyright (c) 2020-2021 The Pybricks Authors
import FileSaver from 'file-saver';
import { select, takeEvery } from 'typed-redux-saga/macro';
import { call, put, select, takeEvery } from 'typed-redux-saga/macro';
import { RootState } from '../reducers';
import { ensureError } from '../utils';
import {
EditorActionType,
EditorOpenAction,
EditorReloadProgramAction,
EditorSaveAsAction,
didFailToSaveAs,
didSaveAs,
} from './actions';
const decoder = new TextDecoder();
@@ -37,7 +40,42 @@ function* saveAs(_action: EditorSaveAsAction): Generator {
const data = editor.getValue();
const blob = new Blob([data], { type: 'text/x-python;charset=utf-8' });
FileSaver.saveAs(blob, 'main.py');
if (window.showSaveFilePicker) {
// This uses https://wicg.github.io/file-system-access which is not
// available in all browsers
try {
const handle = yield* call(() =>
window.showSaveFilePicker({
suggestedName: 'main.py',
types: [
{
accept: { 'text/x-python': '.py' },
// TODO: translate description
description: 'Python Files',
},
],
}),
);
const writeable = yield* call(() => handle.createWritable());
yield* call(() => writeable.write(blob));
yield* call(() => writeable.close());
} catch (err) {
yield* put(didFailToSaveAs(ensureError(err)));
return;
}
} else {
// this is a fallback to use the standard browser download mechanism
try {
FileSaver.saveAs(blob, 'main.py');
} catch (err) {
yield* put(didFailToSaveAs(ensureError(err)));
return;
}
}
yield* put(didSaveAs());
}
function* reloadProgram(_action: EditorReloadProgramAction): Generator {
+2 -1
View File
@@ -15,7 +15,8 @@
"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."
},
"flashFirmware": {
"timedOut": "The hub took too long to respond. Restart the hub and try again.",
+1
View File
@@ -12,6 +12,7 @@ export enum MessageId {
BleGattServiceNotFound = 'ble.gattServiceNotFound',
BleNoWebBluetooth = 'ble.noWebBluetooth',
BleNoBluetooth = 'ble.noBluetooth',
EditorFailedToSaveFile = 'editor.failedToSaveFile',
FlashFirmwareTimedOut = 'flashFirmware.timedOut',
FlashFirmwareBleError = 'flashFirmware.bleError',
FlashFirmwareDisconnected = 'flashFirmware.disconnected',
+3 -1
View File
@@ -15,7 +15,7 @@ import {
didFailToConnect as bleDidFailToConnect,
didConnect,
} from '../ble/actions';
import { storageChanged } from '../editor/actions';
import { didFailToSaveAs, storageChanged } from '../editor/actions';
import {
FailToFinishReasonType,
HubError,
@@ -82,6 +82,7 @@ test.each([
didFailToFinish(FailToFinishReasonType.Unknown, new Error('test error')),
didCheckForUpdate(false),
didConnect('3.0.0'),
didFailToSaveAs(new DOMException('test message', 'NotAllowedError')),
])('actions that should show notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
@@ -113,6 +114,7 @@ test.each([
didSucceed({} as ServiceWorkerRegistration),
didCheckForUpdate(true),
didConnect(firmwareVersion),
didFailToSaveAs(new DOMException('test message', 'AbortError')),
])('actions that should not show a notification: %o', async (action: Action) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
+15 -1
View File
@@ -24,7 +24,11 @@ import {
BleDeviceDidFailToConnectAction,
BleDeviceFailToConnectReasonType,
} from '../ble/actions';
import { EditorActionType, reloadProgram } from '../editor/actions';
import {
EditorActionType,
EditorDidFailToSaveAsAction,
reloadProgram,
} from '../editor/actions';
import {
FailToFinishReasonType,
FlashFirmwareActionType,
@@ -236,6 +240,15 @@ function* showBootloaderDidFailToConnectError(
}
}
function* showEditorFailToSaveFile(action: EditorDidFailToSaveAsAction): Generator {
if (action.err.name === 'AbortError') {
// user clicked cancel button - not an error
return;
}
yield* showUnexpectedError(MessageId.EditorFailedToSaveFile, action.err);
}
function* showEditorStorageChanged(): Generator {
const ch = channel<React.MouseEvent<HTMLElement>>();
@@ -408,6 +421,7 @@ export default function* (): Generator {
BootloaderConnectionActionType.DidFailToConnect,
showBootloaderDidFailToConnectError,
);
yield* takeEvery(EditorActionType.DidFailToSaveAs, showEditorFailToSaveFile);
yield* takeEvery(EditorActionType.StorageChanged, showEditorStorageChanged);
yield* takeEvery(FlashFirmwareActionType.DidFailToFinish, showFlashFirmwareError);
yield* takeEvery(MpyActionType.DidCompile, dismissCompilerError);