diff --git a/CHANGELOG.md b/CHANGELOG.md index 850558b3..a5185f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,13 @@ ## [Unreleased] +### Changed +- Saving file now uses proper save dialog in Chromium browser ([support#84]). + ### Fixed - Fixed buttons wrong size while image is downloading ([support#369]). +[support#84]: https://github.com/pybricks/support/issues/84 [support#369]: https://github.com/pybricks/support/issues/369 ## [1.1.0] - 2021-12-16 diff --git a/package.json b/package.json index f7353fa9..c85639f0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@types/redux-logger": "^3.0.9", "@types/semver": "^7.3.9", "@types/web-bluetooth": "^0.0.12", + "@types/wicg-file-system-access": "^2020.9.4", "@types/zen-push": "^0.1.1", "babel-plugin-macros": "^3.0.1", "canvas": "^2.8.0", diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 5c031c47..f258e6eb 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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; + +/** 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 & { + 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; diff --git a/src/editor/sagas.test.ts b/src/editor/sagas.test.ts index 49c6d9d1..ffe67216 100644 --- a/src/editor/sagas.test.ts +++ b/src/editor/sagas.test.ts @@ -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(); - const saga = new AsyncSaga(editor, { editor: { current: mockEditor } }); +describe('saveAs', () => { + test('web file system api can succeed', async () => { + const mockEditor = mock(); + 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(); + const originalShowSaveFilePicker = window.showSaveFilePicker; + window.showSaveFilePicker = jest.fn().mockResolvedValue( + mock({ + 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(); + 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({ + 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(); + 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(); + 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 () => { diff --git a/src/editor/sagas.ts b/src/editor/sagas.ts index 30cb5069..49a47ca3 100644 --- a/src/editor/sagas.ts +++ b/src/editor/sagas.ts @@ -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 { diff --git a/src/notifications/i18n.en.json b/src/notifications/i18n.en.json index 15afa237..93ee9da2 100644 --- a/src/notifications/i18n.en.json +++ b/src/notifications/i18n.en.json @@ -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.", diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index 08ac380c..490d6a98 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -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', diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 512430bc..8091bcdf 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -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(); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index cafa303f..d305100d 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -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>(); @@ -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); diff --git a/yarn.lock b/yarn.lock index 893cb069..0ccaf7b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2258,6 +2258,11 @@ anymatch "^3.0.0" source-map "^0.6.0" +"@types/wicg-file-system-access@^2020.9.4": + version "2020.9.4" + resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.4.tgz#83f255d6bd20b0ae131d555693473d15a0574e92" + integrity sha512-o43jUljwP0ZrQ927mPjGdJaBMfS12nf3VPj6Z52fMucxILrSs8tnfLbMDSn6cP3hrrLChc3SYneeEvecknNVtA== + "@types/yargs-parser@*": version "20.2.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129"