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(