diff --git a/src/explorer/Explorer.test.tsx b/src/explorer/Explorer.test.tsx index f6b0e6c9..ac2e4d9c 100644 --- a/src/explorer/Explorer.test.tsx +++ b/src/explorer/Explorer.test.tsx @@ -6,13 +6,14 @@ import { cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { testRender, uuid } from '../../test'; -import { - FileMetadata, - fileStorageArchiveAllFiles, - fileStorageExportFile, -} from '../fileStorage/actions'; +import { FileMetadata, fileStorageArchiveAllFiles } from '../fileStorage/actions'; import Explorer from './Explorer'; -import { explorerDeleteFile, explorerImportFiles, explorerRenameFile } from './actions'; +import { + explorerDeleteFile, + explorerExportFile, + explorerImportFiles, + explorerRenameFile, +} from './actions'; afterEach(async () => { cleanup(); @@ -153,7 +154,7 @@ describe('tree item', () => { userEvent.click(button); - expect(dispatch).toHaveBeenCalledWith(fileStorageExportFile('test.file')); + expect(dispatch).toHaveBeenCalledWith(explorerExportFile('test.file')); }); it('should dispatch export action when key is pressed', async () => { @@ -166,6 +167,6 @@ describe('tree item', () => { userEvent.click(treeItem); userEvent.keyboard('{ctrl}e'); - expect(dispatch).toHaveBeenCalledWith(fileStorageExportFile('test.file')); + expect(dispatch).toHaveBeenCalledWith(explorerExportFile('test.file')); }); }); diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index 517fa591..930b5394 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -23,15 +23,17 @@ import { useTreeEnvironment, } from 'react-complex-tree'; import { useDispatch } from 'react-redux'; -import { - fileStorageArchiveAllFiles, - fileStorageExportFile, -} from '../fileStorage/actions'; +import { fileStorageArchiveAllFiles } from '../fileStorage/actions'; import { useSelector } from '../reducers'; import { isMacOS } from '../utils/os'; import { preventBrowserNativeContextMenu } from '../utils/react'; import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer'; -import { explorerDeleteFile, explorerImportFiles, explorerRenameFile } from './actions'; +import { + explorerDeleteFile, + explorerExportFile, + explorerImportFiles, + explorerRenameFile, +} from './actions'; import { I18nId } from './i18n'; import NewFileWizard from './newFileWizard/NewFileWizard'; import RenameFileDialog from './renameFileDialog/RenameFileDialog'; @@ -110,7 +112,7 @@ const FileActionButtonGroup: React.VoidFunctionComponent icon="import" tooltip={i18n.translate(I18nId.TreeItemExportTooltip, { fileName })} focusable={false} - onClick={() => dispatch(fileStorageExportFile(fileName))} + onClick={() => dispatch(explorerExportFile(fileName))} /> { const handleExportKeyDown = useCallback(() => { if (focusedItem !== undefined) { const fileName = environment.getItemTitle(environment.items[focusedItem]); - dispatch(fileStorageExportFile(fileName)); + dispatch(explorerExportFile(fileName)); } }, [environment]); diff --git a/src/explorer/actions.ts b/src/explorer/actions.ts index f3a2f010..d269b45c 100644 --- a/src/explorer/actions.ts +++ b/src/explorer/actions.ts @@ -86,6 +86,37 @@ export const explorerRenameFile = createAction((fileName: string) => ({ fileName, })); +/** + * Request to export (download) a file. + * @param fileName The file name. + */ +export const explorerExportFile = createAction((fileName: string) => ({ + type: 'explorer.action.exportFile', + fileName, +})); + +/** + * Indicates that explorerExportFile(fileName) succeeded. + * @param fileName The file name. + */ +export const explorerDidExportFile = createAction((fileName: string) => ({ + type: 'explorer.action.didExportFile', + fileName, +})); + +/** + * Indicates that explorerExportFile(fileName) failed. + * @param fileName The file name. + * @param error The error that was raised. + */ +export const explorerDidFailToExportFile = createAction( + (fileName: string, error: Error) => ({ + type: 'explorer.action.didFailToExportFile', + fileName, + error, + }), +); + /** * Action that indicates that {@link explorerRenameFile} succeeded. */ diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts index 3afe6c19..9a9e9fa3 100644 --- a/src/explorer/sagas.test.ts +++ b/src/explorer/sagas.test.ts @@ -6,11 +6,15 @@ import { FileWithHandle } from 'browser-fs-access'; import { mock } from 'jest-mock-extended'; import { AsyncSaga, uuid } from '../../test'; import { + fileStorageDidFailToOpenFile, + fileStorageDidFailToReadFile, fileStorageDidFailToRenameFile, fileStorageDidOpenFile, + fileStorageDidReadFile, fileStorageDidRenameFile, fileStorageDidWriteFile, fileStorageOpenFile, + fileStorageReadFile, fileStorageRenameFile, fileStorageWriteFile, } from '../fileStorage/actions'; @@ -19,10 +23,13 @@ import { Hub, explorerCreateNewFile, explorerDidCreateNewFile, + explorerDidExportFile, + explorerDidFailToExportFile, explorerDidFailToImportFiles, explorerDidFailToRenameFile, explorerDidImportFiles, explorerDidRenameFile, + explorerExportFile, explorerImportFiles, explorerRenameFile, } from './actions'; @@ -159,3 +166,70 @@ describe('handleExplorerRenameFile', () => { await saga.end(); }); }); + +describe('handleExplorerExportFile', () => { + let saga: AsyncSaga; + const testFile = 'test.file'; + const testFileId = uuid(0); + const testFileContents = '# test file contents'; + const testError = new Error('test error'); + + beforeEach(async () => { + saga = new AsyncSaga(explorer); + + saga.put(explorerExportFile(testFile)); + + await expect(saga.take()).resolves.toEqual(fileStorageOpenFile(testFile)); + }); + + it('should fail if file does not exist', async () => { + saga.put(fileStorageDidFailToOpenFile(testFile, testError)); + + await expect(saga.take()).resolves.toEqual( + explorerDidFailToExportFile(testFile, testError), + ); + }); + + describe('should read file', () => { + beforeEach(async () => { + saga.put(fileStorageDidOpenFile(testFile, testFileId)); + + await expect(saga.take()).resolves.toEqual(fileStorageReadFile(testFileId)); + }); + + it('should catch read error', async () => { + saga.put(fileStorageDidFailToReadFile(testFileId, testError)); + + await expect(saga.take()).resolves.toEqual( + explorerDidFailToExportFile(testFile, testError), + ); + }); + + describe('should read file', () => { + it('should export file', async () => { + jest.spyOn(browserFsAccess, 'fileSave').mockResolvedValue(null); + + saga.put(fileStorageDidReadFile(testFileId, testFileContents)); + + await expect(saga.take()).resolves.toEqual( + explorerDidExportFile('test.file'), + ); + expect(browserFsAccess.fileSave).toHaveBeenCalled(); + }); + + it('should catch error', async () => { + jest.spyOn(browserFsAccess, 'fileSave').mockRejectedValue(testError); + + saga.put(fileStorageDidReadFile(testFileId, testFileContents)); + + await expect(saga.take()).resolves.toEqual( + explorerDidFailToExportFile('test.file', testError), + ); + }); + }); + }); + + afterEach(async () => { + await saga.end(); + }); +}); diff --git a/src/explorer/sagas.ts b/src/explorer/sagas.ts index 6c05be78..0d5bfc7e 100644 --- a/src/explorer/sagas.ts +++ b/src/explorer/sagas.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -import { fileOpen } from 'browser-fs-access'; +import { fileOpen, fileSave } from 'browser-fs-access'; import { call, put, @@ -14,12 +14,15 @@ import { import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython'; import { fileStorageDidFailToOpenFile, + fileStorageDidFailToReadFile, fileStorageDidFailToRenameFile, fileStorageDidFailToWriteFile, fileStorageDidOpenFile, + fileStorageDidReadFile, fileStorageDidRenameFile, fileStorageDidWriteFile, fileStorageOpenFile, + fileStorageReadFile, fileStorageRenameFile, fileStorageWriteFile, } from '../fileStorage/actions'; @@ -35,11 +38,14 @@ import { defined, ensureError } from '../utils'; import { explorerCreateNewFile, explorerDidCreateNewFile, + explorerDidExportFile, explorerDidFailToCreateNewFile, + explorerDidFailToExportFile, explorerDidFailToImportFiles, explorerDidFailToRenameFile, explorerDidImportFiles, explorerDidRenameFile, + explorerExportFile, explorerImportFiles, explorerRenameFile, } from './actions'; @@ -207,6 +213,61 @@ function* handleExplorerRenameFile( yield* put(explorerDidRenameFile()); } +function* handleExplorerExportFile( + action: ReturnType, +): Generator { + try { + yield* put(fileStorageOpenFile(action.fileName)); + + const { didOpen, didFailToOpen } = yield* race({ + didOpen: take( + fileStorageDidOpenFile.when((a) => a.path === action.fileName), + ), + didFailToOpen: take( + fileStorageDidFailToOpenFile.when((a) => a.path === action.fileName), + ), + }); + + if (didFailToOpen) { + throw didFailToOpen.error; + } + + defined(didOpen); + + yield* put(fileStorageReadFile(didOpen.id)); + + const { didRead, didFailToRead } = yield* race({ + didRead: take(fileStorageDidReadFile.when((a) => a.id === didOpen.id)), + didFailToRead: take( + fileStorageDidFailToReadFile.when((a) => a.id === didOpen.id), + ), + }); + + if (didFailToRead) { + throw didFailToRead.error; + } + + defined(didRead); + + const blob = new Blob([didRead.contents], { type: `${pythonFileMimeType}` }); + + yield* call(() => + fileSave(blob, { + id: 'pybricksCodeFileStorageExport', + fileName: didOpen.path, + extensions: [pythonFileExtension], + mimeTypes: [pythonFileMimeType], + // TODO: translate description + description: 'Python Files', + }), + ); + + yield* put(explorerDidExportFile(action.fileName)); + } catch (err) { + yield* put(explorerDidFailToExportFile(action.fileName, ensureError(err))); + } +} + export default function* (): Generator { yield* takeEvery(explorerImportFiles, handleExplorerImportFiles); yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile); @@ -214,4 +275,5 @@ export default function* (): Generator { // previous one is finished, the old one will be canceled. We don't expect // this to happen in practice though. yield* takeLatest(explorerRenameFile, handleExplorerRenameFile); + yield* takeLatest(explorerExportFile, handleExplorerExportFile); } diff --git a/src/fileStorage/actions.ts b/src/fileStorage/actions.ts index bb35d309..60d27c0c 100644 --- a/src/fileStorage/actions.ts +++ b/src/fileStorage/actions.ts @@ -231,37 +231,6 @@ export const fileStorageDidFailToRenameFile = createAction( }), ); -/** - * Request to export (download) a file. - * @param id The file handle UUID. - */ -export const fileStorageExportFile = createAction((fileName: string) => ({ - type: 'fileStorage.action.exportFile', - fileName, -})); - -/** - * Indicates that fileStorageExportFile(fileName) succeeded. - * @param fileName The file handle UUID. - */ -export const fileStorageDidExportFile = createAction((fileName: string) => ({ - type: 'fileStorage.action.didExportFile', - fileName, -})); - -/** - * Indicates that fileStorageExportFile(fileName) failed. - * @param fileName The file name. - * @param error The error that was raised. - */ -export const fileStorageDidFailToExportFile = createAction( - (fileName: string, error: Error) => ({ - type: 'fileStorage.action.didFailToExportFile', - fileName, - error, - }), -); - /** * Request to archive (download) all files in the store. */ diff --git a/src/fileStorage/sagas.test.ts b/src/fileStorage/sagas.test.ts index 115a7f86..4aba0879 100644 --- a/src/fileStorage/sagas.test.ts +++ b/src/fileStorage/sagas.test.ts @@ -15,9 +15,7 @@ import { fileStorageDidArchiveAllFiles, fileStorageDidChangeItem, fileStorageDidDeleteFile, - fileStorageDidExportFile, fileStorageDidFailToArchiveAllFiles, - fileStorageDidFailToExportFile, fileStorageDidFailToReadFile, fileStorageDidInitialize, fileStorageDidOpenFile, @@ -25,7 +23,6 @@ import { fileStorageDidRemoveItem, fileStorageDidRenameFile, fileStorageDidWriteFile, - fileStorageExportFile, fileStorageOpenFile, fileStorageReadFile, fileStorageRenameFile, @@ -219,59 +216,6 @@ describe('rename', () => { await saga.end(); }); }); - -describe('export', () => { - it('should fail if file does not exist', async () => { - const testFile = 'test.file'; - - const saga = new AsyncSaga(fileStorage); - - await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); - - saga.put(fileStorageExportFile(testFile)); - - await expect(saga.take()).resolves.toEqual( - fileStorageDidFailToExportFile(testFile, new Error('file does not exist')), - ); - - await saga.end(); - }); - - it('should export file', async () => { - const saga = new AsyncSaga(fileStorage); - - const [testFile] = await setUpTestFile(saga); - - jest.spyOn(browserFsAccess, 'fileSave'); - - saga.put(fileStorageExportFile(testFile.path)); - - await expect(saga.take()).resolves.toEqual( - fileStorageDidExportFile(testFile.path), - ); - expect(browserFsAccess.fileSave).toHaveBeenCalled(); - - await saga.end(); - }); - - it('should catch error', async () => { - const saga = new AsyncSaga(fileStorage); - - const [testFile] = await setUpTestFile(saga); - - const testError = new Error('test error'); - jest.spyOn(browserFsAccess, 'fileSave').mockRejectedValue(testError); - - saga.put(fileStorageExportFile(testFile.path)); - - await expect(saga.take()).resolves.toEqual( - fileStorageDidFailToExportFile(testFile.path, testError), - ); - - await saga.end(); - }); -}); - describe('archive', () => { it('should archive file', async () => { const saga = new AsyncSaga(fileStorage); diff --git a/src/fileStorage/sagas.ts b/src/fileStorage/sagas.ts index 96555fcd..f8a7c8a7 100644 --- a/src/fileStorage/sagas.ts +++ b/src/fileStorage/sagas.ts @@ -13,7 +13,6 @@ import 'dexie-observable'; import JSZip from 'jszip'; import { eventChannel } from 'redux-saga'; import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro'; -import { pythonFileExtension, pythonFileMimeType } from '../pybricksMicropython/lib'; import { ensureError, timestamp } from '../utils'; import { sha256Digest } from '../utils/crypto'; import { @@ -25,10 +24,8 @@ import { fileStorageDidArchiveAllFiles, fileStorageDidChangeItem, fileStorageDidDeleteFile, - fileStorageDidExportFile, fileStorageDidFailToArchiveAllFiles, fileStorageDidFailToDeleteFile, - fileStorageDidFailToExportFile, fileStorageDidFailToInitialize, fileStorageDidFailToOpenFile, fileStorageDidFailToReadFile, @@ -40,7 +37,6 @@ import { fileStorageDidRemoveItem, fileStorageDidRenameFile, fileStorageDidWriteFile, - fileStorageExportFile, fileStorageOpenFile, fileStorageReadFile, fileStorageRenameFile, @@ -256,55 +252,6 @@ function* handleWriteFile( } } -function* handleExportFile( - db: FileStorageDb, - action: ReturnType, -): Generator { - const file = yield* call(() => - db.transaction('r', db.metadata, db._contents, async () => { - const metadata = await db.metadata - .where('path') - .equals(action.fileName) - .first(); - - if (!metadata) { - return undefined; - } - - return await db._contents.get(metadata.path); - }), - ); - - if (!file) { - yield* put( - fileStorageDidFailToExportFile( - action.fileName, - new Error('file does not exist'), - ), - ); - return; - } - - const blob = new Blob([file.contents], { type: `${pythonFileMimeType}` }); - - try { - yield* call(() => - fileSave(blob, { - id: 'pybricksCodeFileStorageExport', - fileName: file.path, - extensions: [pythonFileExtension], - mimeTypes: [pythonFileMimeType], - // TODO: translate description - description: 'Python Files', - }), - ); - - yield* put(fileStorageDidExportFile(action.fileName)); - } catch (err) { - yield* put(fileStorageDidFailToExportFile(action.fileName, ensureError(err))); - } -} - /** * Deletes a file from storage. * @param db The database instance. @@ -466,7 +413,6 @@ function* initialize(): Generator { yield* takeEvery(fileStorageWriteFile, handleWriteFile, db); yield* takeEvery(fileStorageDeleteFile, handleDeleteFile, db); yield* takeEvery(fileStorageRenameFile, handleRenameFile, db); - yield* takeEvery(fileStorageExportFile, handleExportFile, db); yield* takeEvery(fileStorageArchiveAllFiles, handleArchiveAllFiles, db); const files = yield* call(() => db.metadata.toArray()); diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index 4fd185b3..0ed18b27 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -16,11 +16,10 @@ export enum I18nId { ExplorerDeleteFileMessage = 'explorer.deleteFile.message', ExplorerDeleteFileAction = 'explorer.deleteFile.action', ExplorerFailedToImportFiles = 'explorer.failedToImportFiles', + ExplorerFailedToCreate = 'explorer.failedToCreate', + ExplorerFailedToExport = 'explorer.failedToExport', FileStorageFailedToInitialize = 'fileStorage.failedToInitialize', - FileStorageFailedToRead = 'fileStorage.failedToRead', - FileStorageFailedToWrite = 'fileStorage.failedToWrite', - FileStorageFailedToDelete = 'fileStorage.failedToDelete', - FileStorageFailedToExport = 'fileStorage.failedToExport', + FileStorageFailedToArchive = 'fileStorage.failedToArchive', FlashFirmwareTimedOut = 'flashFirmware.timedOut', FlashFirmwareBleError = 'flashFirmware.bleError', FlashFirmwareDisconnected = 'flashFirmware.disconnected', diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 932382a4..1e9783ef 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -19,12 +19,12 @@ import { import { explorerDeleteFile, explorerDidFailToCreateNewFile, + explorerDidFailToExportFile, explorerDidFailToImportFiles, } from '../explorer/actions'; import { fileStorageDeleteFile, fileStorageDidFailToArchiveAllFiles, - fileStorageDidFailToExportFile, fileStorageDidFailToInitialize, fileStorageDidRemoveItem, } from '../fileStorage/actions'; @@ -112,10 +112,10 @@ test.each([ appDidCheckForUpdate(false), bleDIServiceDidReceiveFirmwareRevision('3.0.0'), fileStorageDidFailToInitialize(new Error('test error')), - fileStorageDidFailToExportFile('test.file', new Error('test error')), fileStorageDidFailToArchiveAllFiles(new Error('test error')), explorerDidFailToImportFiles(new Error('test error')), explorerDidFailToCreateNewFile(new Error('test error')), + explorerDidFailToExportFile('test.file', new Error('test error')), ])('actions that should show notification: %o', async (action: AnyAction) => { const { toaster, saga } = createTestToasterSaga(); @@ -135,12 +135,12 @@ test.each([ serviceWorkerDidSucceed(), appDidCheckForUpdate(true), bleDIServiceDidReceiveFirmwareRevision(firmwareVersion), - fileStorageDidFailToExportFile( + fileStorageDidFailToArchiveAllFiles(new DOMException('test message', 'AbortError')), + explorerDidFailToImportFiles(new DOMException('test message', 'AbortError')), + explorerDidFailToExportFile( 'test.file', new DOMException('test message', 'AbortError'), ), - fileStorageDidFailToArchiveAllFiles(new DOMException('test message', 'AbortError')), - explorerDidFailToImportFiles(new DOMException('test message', 'AbortError')), ])('actions that should not show a notification: %o', async (action: AnyAction) => { const { toaster, saga } = createTestToasterSaga(); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index aaa4f414..bd999ae9 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -20,12 +20,12 @@ import { import { explorerDeleteFile, explorerDidFailToCreateNewFile, + explorerDidFailToExportFile, explorerDidFailToImportFiles, } from '../explorer/actions'; import { fileStorageDeleteFile, fileStorageDidFailToArchiveAllFiles, - fileStorageDidFailToExportFile, fileStorageDidFailToInitialize, fileStorageDidRemoveItem, } from '../fileStorage/actions'; @@ -387,17 +387,6 @@ function* showFileStorageFailToInitialize( yield* showUnexpectedError(I18nId.FileStorageFailedToInitialize, action.error); } -function* showFileStorageFailToExport( - action: ReturnType, -): Generator { - if (action.error.name === 'AbortError') { - // user clicked cancel button - not an error - return; - } - - yield* showUnexpectedError(I18nId.FileStorageFailedToExport, action.error); -} - function* showFileStorageFailToArchive( action: ReturnType, ): Generator { @@ -406,7 +395,7 @@ function* showFileStorageFailToArchive( return; } - yield* showUnexpectedError(I18nId.FileStorageFailedToExport, action.error); + yield* showUnexpectedError(I18nId.FileStorageFailedToArchive, action.error); } function* showDeleteFileWarning(action: ReturnType) { @@ -458,7 +447,18 @@ function* showExplorerFailToImportFiles( function* showExplorerFailToCreateFile( action: ReturnType, ): Generator { - yield* showUnexpectedError(I18nId.FileStorageFailedToDelete, action.error); + yield* showUnexpectedError(I18nId.ExplorerFailedToCreate, action.error); +} + +function* showExplorerFailToExport( + action: ReturnType, +): Generator { + if (action.error.name === 'AbortError') { + // user clicked cancel button - not an error + return; + } + + yield* showUnexpectedError(I18nId.ExplorerFailedToExport, action.error); } export default function* (): Generator { @@ -472,9 +472,9 @@ export default function* (): Generator { yield* takeEvery(appDidCheckForUpdate, showNoUpdateInfo); yield* takeEvery(bleDIServiceDidReceiveFirmwareRevision, checkVersion); yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize); - yield* takeEvery(fileStorageDidFailToExportFile, showFileStorageFailToExport); yield* takeEvery(fileStorageDidFailToArchiveAllFiles, showFileStorageFailToArchive); yield* takeEvery(explorerDeleteFile, showDeleteFileWarning); yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles); yield* takeEvery(explorerDidFailToCreateNewFile, showExplorerFailToCreateFile); + yield* takeEvery(explorerDidFailToExportFile, showExplorerFailToExport); } diff --git a/src/notifications/translations/en.json b/src/notifications/translations/en.json index a3792ab8..046b7673 100644 --- a/src/notifications/translations/en.json +++ b/src/notifications/translations/en.json @@ -19,14 +19,13 @@ "message": "The file {fileName} will be permanently deleted. This cannot be undone.", "action": "Delete" }, - "failedToImportFiles": "Failed to import file(s)" + "failedToImportFiles": "Failed to import file(s).", + "failedToCreate": "Failed to create file.", + "failedToExport": "Failed to export file." }, "fileStorage": { "failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved.", - "failedToRead": "Failed to read file.", - "failedToWrite": "Failed to write file.", - "failedToDelete": "Failed to delete file.", - "failedToExport": "Failed to export file.'" + "failedToArchive": "Failed to archive files.'" }, "flashFirmware": { "timedOut": "The hub took too long to respond. Restart the hub and try again.",