From e0a6926d019f86751ba9c7e8a7ed863bc1d8ab0c Mon Sep 17 00:00:00 2001 From: David Lechner Date: Fri, 8 Apr 2022 18:11:09 -0500 Subject: [PATCH] explorer: implement duplicate file feature --- src/app/App.tsx | 14 +++ src/explorer/Explorer.test.tsx | 33 +++++++ src/explorer/Explorer.tsx | 30 +++++++ src/explorer/actions.ts | 31 +++++++ .../DuplicateFileDialog.test.tsx | 80 +++++++++++++++++ .../DuplicateFileDialog.tsx | 87 +++++++++++++++++++ src/explorer/duplicateFileDialog/actions.ts | 33 +++++++ src/explorer/duplicateFileDialog/i18n.test.ts | 12 +++ src/explorer/duplicateFileDialog/i18n.ts | 7 ++ src/explorer/duplicateFileDialog/reducers.ts | 36 ++++++++ .../duplicateFileDialog/translations/en.json | 6 ++ src/explorer/i18n.ts | 2 + src/explorer/reducers.ts | 2 + src/explorer/sagas.test.ts | 66 ++++++++++++++ src/explorer/sagas.ts | 54 ++++++++++++ src/explorer/translations/en.json | 6 +- src/fileStorage/actions.ts | 33 +++++++ src/fileStorage/sagas.test.ts | 75 ++++++++++++++++ src/fileStorage/sagas.ts | 64 ++++++++++++++ src/notifications/i18n.ts | 1 + src/notifications/sagas.test.ts | 6 ++ src/notifications/sagas.ts | 13 +++ src/notifications/translations/en.json | 1 + src/settings/SettingsDrawer.test.tsx | 13 --- src/settings/SettingsDrawer.tsx | 25 +----- 25 files changed, 693 insertions(+), 37 deletions(-) create mode 100644 src/explorer/duplicateFileDialog/DuplicateFileDialog.test.tsx create mode 100644 src/explorer/duplicateFileDialog/DuplicateFileDialog.tsx create mode 100644 src/explorer/duplicateFileDialog/actions.ts create mode 100644 src/explorer/duplicateFileDialog/i18n.test.ts create mode 100644 src/explorer/duplicateFileDialog/i18n.ts create mode 100644 src/explorer/duplicateFileDialog/reducers.ts create mode 100644 src/explorer/duplicateFileDialog/translations/en.json diff --git a/src/app/App.tsx b/src/app/App.tsx index e792e9ca..6a5c9004 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -141,6 +141,20 @@ const App: React.VFC = () => { return () => document.body.classList.remove(Classes.DARK); }, [isDarkMode]); + useEffect(() => { + const listener = (e: KeyboardEvent) => { + // prevent default browser keyboard shortcuts that we use + // NB: some of these like 'n' and 'w' cannot be prevented when + // running "in the browser" + if (e.ctrlKey && ['d', 'n', 's', 'w'].includes(e.key)) { + e.preventDefault(); + } + }; + + addEventListener('keydown', listener); + return () => removeEventListener('keydown', listener); + }, []); + return (
diff --git a/src/explorer/Explorer.test.tsx b/src/explorer/Explorer.test.tsx index 1bdf09d4..4d0ec88e 100644 --- a/src/explorer/Explorer.test.tsx +++ b/src/explorer/Explorer.test.tsx @@ -11,6 +11,7 @@ import { explorerArchiveAllFiles, explorerCreateNewFile, explorerDeleteFile, + explorerDuplicateFile, explorerExportFile, explorerImportFiles, } from './actions'; @@ -101,6 +102,38 @@ describe('tree item', () => { expect(dispatch).toHaveBeenCalledWith(explorerActivateFile('test.file')); }); + describe('duplicate', () => { + it('should dispatch action when button is clicked', async () => { + const [explorer, dispatch] = testRender(, { + explorer: { files: [testFile] }, + }); + + // NB: this button is intentionally not accessible (by role) since + // there is a keyboard shortcut. + const button = explorer.getByTitle('Duplicate test.file'); + + userEvent.click(button); + + expect(dispatch).toHaveBeenCalledWith(explorerDuplicateFile('test.file')); + + // should not propagate to treeitem + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('should dispatch action when key is pressed', async () => { + const [explorer, dispatch] = testRender(, { + explorer: { files: [testFile] }, + }); + + const treeItem = explorer.getByRole('treeitem', { name: 'test.file' }); + + userEvent.click(treeItem); + userEvent.keyboard('{ctrl}d'); + + expect(dispatch).toHaveBeenCalledWith(explorerDuplicateFile('test.file')); + }); + }); + describe('export', () => { it('should dispatch export action when button is clicked', async () => { const [explorer, dispatch] = testRender(, { diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index d080f4c4..809e6104 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -33,10 +33,12 @@ import { explorerArchiveAllFiles, explorerCreateNewFile, explorerDeleteFile, + explorerDuplicateFile, explorerExportFile, explorerImportFiles, } from './actions'; import DeleteFileAlert from './deleteFileAlert/DeleteFileAlert'; +import DuplicateFileDialog from './duplicateFileDialog/DuplicateFileDialog'; import { I18nId } from './i18n'; import NewFileWizard from './newFileWizard/NewFileWizard'; @@ -107,6 +109,12 @@ const FileActionButtonGroup: React.VoidFunctionComponent className="pb-explorer-file-action-button-group" minimal={true} > + dispatch(explorerDuplicateFile(fileName))} + /> +
  • ${i18n.translate( + I18nId.TreeLiveDescriptorIntroKeybindingsDuplicate, + { key: `${isMacOS() ? 'cmd' : 'ctrl'}+d` }, + )}
  • ${i18n.translate( I18nId.TreeLiveDescriptorIntroKeybindingsExport, { key: `${isMacOS() ? 'cmd' : 'ctrl'}+e` }, @@ -216,6 +228,13 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => { const hotKeyActive = isActiveTree; /* && !dnd.isProgrammaticallyDragging && !isRenaming */ + const handleDuplicateKeyDown = useCallback(() => { + if (focusedItem !== undefined) { + const fileName = environment.getItemTitle(environment.items[focusedItem]); + dispatch(explorerDuplicateFile(fileName)); + } + }, [environment]); + const handleDeleteKeyDown = useCallback(() => { if (focusedItem !== undefined) { const fileName = environment.getItemTitle(environment.items[focusedItem]); @@ -232,11 +251,20 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => { const hotkeys = useMemo( () => [ + { + combo: 'mod+d', + label: 'Duplicate', + disabled: !hotKeyActive, + preventDefault: true, + stopPropagation: true, + onKeyDown: handleDuplicateKeyDown, + }, { combo: 'del', label: 'Delete', disabled: !hotKeyActive, preventDefault: true, + stopPropagation: true, onKeyDown: handleDeleteKeyDown, }, { @@ -244,6 +272,7 @@ const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => { label: 'Export', disabled: !hotKeyActive, preventDefault: true, + stopPropagation: true, onKeyDown: handleExportKeyDown, }, ], @@ -354,6 +383,7 @@ const Explorer: React.VFC = () => { +
  • ); diff --git a/src/explorer/actions.ts b/src/explorer/actions.ts index 1f730ca2..0587427c 100644 --- a/src/explorer/actions.ts +++ b/src/explorer/actions.ts @@ -102,6 +102,37 @@ export const explorerDidFailToActivateFile = createAction( }), ); +/** + * Action that requests to duplicate a file. + * @param fileName The file name. + */ +export const explorerDuplicateFile = createAction((fileName: string) => ({ + type: 'explorer.action.duplicateFile', + fileName, +})); + +/** + * Action that indicates that {@link explorerDuplicateFile} succeeded. + * @param fileName The file name. + */ +export const explorerDidDuplicateFile = createAction((fileName: string) => ({ + type: 'explorer.action.didDuplicateFile', + fileName, +})); + +/** + * Action that indicates that {@link explorerDuplicateFile} failed. + * @param fileName The file name. + * @param err The error. + */ +export const explorerDidFailToDuplicateFile = createAction( + (fileName: string, error: Error) => ({ + type: 'explorer.action.didFailToDuplicateFile', + fileName, + error, + }), +); + /** * Request to export (download) a file. * @param fileName The file name. diff --git a/src/explorer/duplicateFileDialog/DuplicateFileDialog.test.tsx b/src/explorer/duplicateFileDialog/DuplicateFileDialog.test.tsx new file mode 100644 index 00000000..b3f733a7 --- /dev/null +++ b/src/explorer/duplicateFileDialog/DuplicateFileDialog.test.tsx @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { waitFor } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { testRender } from '../../../test'; +import DuplicateFileDialog from './DuplicateFileDialog'; +import { duplicateFileDialogDidAccept, duplicateFileDialogDidCancel } from './actions'; + +describe('duplicate button', () => { + it('should accept the dialog Duplicate is clicked', async () => { + const [dialog, dispatch] = testRender(, { + explorer: { + duplicateFileDialog: { isOpen: true, fileName: 'source.file' }, + }, + }); + + const button = dialog.getByRole('button', { name: 'Duplicate' }); + + // have to type a new file name before Duplicate button is enabled + const input = dialog.getByRole('textbox', { name: 'File name' }); + await waitFor(() => expect(input).toHaveFocus()); + userEvent.type(input, 'new'); + await waitFor(() => expect(button).not.toBeDisabled()); + + userEvent.click(button); + expect(dispatch).toHaveBeenCalledWith( + duplicateFileDialogDidAccept('source.file', 'new.file'), + ); + }); + + it('should accept the dialog when enter is pressed in the text input', async () => { + const [dialog, dispatch] = testRender(, { + explorer: { + duplicateFileDialog: { isOpen: true, fileName: 'source.file' }, + }, + }); + + // have to type a new file name before Duplicate button is enabled + const input = dialog.getByRole('textbox', { name: 'File name' }); + await waitFor(() => expect(input).toHaveFocus()); + userEvent.type(input, 'new{enter}'); + + expect(dispatch).toHaveBeenCalledWith( + duplicateFileDialogDidAccept('source.file', 'new.file'), + ); + }); + + it('should cancel when user clicks close button', async () => { + const [dialog, dispatch] = testRender(, { + explorer: { + duplicateFileDialog: { isOpen: true, fileName: 'source.file' }, + }, + }); + + const button = dialog.getByRole('button', { name: 'Close' }); + + await waitFor(() => expect(button).toBeVisible()); + + userEvent.click(button); + expect(dispatch).toHaveBeenCalledWith(duplicateFileDialogDidCancel()); + }); + + it('should cancel when user user presses esc key', async () => { + const [dialog, dispatch] = testRender(, { + explorer: { + duplicateFileDialog: { isOpen: true, fileName: 'source.file' }, + }, + }); + + await waitFor(() => + expect(dialog.getByRole('textbox', { name: 'File name' })).toHaveFocus(), + ); + + userEvent.keyboard('{esc}'); + + expect(dispatch).toHaveBeenCalledWith(duplicateFileDialogDidCancel()); + }); +}); diff --git a/src/explorer/duplicateFileDialog/DuplicateFileDialog.tsx b/src/explorer/duplicateFileDialog/DuplicateFileDialog.tsx new file mode 100644 index 00000000..abb9ed5b --- /dev/null +++ b/src/explorer/duplicateFileDialog/DuplicateFileDialog.tsx @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Button, Classes, Dialog } from '@blueprintjs/core'; +import { useI18n } from '@shopify/react-i18n'; +import React, { useCallback, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { + FileNameValidationResult, + validateFileName, +} from '../../pybricksMicropython/lib'; +import { useSelector } from '../../reducers'; +import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup'; +import { duplicateFileDialogDidAccept, duplicateFileDialogDidCancel } from './actions'; +import { I18nId } from './i18n'; + +const DuplicateFileDialog: React.VFC = () => { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useI18n(); + const dispatch = useDispatch(); + const isOpen = useSelector((s) => s.explorer.duplicateFileDialog.isOpen); + const oldName = useSelector((s) => s.explorer.duplicateFileDialog.fileName); + + const [baseName, extension] = oldName.split(/(\.\w+)$/); + + const [newName, setNewName] = useState(baseName); + const files = useSelector((s) => s.explorer.files); + const result = validateFileName( + newName, + extension, + files.map((f) => f.name), + ); + + const inputRef = useRef(null); + + const handleSubmit = useCallback( + (e) => { + e.preventDefault(); + dispatch(duplicateFileDialogDidAccept(oldName, `${newName}${extension}`)); + }, + [dispatch, oldName, newName, extension], + ); + + const handleClose = useCallback(() => { + dispatch(duplicateFileDialogDidCancel()); + }, [dispatch]); + + return ( + setNewName(baseName)} + onOpened={() => { + inputRef.current?.select(); + inputRef.current?.focus(); + }} + onClose={handleClose} + > +
    +
    + +
    +
    +
    + +
    +
    +
    +
    + ); +}; + +export default DuplicateFileDialog; diff --git a/src/explorer/duplicateFileDialog/actions.ts b/src/explorer/duplicateFileDialog/actions.ts new file mode 100644 index 00000000..11bc31e0 --- /dev/null +++ b/src/explorer/duplicateFileDialog/actions.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { createAction } from '../../actions'; + +/** + * Action that requests to show the duplicate file dialog. + * @param oldName The old file name. + */ +export const duplicateFileDialogShow = createAction((oldName: string) => ({ + type: 'explorer.duplicateFileDialog.action.show', + oldName, +})); + +/** + * Action that indicates the duplicate file dialog was accepted. + * @param oldName The old file name. + * @param newName The new file name. + */ +export const duplicateFileDialogDidAccept = createAction( + (oldName: string, newName: string) => ({ + type: 'explorer.duplicateFileDialog.action.didAccept', + oldName, + newName, + }), +); + +/** + * Action that indicates the duplicate file dialog was canceled. + */ +export const duplicateFileDialogDidCancel = createAction(() => ({ + type: 'explorer.duplicateFileDialog.action.didCancel', +})); diff --git a/src/explorer/duplicateFileDialog/i18n.test.ts b/src/explorer/duplicateFileDialog/i18n.test.ts new file mode 100644 index 00000000..e706ba28 --- /dev/null +++ b/src/explorer/duplicateFileDialog/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/duplicateFileDialog/i18n.ts b/src/explorer/duplicateFileDialog/i18n.ts new file mode 100644 index 00000000..c380ebc0 --- /dev/null +++ b/src/explorer/duplicateFileDialog/i18n.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +export enum I18nId { + Title = 'title', + ActionAccept = 'action.accept', +} diff --git a/src/explorer/duplicateFileDialog/reducers.ts b/src/explorer/duplicateFileDialog/reducers.ts new file mode 100644 index 00000000..31772459 --- /dev/null +++ b/src/explorer/duplicateFileDialog/reducers.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { Reducer, combineReducers } from 'redux'; +import { + duplicateFileDialogDidAccept, + duplicateFileDialogDidCancel, + duplicateFileDialogShow, +} from './actions'; + +/** Controls the duplicate file dialog isOpen state. */ +const isOpen: Reducer = (state = false, action) => { + if (duplicateFileDialogShow.matches(action)) { + return true; + } + + if ( + duplicateFileDialogDidAccept.matches(action) || + duplicateFileDialogDidCancel.matches(action) + ) { + return false; + } + + return state; +}; + +/** Controls the duplicate file dialog file name input box text. */ +const fileName: Reducer = (state = '', action) => { + if (duplicateFileDialogShow.matches(action)) { + return action.oldName; + } + + return state; +}; + +export default combineReducers({ isOpen, fileName }); diff --git a/src/explorer/duplicateFileDialog/translations/en.json b/src/explorer/duplicateFileDialog/translations/en.json new file mode 100644 index 00000000..3a370d7d --- /dev/null +++ b/src/explorer/duplicateFileDialog/translations/en.json @@ -0,0 +1,6 @@ +{ + "title": "Duplicate '{fileName}'", + "action": { + "accept": "Duplicate" + } +} diff --git a/src/explorer/i18n.ts b/src/explorer/i18n.ts index 4e64f97d..115560f0 100644 --- a/src/explorer/i18n.ts +++ b/src/explorer/i18n.ts @@ -11,9 +11,11 @@ export enum I18nId { TreeLiveDescriptorIntroAccessibilityGuide = 'tree.liveDescriptor.intro.accessibilityGuide', TreeLiveDescriptorIntroNavigation = 'tree.liveDescriptor.intro.navigation', TreeLiveDescriptorIntroKeybindingsPrimaryAction = 'tree.liveDescriptor.intro.keybindings.primaryAction', + TreeLiveDescriptorIntroKeybindingsDuplicate = 'tree.liveDescriptor.intro.keybindings.duplicate', TreeLiveDescriptorIntroKeybindingsExport = 'tree.liveDescriptor.intro.keybindings.export', TreeLiveDescriptorIntroKeybindingsDelete = 'tree.liveDescriptor.intro.keybindings.delete', TreeLiveDescriptorSearching = 'tree.liveDescriptor.searching', TreeItemDeleteTooltip = 'treeItem.deleteTooltip', TreeItemExportTooltip = 'treeItem.exportTooltip', + TreeItemDuplicateTooltip = 'treeItem.duplicateTooltip', } diff --git a/src/explorer/reducers.ts b/src/explorer/reducers.ts index 616aae5b..00ddd2bb 100644 --- a/src/explorer/reducers.ts +++ b/src/explorer/reducers.ts @@ -11,6 +11,7 @@ import { } from '../fileStorage/actions'; import deleteFileAlert from './deleteFileAlert/reducers'; +import duplicateFileDialog from './duplicateFileDialog/reducers'; import newFileWizard from './newFileWizard/reducers'; import renameFileDialog from './renameFileDialog/reducers'; @@ -55,6 +56,7 @@ const files: Reducer = (state = [], action) => { export default combineReducers({ files, + duplicateFileDialog, deleteFileAlert, newFileWizard, renameFileDialog, diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts index bec97f98..3759dba5 100644 --- a/src/explorer/sagas.test.ts +++ b/src/explorer/sagas.test.ts @@ -13,9 +13,12 @@ import { editorDidFailToActivateFile, } from '../editor/actions'; import { + fileStorageCopyFile, fileStorageDeleteFile, + fileStorageDidCopyFile, fileStorageDidDeleteFile, fileStorageDidDumpAllFiles, + fileStorageDidFailToCopyFile, fileStorageDidFailToDeleteFile, fileStorageDidFailToDumpAllFiles, fileStorageDidFailToReadFile, @@ -36,14 +39,17 @@ import { explorerDidArchiveAllFiles, explorerDidCreateNewFile, explorerDidDeleteFile, + explorerDidDuplicateFile, explorerDidExportFile, explorerDidFailToActivateFile, explorerDidFailToArchiveAllFiles, explorerDidFailToCreateNewFile, explorerDidFailToDeleteFile, + explorerDidFailToDuplicateFile, explorerDidFailToExportFile, explorerDidFailToImportFiles, explorerDidImportFiles, + explorerDuplicateFile, explorerExportFile, explorerImportFiles, } from './actions'; @@ -52,6 +58,11 @@ import { deleteFileAlertDidCancel, deleteFileAlertShow, } from './deleteFileAlert/actions'; +import { + duplicateFileDialogDidAccept, + duplicateFileDialogDidCancel, + duplicateFileDialogShow, +} from './duplicateFileDialog/actions'; import { Hub, newFileWizardDidAccept, @@ -252,6 +263,61 @@ describe('handleExplorerActivateFile', () => { }); }); +describe('handleExplorerDuplicateFile', () => { + let saga: AsyncSaga; + + beforeEach(async () => { + saga = new AsyncSaga(explorer); + + saga.put(explorerDuplicateFile('old.file')); + + await expect(saga.take()).resolves.toEqual(duplicateFileDialogShow('old.file')); + }); + + it('should dispatch action if canceled', async () => { + saga.put(duplicateFileDialogDidCancel()); + + await expect(saga.take()).resolves.toEqual( + explorerDidFailToDuplicateFile( + 'old.file', + new DOMException('user canceled', 'AbortError'), + ), + ); + }); + + describe('user accepted', () => { + beforeEach(async () => { + saga.put(duplicateFileDialogDidAccept('old.file', 'new.file')); + + await expect(saga.take()).resolves.toEqual( + fileStorageCopyFile('old.file', 'new.file'), + ); + }); + + it('should propagate failure', async () => { + const testError = new Error('test error'); + + saga.put(fileStorageDidFailToCopyFile('old.file', testError)); + + await expect(saga.take()).resolves.toEqual( + explorerDidFailToDuplicateFile('old.file', testError), + ); + }); + + it('should dispatch action on fileStorageDuplicateFile success', async () => { + saga.put(fileStorageDidCopyFile('old.file')); + + await expect(saga.take()).resolves.toEqual( + explorerDidDuplicateFile('old.file'), + ); + }); + }); + + afterEach(async () => { + await saga.end(); + }); +}); + describe('handleExplorerExportFile', () => { let saga: AsyncSaga; const testFile = 'test.file'; diff --git a/src/explorer/sagas.ts b/src/explorer/sagas.ts index 97563e6e..bb571ff7 100644 --- a/src/explorer/sagas.ts +++ b/src/explorer/sagas.ts @@ -13,9 +13,12 @@ import { } from '../editor/actions'; import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython'; import { + fileStorageCopyFile, fileStorageDeleteFile, + fileStorageDidCopyFile, fileStorageDidDeleteFile, fileStorageDidDumpAllFiles, + fileStorageDidFailToCopyFile, fileStorageDidFailToDeleteFile, fileStorageDidFailToDumpAllFiles, fileStorageDidFailToReadFile, @@ -45,14 +48,17 @@ import { explorerDidArchiveAllFiles, explorerDidCreateNewFile, explorerDidDeleteFile, + explorerDidDuplicateFile, explorerDidExportFile, explorerDidFailToActivateFile, explorerDidFailToArchiveAllFiles, explorerDidFailToCreateNewFile, explorerDidFailToDeleteFile, + explorerDidFailToDuplicateFile, explorerDidFailToExportFile, explorerDidFailToImportFiles, explorerDidImportFiles, + explorerDuplicateFile, explorerExportFile, explorerImportFiles, } from './actions'; @@ -61,6 +67,11 @@ import { deleteFileAlertDidCancel, deleteFileAlertShow, } from './deleteFileAlert/actions'; +import { + duplicateFileDialogDidAccept, + duplicateFileDialogDidCancel, + duplicateFileDialogShow, +} from './duplicateFileDialog/actions'; import { newFileWizardDidAccept, newFileWizardDidCancel, @@ -246,6 +257,48 @@ function* handleExplorerActivateFile( yield* put(explorerDidActivateFile(didActivate.fileName)); } +/** Connects user initiate duplicate file actions to the duplicate file dialog. */ +function* handleExplorerDuplicateFile( + action: ReturnType, +): Generator { + try { + yield* put(duplicateFileDialogShow(action.fileName)); + + const { didAccept, didCancel } = yield* race({ + didAccept: take(duplicateFileDialogDidAccept), + didCancel: take(duplicateFileDialogDidCancel), + }); + + if (didCancel) { + throw new DOMException('user canceled', 'AbortError'); + } + + defined(didAccept); + + // REVISIT: if editor is not flushed to storage right away, we would + // need to check for open editors here + + yield* put(fileStorageCopyFile(action.fileName, didAccept.newName)); + + const { didFailToCopy } = yield* race({ + didCopy: take( + fileStorageDidCopyFile.when((a) => a.path === action.fileName), + ), + didFailToCopy: take( + fileStorageDidFailToCopyFile.when((a) => a.path === action.fileName), + ), + }); + + if (didFailToCopy) { + throw didFailToCopy.error; + } + + yield* put(explorerDidDuplicateFile(action.fileName)); + } catch (err) { + yield* put(explorerDidFailToDuplicateFile(action.fileName, ensureError(err))); + } +} + function* handleExplorerExportFile( action: ReturnType, ): Generator { @@ -341,6 +394,7 @@ export default function* (): Generator { yield* takeEvery(explorerImportFiles, handleExplorerImportFiles); yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile); yield* takeEvery(explorerActivateFile, handleExplorerActivateFile); + yield* takeEvery(explorerDuplicateFile, handleExplorerDuplicateFile); yield* takeEvery(explorerExportFile, handleExplorerExportFile); yield* takeEvery(explorerDeleteFile, handleExplorerDeleteFile); } diff --git a/src/explorer/translations/en.json b/src/explorer/translations/en.json index 84551b6d..384e09af 100644 --- a/src/explorer/translations/en.json +++ b/src/explorer/translations/en.json @@ -12,6 +12,7 @@ "navigation": "Navigate the tree with the arrow keys. Start typing the name of a file to search for a file. Additional keybindings are available:", "keybindings": { "primaryAction": "{key} to open the file in the code editor", + "duplicate": "{key} to duplicate focused file", "export": "{key} to export the focused file", "delete": "{key} to delete the focused file" } @@ -20,7 +21,8 @@ } }, "treeItem": { - "deleteTooltip": "Delete {fileName}", - "exportTooltip": "Export {fileName}" + "duplicateTooltip": "Duplicate {fileName}", + "exportTooltip": "Export {fileName}", + "deleteTooltip": "Delete {fileName}" } } diff --git a/src/fileStorage/actions.ts b/src/fileStorage/actions.ts index 7ad216bd..ee70a6a4 100644 --- a/src/fileStorage/actions.ts +++ b/src/fileStorage/actions.ts @@ -261,6 +261,39 @@ export const fileStorageDidFailToWriteFile = createAction( }), ); +/** + * Request to copy a file from storage. + * @param path: The path of the file to be copied. + * @param newPath: The path of the new file to be created. + */ +export const fileStorageCopyFile = createAction((path: string, newPath: string) => ({ + type: 'fileStorage.action.copyFile', + path, + newPath, +})); + +/** + * Indicates that {@link fileStorageCopyFile} succeeded. + * @param path: The file path. + */ +export const fileStorageDidCopyFile = createAction((path: string) => ({ + type: 'fileStorage.action.didCopyFile', + path, +})); + +/** + * Indicates that {@link fileStorageCopyFile} failed. + * @param path: The file path. + * @param error The error. + */ +export const fileStorageDidFailToCopyFile = createAction( + (path: string, error: Error) => ({ + type: 'fileStorage.action.didFailToCopyFile', + path, + error, + }), +); + /** * Request to delete a file from storage. * @param path: The file path. diff --git a/src/fileStorage/sagas.test.ts b/src/fileStorage/sagas.test.ts index 767742c0..32c4b8a8 100644 --- a/src/fileStorage/sagas.test.ts +++ b/src/fileStorage/sagas.test.ts @@ -11,12 +11,15 @@ import { FileMetadata, FileOpenMode, fileStorageClose, + fileStorageCopyFile, fileStorageDeleteFile, fileStorageDidAddItem, fileStorageDidChangeItem, fileStorageDidClose, + fileStorageDidCopyFile, fileStorageDidDeleteFile, fileStorageDidDumpAllFiles, + fileStorageDidFailToCopyFile, fileStorageDidFailToDeleteFile, fileStorageDidFailToDumpAllFiles, fileStorageDidFailToInitialize, @@ -544,6 +547,78 @@ describe('writeFile', () => { }); }); +describe('copyFile', () => { + let saga: AsyncSaga; + let testFile: FileMetadata; + + beforeEach(async () => { + saga = new AsyncSaga(fileStorage); + + await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); + [testFile] = await setUpTestFile(saga); + }); + + it('should fail if file does not exist', async () => { + saga.put(fileStorageCopyFile('other.file', 'new.file')); + + await expect(saga.take()).resolves.toEqual( + fileStorageDidFailToCopyFile( + 'other.file', + new Error("file 'other.file' does not exist"), + ), + ); + }); + + it('should fail if new file is open', async () => { + saga.put(fileStorageOpen('new.file', 'w')); + await expect(saga.take()).resolves.toEqual( + fileStorageDidOpen('new.file', 1 as FD), + ); + + saga.put(fileStorageCopyFile('test.file', 'new.file')); + + await expect(saga.take()).resolves.toEqual( + fileStorageDidFailToCopyFile( + 'test.file', + new Error("file 'new.file' is in use"), + ), + ); + }); + + it('should fail if new file exists', async () => { + saga.put(fileStorageOpen('new.file', 'w')); + await expect(saga.take()).resolves.toEqual( + fileStorageDidOpen('new.file', 1 as FD), + ); + + saga.put(fileStorageClose(1 as FD)); + await expect(saga.take()).resolves.toEqual(fileStorageDidClose(1 as FD)); + + saga.put(fileStorageCopyFile('test.file', 'new.file')); + + await expect(saga.take()).resolves.toEqual( + fileStorageDidFailToCopyFile( + 'test.file', + new Error("file 'new.file' already exists"), + ), + ); + }); + + it('should copy file', async () => { + saga.put(fileStorageCopyFile('test.file', 'new.file')); + + await expect(saga.take()).resolves.toEqual(fileStorageDidCopyFile('test.file')); + + await expect(saga.take()).resolves.toEqual( + fileStorageDidAddItem({ ...testFile, uuid: uuid(1), path: 'new.file' }), + ); + }); + + afterEach(async () => { + await saga.end(); + }); +}); + describe('deleteFile', () => { let saga: AsyncSaga; let testFile: FileMetadata; diff --git a/src/fileStorage/sagas.ts b/src/fileStorage/sagas.ts index 14de30b1..7acf8236 100644 --- a/src/fileStorage/sagas.ts +++ b/src/fileStorage/sagas.ts @@ -20,12 +20,15 @@ import { FileOpenMode, UUID, fileStorageClose, + fileStorageCopyFile, fileStorageDeleteFile, fileStorageDidAddItem, fileStorageDidChangeItem, fileStorageDidClose, + fileStorageDidCopyFile, fileStorageDidDeleteFile, fileStorageDidDumpAllFiles, + fileStorageDidFailToCopyFile, fileStorageDidFailToDeleteFile, fileStorageDidFailToDumpAllFiles, fileStorageDidFailToInitialize, @@ -481,6 +484,66 @@ function* handleWriteFile(action: ReturnType): Gene } } +function* handleCopyFile( + db: FileStorageDb, + action: ReturnType, +): Generator { + try { + yield* call(() => + navigator.locks.request( + lockNameForPath(action.newPath), + { ifAvailable: true }, + async (lock) => { + if (lock === null) { + throw new Error(`file '${action.newPath}' is in use`); + } + + await db.transaction('rw', db.metadata, db._contents, async () => { + const metadata = await db.metadata + .where('path') + .equals(action.path) + .first(); + + if (!metadata) { + throw new Error(`file '${action.path}' does not exist`); + } + + if ( + await db.metadata + .where('path') + .equals(action.newPath) + .first() + ) { + throw new Error(`file '${action.newPath}' already exists`); + } + + await db.metadata.add((>{ + ...metadata, + uuid: undefined, + path: action.newPath, + }) as FileMetadata); + + const contents = await db._contents.get(metadata.path); + + // istanbul ignore if: should not be reachable + if (!contents) { + throw new Error( + `bug: missing file content for ${metadata.path}`, + ); + } + + await db._contents.add({ ...contents, path: action.newPath }); + }); + }, + ), + ); + + yield* put(fileStorageDidCopyFile(action.path)); + } catch (err) { + yield* put(fileStorageDidFailToCopyFile(action.path, ensureError(err))); + } +} + /** * Deletes a file from storage. * @param db The database instance. @@ -690,6 +753,7 @@ function* initialize(): Generator { yield* takeEvery(fileStorageWrite, handleWrite, db, openFds); yield* takeEvery(fileStorageReadFile, handleReadFile); yield* takeEvery(fileStorageWriteFile, handleWriteFile); + yield* takeEvery(fileStorageCopyFile, handleCopyFile, db); yield* takeEvery(fileStorageDeleteFile, handleDeleteFile, db); yield* takeEvery(fileStorageRenameFile, handleRenameFile, db); yield* takeEvery(fileStorageDumpAllFiles, handleDumpAllFiles, db); diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index f6793149..70703bba 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -18,6 +18,7 @@ export enum I18nId { ExplorerFailedToImportFiles = 'explorer.failedToImportFiles', ExplorerFailedToCreate = 'explorer.failedToCreate', ExplorerFailedToDelete = 'explorer.failedToDelete', + ExplorerFailedToDuplicate = 'explorer.failedToDuplicate', ExplorerFailedToExport = 'explorer.failedToExport', ExplorerFailedToArchive = 'explorer.failedToArchive', FileStorageFailedToInitialize = 'fileStorage.failedToInitialize', diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 905aca92..ead5bdd9 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -21,6 +21,7 @@ import { explorerDidFailToArchiveAllFiles, explorerDidFailToCreateNewFile, explorerDidFailToDeleteFile, + explorerDidFailToDuplicateFile, explorerDidFailToExportFile, explorerDidFailToImportFiles, } from '../explorer/actions'; @@ -112,6 +113,7 @@ test.each([ explorerDidFailToArchiveAllFiles(new Error('test error')), explorerDidFailToImportFiles(new Error('test error')), explorerDidFailToCreateNewFile(new Error('test error')), + explorerDidFailToDuplicateFile('test.file', 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')), @@ -137,6 +139,10 @@ test.each([ explorerDidFailToArchiveAllFiles(new DOMException('test message', 'AbortError')), explorerDidFailToImportFiles(new DOMException('test message', 'AbortError')), explorerDidFailToCreateNewFile(new DOMException('test message', 'AbortError')), + explorerDidFailToDuplicateFile( + 'test.file', + new DOMException('test message', 'AbortError'), + ), explorerDidFailToExportFile( 'test.file', new DOMException('test message', 'AbortError'), diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index afab9d62..aae175db 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -22,6 +22,7 @@ import { explorerDidFailToArchiveAllFiles, explorerDidFailToCreateNewFile, explorerDidFailToDeleteFile, + explorerDidFailToDuplicateFile, explorerDidFailToExportFile, explorerDidFailToImportFiles, } from '../explorer/actions'; @@ -417,6 +418,17 @@ function* showExplorerFailToCreateFile( yield* showUnexpectedError(I18nId.ExplorerFailedToCreate, action.error); } +function* showExplorerFailToDuplicate( + action: ReturnType, +): Generator { + if (action.error.name === 'AbortError') { + // user clicked cancel button - not an error + return; + } + + yield* showUnexpectedError(I18nId.ExplorerFailedToDuplicate, action.error); +} + function* showExplorerFailToExport( action: ReturnType, ): Generator { @@ -462,6 +474,7 @@ export default function* (): Generator { yield* takeEvery(explorerDidFailToArchiveAllFiles, showFileStorageFailToArchive); yield* takeEvery(explorerDidFailToImportFiles, showExplorerFailToImportFiles); yield* takeEvery(explorerDidFailToCreateNewFile, showExplorerFailToCreateFile); + yield* takeEvery(explorerDidFailToDuplicateFile, showExplorerFailToDuplicate); 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 d5e00ce1..882c87d5 100644 --- a/src/notifications/translations/en.json +++ b/src/notifications/translations/en.json @@ -19,6 +19,7 @@ "explorer": { "failedToImportFiles": "Failed to import file(s).", "failedToCreate": "Failed to create file.", + "failedToDuplicate": "Failed to duplicate file.", "failedToExport": "Failed to export file.", "failedToDelete": "Failed to delete file.", "failedToArchive": "Failed to archive files.'" diff --git a/src/settings/SettingsDrawer.test.tsx b/src/settings/SettingsDrawer.test.tsx index c7b4f638..d08d0f6e 100644 --- a/src/settings/SettingsDrawer.test.tsx +++ b/src/settings/SettingsDrawer.test.tsx @@ -24,19 +24,6 @@ describe('showDocs setting switch', () => { userEvent.click(showDocs); expect(showDocs).not.toBeChecked(); }); - - it('should have global keyboard shortcut', async () => { - const [settings] = testRender( - undefined} />, - ); - - const showDocs = settings.getByLabelText('Documentation'); - expect(showDocs).toBeChecked(); - - userEvent.keyboard('{ctrl}d{/ctrl}'); - - await waitFor(() => expect(showDocs).not.toBeChecked()); - }); }); describe('darkMode setting switch', () => { diff --git a/src/settings/SettingsDrawer.tsx b/src/settings/SettingsDrawer.tsx index 021f630e..f9054f06 100644 --- a/src/settings/SettingsDrawer.tsx +++ b/src/settings/SettingsDrawer.tsx @@ -15,11 +15,10 @@ import { Intent, Label, Switch, - useHotkeys, } from '@blueprintjs/core'; import { Tooltip2 } from '@blueprintjs/popover2'; import { useI18n } from '@shopify/react-i18n'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useTernaryDarkMode } from 'usehooks-ts'; import AboutDialog from '../about/AboutDialog'; @@ -52,11 +51,8 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ isOpen, onClose, }) => { - const { - isSettingShowDocsEnabled, - setIsSettingShowDocsEnabled, - toggleIsSettingShowDocsEnabled, - } = useSettingIsShowDocsEnabled(); + const { isSettingShowDocsEnabled, setIsSettingShowDocsEnabled } = + useSettingIsShowDocsEnabled(); const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false); const { isDarkMode, setTernaryDarkMode } = useTernaryDarkMode(); @@ -79,21 +75,6 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ // istanbul ignore next: babel-loader rewrites this line const [i18n] = useI18n(); - const hotkeys = useMemo( - () => [ - { - combo: 'mod+d', - label: i18n.translate(I18nId.AppearanceDocumentationTooltip), - global: true, - preventDefault: true, - onKeyDown: toggleIsSettingShowDocsEnabled, - }, - ], - [i18n, toggleIsSettingShowDocsEnabled], - ); - - useHotkeys(hotkeys); - // HACK: set additional attributes that are not supported via Drawer props const handleDrawerOpening = useCallback<(node: HTMLElement) => void>((n) => { n.setAttribute('role', 'dialog');