diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx
index 897f236c..8397cb1e 100644
--- a/src/explorer/Explorer.tsx
+++ b/src/explorer/Explorer.tsx
@@ -37,6 +37,7 @@ import {
explorerImportFiles,
explorerRenameFile,
} from './actions';
+import DeleteFileAlert from './deleteFileAlert/DeleteFileAlert';
import { I18nId } from './i18n';
import NewFileWizard from './newFileWizard/NewFileWizard';
import RenameFileDialog from './renameFileDialog/RenameFileDialog';
@@ -380,6 +381,7 @@ const Explorer: React.VFC = () => {
+
);
};
diff --git a/src/explorer/actions.ts b/src/explorer/actions.ts
index 52833867..62b58829 100644
--- a/src/explorer/actions.ts
+++ b/src/explorer/actions.ts
@@ -164,3 +164,24 @@ export const explorerDeleteFile = createAction((fileName: string) => ({
type: 'explorer.action.deleteFile',
fileName,
}));
+
+/**
+ * Action that indicates that {@link explorerDeleteFile} succeeded.
+ * @param fileName The file name.
+ */
+export const explorerDidDeleteFile = createAction((fileName: string) => ({
+ type: 'explorer.action.didDeleteFile',
+ fileName,
+}));
+
+/**
+ * Action that indicates that {@link explorerDeleteFile} failed.
+ * @param fileName The file name.
+ */
+export const explorerDidFailToDeleteFile = createAction(
+ (fileName: string, error: Error) => ({
+ type: 'explorer.action.didFailToDeleteFile',
+ fileName,
+ error,
+ }),
+);
diff --git a/src/explorer/deleteFileAlert/DeleteFileAlert.test.tsx b/src/explorer/deleteFileAlert/DeleteFileAlert.test.tsx
new file mode 100644
index 00000000..713cb1c3
--- /dev/null
+++ b/src/explorer/deleteFileAlert/DeleteFileAlert.test.tsx
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { cleanup, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { testRender } from '../../../test';
+import DeleteFileAlert from './DeleteFileAlert';
+import { deleteFileAlertDidAccept, deleteFileAlertDidCancel } from './actions';
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('accept', () => {
+ it('should dispatch accept action when delete button is clicked', async () => {
+ const [dialog, dispatch] = testRender(, {
+ explorer: { deleteFileAlert: { fileName: 'test.file', isOpen: true } },
+ });
+
+ userEvent.click(dialog.getByRole('button', { name: 'Delete' }));
+ expect(dispatch).toHaveBeenCalledWith(deleteFileAlertDidAccept());
+ });
+
+ it('should dispatch accept action when enter is pressed ', async () => {
+ const [dialog, dispatch] = testRender(, {
+ explorer: { deleteFileAlert: { fileName: 'test.file', isOpen: true } },
+ });
+
+ await waitFor(() =>
+ expect(dialog.getByRole('button', { name: 'Delete' })).toHaveFocus(),
+ );
+ userEvent.keyboard('{enter}');
+
+ expect(dispatch).toHaveBeenCalledWith(deleteFileAlertDidAccept());
+ });
+});
+
+describe('cancel', () => {
+ it('should dispatch cancel when keep button is clicked', () => {
+ const [dialog, dispatch] = testRender(, {
+ explorer: { deleteFileAlert: { fileName: 'test.file', isOpen: true } },
+ });
+
+ userEvent.click(dialog.getByRole('button', { name: 'Keep' }));
+
+ expect(dispatch).toHaveBeenCalledWith(deleteFileAlertDidCancel());
+ });
+
+ it('should dispatch cancel when escape button is pressed', async () => {
+ const [dialog, dispatch] = testRender(, {
+ explorer: { deleteFileAlert: { fileName: 'test.file', isOpen: true } },
+ });
+
+ await waitFor(() =>
+ expect(dialog.getByRole('button', { name: 'Delete' })).toHaveFocus(),
+ );
+ userEvent.keyboard('{esc}');
+
+ expect(dispatch).toHaveBeenCalledWith(deleteFileAlertDidCancel());
+ });
+});
diff --git a/src/explorer/deleteFileAlert/DeleteFileAlert.tsx b/src/explorer/deleteFileAlert/DeleteFileAlert.tsx
new file mode 100644
index 00000000..7c35be81
--- /dev/null
+++ b/src/explorer/deleteFileAlert/DeleteFileAlert.tsx
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { Alert, Classes, Intent } from '@blueprintjs/core';
+import { useI18n } from '@shopify/react-i18n';
+import React, { useCallback } from 'react';
+import { useDispatch } from 'react-redux';
+import { useSelector } from '../../reducers';
+import { deleteFileAlertDidAccept, deleteFileAlertDidCancel } from './actions';
+import { I18nId } from './i18n';
+
+const DeleteFileAlert: React.VoidFunctionComponent = () => {
+ const { isOpen, fileName } = useSelector((s) => s.explorer.deleteFileAlert);
+ const dispatch = useDispatch();
+ // istanbul ignore next: babel rewrites this line
+ const [i18n] = useI18n();
+
+ // a11y: focus primary button when dialog is opened
+ const handleOpened = useCallback((node: HTMLElement) => {
+ // HACK: get the accept button
+ // there doesn't seem to be a nice way to access it via props/ref/etc
+ const button = node.querySelector(
+ `button.${Classes.INTENT_DANGER}`,
+ );
+
+ // istanbul ignore if: bug if reached
+ if (!button) {
+ console.error('bug: could not find accept button');
+ return;
+ }
+
+ button.focus();
+ }, []);
+
+ return (
+ dispatch(deleteFileAlertDidAccept())}
+ onCancel={() => dispatch(deleteFileAlertDidCancel())}
+ onOpened={handleOpened}
+ >
+ {i18n.translate(I18nId.Message, { fileName })}
+
+ );
+};
+
+export default DeleteFileAlert;
diff --git a/src/explorer/deleteFileAlert/actions.ts b/src/explorer/deleteFileAlert/actions.ts
new file mode 100644
index 00000000..5cac4fd1
--- /dev/null
+++ b/src/explorer/deleteFileAlert/actions.ts
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { createAction } from '../../actions';
+
+/**
+ * Requests to show the delete file alert dialog.
+ * @param fileName The file name to display to the user.
+ */
+export const deleteFileAlertShow = createAction((fileName: string) => ({
+ type: 'explorer.deleteFileAlert.action.show',
+ fileName,
+}));
+
+/**
+ * Indicates that the user accepted the delete file alert dialog.
+ */
+export const deleteFileAlertDidAccept = createAction(() => ({
+ type: 'explorer.deleteFileAlert.action.didAccept',
+}));
+
+/**
+ * Indicates that the user canceled the delete file alert dialog.
+ */
+export const deleteFileAlertDidCancel = createAction(() => ({
+ type: 'explorer.deleteFileAlert.action.didCancel',
+}));
diff --git a/src/explorer/deleteFileAlert/i18n.test.ts b/src/explorer/deleteFileAlert/i18n.test.ts
new file mode 100644
index 00000000..e706ba28
--- /dev/null
+++ b/src/explorer/deleteFileAlert/i18n.test.ts
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { lookup } from '../../../test';
+import { I18nId } from './i18n';
+import en from './translations/en.json';
+
+describe('Ensure .json file has matches for I18nId', () => {
+ test.each(Object.values(I18nId))('%s', (id) => {
+ expect(lookup(en, id)).toBeDefined();
+ });
+});
diff --git a/src/explorer/deleteFileAlert/i18n.ts b/src/explorer/deleteFileAlert/i18n.ts
new file mode 100644
index 00000000..718931cf
--- /dev/null
+++ b/src/explorer/deleteFileAlert/i18n.ts
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+export enum I18nId {
+ Accept = 'action.accept',
+ Cancel = 'action.cancel',
+ Message = 'message',
+}
diff --git a/src/explorer/deleteFileAlert/reducers.ts b/src/explorer/deleteFileAlert/reducers.ts
new file mode 100644
index 00000000..f7ac77dd
--- /dev/null
+++ b/src/explorer/deleteFileAlert/reducers.ts
@@ -0,0 +1,36 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { Reducer, combineReducers } from 'redux';
+import {
+ deleteFileAlertDidAccept,
+ deleteFileAlertDidCancel,
+ deleteFileAlertShow,
+} from './actions';
+
+/** Controls the delete file alert dialog isOpen state. */
+const isOpen: Reducer = (state = false, action) => {
+ if (deleteFileAlertShow.matches(action)) {
+ return true;
+ }
+
+ if (
+ deleteFileAlertDidAccept.matches(action) ||
+ deleteFileAlertDidCancel.matches(action)
+ ) {
+ return false;
+ }
+
+ return state;
+};
+
+/** Controls the file name displayed in the file alert dialog. */
+const fileName: Reducer = (state = '', action) => {
+ if (deleteFileAlertShow.matches(action)) {
+ return action.fileName;
+ }
+
+ return state;
+};
+
+export default combineReducers({ isOpen, fileName });
diff --git a/src/explorer/deleteFileAlert/translations/en.json b/src/explorer/deleteFileAlert/translations/en.json
new file mode 100644
index 00000000..261eac23
--- /dev/null
+++ b/src/explorer/deleteFileAlert/translations/en.json
@@ -0,0 +1,7 @@
+{
+ "message": "The file '{fileName}' will be permanently deleted. This cannot be undone.",
+ "action": {
+ "accept": "Delete",
+ "cancel": "Keep"
+ }
+}
diff --git a/src/explorer/reducers.ts b/src/explorer/reducers.ts
index 89c18690..616aae5b 100644
--- a/src/explorer/reducers.ts
+++ b/src/explorer/reducers.ts
@@ -10,6 +10,7 @@ import {
fileStorageDidRemoveItem,
} from '../fileStorage/actions';
+import deleteFileAlert from './deleteFileAlert/reducers';
import newFileWizard from './newFileWizard/reducers';
import renameFileDialog from './renameFileDialog/reducers';
@@ -52,4 +53,9 @@ const files: Reducer = (state = [], action) => {
return state;
};
-export default combineReducers({ files, newFileWizard, renameFileDialog });
+export default combineReducers({
+ files,
+ deleteFileAlert,
+ newFileWizard,
+ renameFileDialog,
+});
diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts
index 3eb3727e..c022cd54 100644
--- a/src/explorer/sagas.test.ts
+++ b/src/explorer/sagas.test.ts
@@ -4,18 +4,24 @@
import * as browserFsAccess from 'browser-fs-access';
import { FileWithHandle } from 'browser-fs-access';
import { mock } from 'jest-mock-extended';
-import { AsyncSaga } from '../../test';
+import { AsyncSaga, uuid } from '../../test';
import {
editorActivateFile,
+ editorCloseFile,
editorDidActivateFile,
+ editorDidCloseFile,
editorDidFailToActivateFile,
} from '../editor/actions';
import {
+ fileStorageDeleteFile,
+ fileStorageDidDeleteFile,
fileStorageDidDumpAllFiles,
+ fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
fileStorageDidFailToRenameFile,
fileStorageDidReadFile,
+ fileStorageDidRemoveItem,
fileStorageDidRenameFile,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
@@ -28,13 +34,16 @@ import {
explorerActivateFile,
explorerArchiveAllFiles,
explorerCreateNewFile,
+ explorerDeleteFile,
explorerDidActivateFile,
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
+ explorerDidDeleteFile,
explorerDidExportFile,
explorerDidFailToActivateFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
+ explorerDidFailToDeleteFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
explorerDidFailToRenameFile,
@@ -44,6 +53,11 @@ import {
explorerImportFiles,
explorerRenameFile,
} from './actions';
+import {
+ deleteFileAlertDidAccept,
+ deleteFileAlertDidCancel,
+ deleteFileAlertShow,
+} from './deleteFileAlert/actions';
import {
Hub,
newFileWizardDidAccept,
@@ -344,3 +358,73 @@ describe('handleExplorerExportFile', () => {
await saga.end();
});
});
+
+describe('handleExplorerDeleteFile', () => {
+ let saga: AsyncSaga;
+ const testFile = 'test.file';
+
+ beforeEach(async () => {
+ saga = new AsyncSaga(explorer);
+
+ saga.put(explorerDeleteFile(testFile));
+
+ await expect(saga.take()).resolves.toEqual(deleteFileAlertShow(testFile));
+ });
+
+ it('should fail with AbortError if canceled', async () => {
+ saga.put(deleteFileAlertDidCancel());
+
+ await expect(saga.take()).resolves.toEqual(
+ explorerDidFailToDeleteFile(
+ testFile,
+ new DOMException('user canceled', 'AbortError'),
+ ),
+ );
+ });
+
+ it('should fail with AbortError if file was removed before user accept/cancel', async () => {
+ saga.put(
+ fileStorageDidRemoveItem({ path: testFile, uuid: uuid(0), sha256: '' }),
+ );
+
+ // should programmatically cancel the dialog
+ await expect(saga.take()).resolves.toEqual(deleteFileAlertDidCancel());
+
+ await expect(saga.take()).resolves.toEqual(
+ explorerDidFailToDeleteFile(
+ testFile,
+ new DOMException('file was removed', 'AbortError'),
+ ),
+ );
+ });
+
+ describe('accepted', () => {
+ beforeEach(async () => {
+ saga.put(deleteFileAlertDidAccept());
+
+ // should close the editor first
+ await expect(saga.take()).resolves.toEqual(editorCloseFile(testFile));
+ saga.put(editorDidCloseFile(testFile));
+
+ // then delete the file
+ await expect(saga.take()).resolves.toEqual(fileStorageDeleteFile(testFile));
+ });
+
+ it('should propagate error', async () => {
+ const testError = new Error('test error');
+ saga.put(fileStorageDidFailToDeleteFile(testFile, testError));
+ await expect(saga.take()).resolves.toEqual(
+ explorerDidFailToDeleteFile(testFile, testError),
+ );
+ });
+
+ it('should succeed', async () => {
+ saga.put(fileStorageDidDeleteFile(testFile));
+ await expect(saga.take()).resolves.toEqual(explorerDidDeleteFile(testFile));
+ });
+ });
+
+ afterEach(async () => {
+ await saga.end();
+ });
+});
diff --git a/src/explorer/sagas.ts b/src/explorer/sagas.ts
index 6f0e3170..9f890822 100644
--- a/src/explorer/sagas.ts
+++ b/src/explorer/sagas.ts
@@ -14,17 +14,23 @@ import {
} from 'typed-redux-saga/macro';
import {
editorActivateFile,
+ editorCloseFile,
editorDidActivateFile,
+ editorDidCloseFile,
editorDidFailToActivateFile,
} from '../editor/actions';
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
import {
+ fileStorageDeleteFile,
+ fileStorageDidDeleteFile,
fileStorageDidDumpAllFiles,
+ fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
fileStorageDidFailToRenameFile,
fileStorageDidFailToWriteFile,
fileStorageDidReadFile,
+ fileStorageDidRemoveItem,
fileStorageDidRenameFile,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
@@ -45,13 +51,16 @@ import {
explorerActivateFile,
explorerArchiveAllFiles,
explorerCreateNewFile,
+ explorerDeleteFile,
explorerDidActivateFile,
explorerDidArchiveAllFiles,
explorerDidCreateNewFile,
+ explorerDidDeleteFile,
explorerDidExportFile,
explorerDidFailToActivateFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
+ explorerDidFailToDeleteFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
explorerDidFailToRenameFile,
@@ -61,6 +70,11 @@ import {
explorerImportFiles,
explorerRenameFile,
} from './actions';
+import {
+ deleteFileAlertDidAccept,
+ deleteFileAlertDidCancel,
+ deleteFileAlertShow,
+} from './deleteFileAlert/actions';
import {
newFileWizardDidAccept,
newFileWizardDidCancel,
@@ -328,6 +342,56 @@ function* handleExplorerExportFile(
}
}
+function* handleExplorerDeleteFile(action: ReturnType) {
+ try {
+ yield* put(deleteFileAlertShow(action.fileName));
+
+ const { didCancel, didRemove } = yield* race({
+ didAccept: take(deleteFileAlertDidAccept),
+ didCancel: take(deleteFileAlertDidCancel),
+ didRemove: take(
+ fileStorageDidRemoveItem.when((a) => a.file.path === action.fileName),
+ ),
+ });
+
+ if (didCancel) {
+ throw new DOMException('user canceled', 'AbortError');
+ }
+
+ // automatically cancel the dialog, if the file was removed, e.g. it was
+ // deleted in a different window
+ if (didRemove) {
+ yield* put(deleteFileAlertDidCancel());
+ throw new DOMException('file was removed', 'AbortError');
+ }
+
+ // at this point we know the user accepted
+
+ // have to close editor before deleting, otherwise we get "in use" error
+ yield* put(editorCloseFile(action.fileName));
+ yield* take(editorDidCloseFile.when((a) => a.fileName === action.fileName));
+
+ yield* put(fileStorageDeleteFile(action.fileName));
+
+ const { didFailToDelete } = yield* race({
+ didDelete: take(
+ fileStorageDidDeleteFile.when((a) => a.path === action.fileName),
+ ),
+ didFailToDelete: take(
+ fileStorageDidFailToDeleteFile.when((a) => a.path === action.fileName),
+ ),
+ });
+
+ if (didFailToDelete) {
+ throw didFailToDelete.error;
+ }
+
+ yield* put(explorerDidDeleteFile(action.fileName));
+ } catch (err) {
+ yield* put(explorerDidFailToDeleteFile(action.fileName, ensureError(err)));
+ }
+}
+
export default function* (): Generator {
yield* takeEvery(explorerArchiveAllFiles, handleExplorerArchiveAllFiles);
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
@@ -338,4 +402,5 @@ export default function* (): Generator {
// this to happen in practice though.
yield* takeLatest(explorerRenameFile, handleExplorerRenameFile);
yield* takeLatest(explorerExportFile, handleExplorerExportFile);
+ yield* takeLatest(explorerDeleteFile, handleExplorerDeleteFile);
}
diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts
index dd8bf92b..f6793149 100644
--- a/src/notifications/i18n.ts
+++ b/src/notifications/i18n.ts
@@ -15,10 +15,9 @@ export enum I18nId {
BleNoBluetooth = 'ble.noBluetooth',
EditorFailedToOpenFile = 'editor.failedToOpenFile',
EditorFailedToSaveFile = 'editor.failedToSaveFile',
- ExplorerDeleteFileMessage = 'explorer.deleteFile.message',
- ExplorerDeleteFileAction = 'explorer.deleteFile.action',
ExplorerFailedToImportFiles = 'explorer.failedToImportFiles',
ExplorerFailedToCreate = 'explorer.failedToCreate',
+ ExplorerFailedToDelete = 'explorer.failedToDelete',
ExplorerFailedToExport = 'explorer.failedToExport',
ExplorerFailedToArchive = 'explorer.failedToArchive',
FileStorageFailedToInitialize = 'fileStorage.failedToInitialize',
diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts
index 3f86f825..905aca92 100644
--- a/src/notifications/sagas.test.ts
+++ b/src/notifications/sagas.test.ts
@@ -9,7 +9,7 @@ import {
} from '@pybricks/firmware';
import { I18nManager } from '@shopify/react-i18n';
import { AnyAction } from 'redux';
-import { AsyncSaga, uuid } from '../../test';
+import { AsyncSaga } from '../../test';
import { appDidCheckForUpdate } from '../app/actions';
import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions';
import {
@@ -18,17 +18,13 @@ import {
} from '../ble/actions';
import { editorDidFailToOpenFile } from '../editor/actions';
import {
- explorerDeleteFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
+ explorerDidFailToDeleteFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
} from '../explorer/actions';
-import {
- fileStorageDeleteFile,
- fileStorageDidFailToInitialize,
- fileStorageDidRemoveItem,
-} from '../fileStorage/actions';
+import { fileStorageDidFailToInitialize } from '../fileStorage/actions';
import {
FailToFinishReasonType,
HubError,
@@ -117,6 +113,7 @@ test.each([
explorerDidFailToImportFiles(new Error('test error')),
explorerDidFailToCreateNewFile(new Error('test error')),
explorerDidFailToExportFile('test.file', new Error('test error')),
+ explorerDidFailToDeleteFile('test.file', new Error('test error')),
editorDidFailToOpenFile('test.file', new Error('test error')),
])('actions that should show notification: %o', async (action: AnyAction) => {
const { toaster, saga } = createTestToasterSaga();
@@ -144,6 +141,10 @@ test.each([
'test.file',
new DOMException('test message', 'AbortError'),
),
+ explorerDidFailToDeleteFile(
+ 'test.file',
+ new DOMException('test message', 'AbortError'),
+ ),
])('actions that should not show a notification: %o', async (action: AnyAction) => {
const { toaster, saga } = createTestToasterSaga();
@@ -171,59 +172,3 @@ test.each([[didCompile(new Uint8Array()), I18nId.MpyError]])(
await saga.end();
},
);
-
-describe('delete file saga', () => {
- it('should not delete the file if the user closes the notification', async () => {
- const { toaster, saga } = createTestToasterSaga();
-
- saga.put(explorerDeleteFile('test.file'));
-
- toaster.dismiss(I18nId.ExplorerDeleteFileMessage);
-
- await saga.end();
- });
-
- it('should delete the file if the user clicks the delete button', async () => {
- const { toaster, saga } = createTestToasterSaga();
-
- saga.put(explorerDeleteFile('test.file'));
-
- const toast = toaster
- .getToasts()
- .find((t) => t.key === I18nId.ExplorerDeleteFileMessage);
-
- expect(toast).toBeDefined();
- expect(toast?.action).toBeDefined();
- expect(toast?.action?.onClick).toBeDefined();
-
- toast?.action?.onClick?.call(
- toast?.action,
- {} as React.MouseEvent,
- );
-
- const action = await saga.take();
- expect(action).toEqual(fileStorageDeleteFile('test.file'));
-
- await saga.end();
- });
-
- it('should close automatically if the file is deleted without user action', async () => {
- const { toaster, saga } = createTestToasterSaga();
-
- saga.put(explorerDeleteFile('test.file'));
-
- expect(
- toaster.getToasts().find((t) => t.key === I18nId.ExplorerDeleteFileMessage),
- ).toBeDefined();
-
- saga.put(
- fileStorageDidRemoveItem({ uuid: uuid(0), path: 'test.file', sha256: '' }),
- );
-
- expect(
- toaster.getToasts().find((t) => t.key === I18nId.ExplorerDeleteFileMessage),
- ).toBeUndefined();
-
- await saga.end();
- });
-});
diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts
index b80d14ae..afab9d62 100644
--- a/src/notifications/sagas.ts
+++ b/src/notifications/sagas.ts
@@ -9,7 +9,7 @@ import { Replacements } from '@shopify/react-i18n';
import React from 'react';
import { channel } from 'redux-saga';
import * as semver from 'semver';
-import { delay, getContext, put, race, take, takeEvery } from 'typed-redux-saga/macro';
+import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro';
import { appDidCheckForUpdate, appReload } from '../app/actions';
import { appName } from '../app/constants';
import { bleDIServiceDidReceiveFirmwareRevision } from '../ble-device-info-service/actions';
@@ -19,17 +19,13 @@ import {
} from '../ble/actions';
import { editorDidFailToOpenFile } from '../editor/actions';
import {
- explorerDeleteFile,
explorerDidFailToArchiveAllFiles,
explorerDidFailToCreateNewFile,
+ explorerDidFailToDeleteFile,
explorerDidFailToExportFile,
explorerDidFailToImportFiles,
} from '../explorer/actions';
-import {
- fileStorageDeleteFile,
- fileStorageDidFailToInitialize,
- fileStorageDidRemoveItem,
-} from '../fileStorage/actions';
+import { fileStorageDidFailToInitialize } from '../fileStorage/actions';
import { FailToFinishReasonType, didFailToFinish } from '../firmware/actions';
import {
BootloaderConnectionFailureReason,
@@ -399,41 +395,6 @@ function* showFileStorageFailToArchive(
yield* showUnexpectedError(I18nId.ExplorerFailedToArchive, action.error);
}
-function* showDeleteFileWarning(action: ReturnType) {
- const ch = channel>();
- const userAction = dispatchAction(I18nId.ExplorerDeleteFileAction, ch.put, 'trash');
-
- // TODO: this should probably not be a singleton
- yield* showSingleton(
- Level.Warning,
- I18nId.ExplorerDeleteFileMessage,
- {
- fileName: React.createElement('strong', undefined, action.fileName),
- },
- userAction,
- ch.close,
- );
-
- // task is terminated here if channel is closed (triggered by closing the notification)
- const { didRemoveFile } = yield* race({
- userActionEvent: take(ch),
- didRemoveFile: take(
- fileStorageDidRemoveItem.when((a) => a.file.path === action.fileName),
- ),
- });
-
- // if the file was removed by other means while the notification was being
- // shown, close the notification
- if (didRemoveFile) {
- const { toaster } = yield* getContext('notification');
- toaster.dismiss(I18nId.ExplorerDeleteFileMessage);
- return;
- }
-
- // this only runs if userAction is dispatched
- yield* put(fileStorageDeleteFile(action.fileName));
-}
-
function* showExplorerFailToImportFiles(
action: ReturnType,
): Generator {
@@ -474,6 +435,19 @@ function* showEditorDidFailToOpenFile(
yield* showUnexpectedError(I18nId.EditorFailedToOpenFile, action.error);
}
+function* showExplorerFailToDelete(
+ action: ReturnType,
+): Generator {
+ if (action.error.name === 'AbortError') {
+ // user clicked cancel button - not an error
+ return;
+ }
+
+ // TODO: add a specific error message for when file in use (e.g. open in another window)
+
+ yield* showUnexpectedError(I18nId.ExplorerFailedToDelete, action.error);
+}
+
export default function* (): Generator {
yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError);
yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError);
@@ -486,9 +460,9 @@ export default function* (): Generator {
yield* takeEvery(bleDIServiceDidReceiveFirmwareRevision, checkVersion);
yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize);
yield* takeEvery(explorerDidFailToArchiveAllFiles, showFileStorageFailToArchive);
- yield* takeEvery(explorerDeleteFile, showDeleteFileWarning);
yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles);
yield* takeEvery(explorerDidFailToCreateNewFile, showExplorerFailToCreateFile);
yield* takeEvery(explorerDidFailToExportFile, showExplorerFailToExport);
+ yield* takeEvery(explorerDidFailToDeleteFile, showExplorerFailToDelete);
yield* takeEvery(editorDidFailToOpenFile, showEditorDidFailToOpenFile);
}
diff --git a/src/notifications/translations/en.json b/src/notifications/translations/en.json
index 83db5cc2..d5e00ce1 100644
--- a/src/notifications/translations/en.json
+++ b/src/notifications/translations/en.json
@@ -17,13 +17,10 @@
"failedToSaveFile": "Failed to save the program."
},
"explorer": {
- "deleteFile": {
- "message": "The file {fileName} will be permanently deleted. This cannot be undone.",
- "action": "Delete"
- },
"failedToImportFiles": "Failed to import file(s).",
"failedToCreate": "Failed to create file.",
"failedToExport": "Failed to export file.",
+ "failedToDelete": "Failed to delete file.",
"failedToArchive": "Failed to archive files.'"
},
"fileStorage": {