mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-14 18:46:17 +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 {
|
||||
|
||||
Reference in New Issue
Block a user