mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
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:
+29
-15
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user