mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
explorer: implement duplicate file feature
This commit is contained in:
@@ -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 (
|
||||
<div className="pb-app h-100 w-100 p-absolute">
|
||||
<Toolbar />
|
||||
|
||||
@@ -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 />, {
|
||||
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 />, {
|
||||
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(<Explorer />, {
|
||||
|
||||
@@ -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<ActionButtonGroupProps>
|
||||
className="pb-explorer-file-action-button-group"
|
||||
minimal={true}
|
||||
>
|
||||
<ActionButton
|
||||
icon="duplicate"
|
||||
tooltip={i18n.translate(I18nId.TreeItemDuplicateTooltip, { fileName })}
|
||||
focusable={false}
|
||||
onClick={() => dispatch(explorerDuplicateFile(fileName))}
|
||||
/>
|
||||
<ActionButton
|
||||
// NB: the "import" icon has an arrow pointing down, which is
|
||||
// what we want here since import is analogous to download
|
||||
@@ -181,6 +189,10 @@ function useLiveDescriptors(i18n: I18n): LiveDescriptors {
|
||||
I18nId.TreeLiveDescriptorIntroKeybindingsPrimaryAction,
|
||||
{ key: '{keybinding:primaryAction}' },
|
||||
)}</li>
|
||||
<li>${i18n.translate(
|
||||
I18nId.TreeLiveDescriptorIntroKeybindingsDuplicate,
|
||||
{ key: `${isMacOS() ? 'cmd' : 'ctrl'}+d` },
|
||||
)}</li>
|
||||
<li>${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<readonly HotkeyConfig[]>(
|
||||
() => [
|
||||
{
|
||||
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 = () => {
|
||||
<Divider />
|
||||
<FileTree i18n={i18n} />
|
||||
<NewFileWizard />
|
||||
<DuplicateFileDialog />
|
||||
<DeleteFileAlert />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(<DuplicateFileDialog />, {
|
||||
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(<DuplicateFileDialog />, {
|
||||
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(<DuplicateFileDialog />, {
|
||||
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(<DuplicateFileDialog />, {
|
||||
explorer: {
|
||||
duplicateFileDialog: { isOpen: true, fileName: 'source.file' },
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(dialog.getByRole('textbox', { name: 'File name' })).toHaveFocus(),
|
||||
);
|
||||
|
||||
userEvent.keyboard('{esc}');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(duplicateFileDialogDidCancel());
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = useCallback<React.FormEventHandler>(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
dispatch(duplicateFileDialogDidAccept(oldName, `${newName}${extension}`));
|
||||
},
|
||||
[dispatch, oldName, newName, extension],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(duplicateFileDialogDidCancel());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={i18n.translate(I18nId.Title, {
|
||||
fileName: oldName,
|
||||
})}
|
||||
isOpen={isOpen}
|
||||
onOpening={() => setNewName(baseName)}
|
||||
onOpened={() => {
|
||||
inputRef.current?.select();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<FileNameFormGroup
|
||||
fileName={newName}
|
||||
fileExtension={extension}
|
||||
validationResult={result}
|
||||
inputRef={inputRef}
|
||||
onChange={setNewName}
|
||||
/>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button
|
||||
intent="primary"
|
||||
disabled={result !== FileNameValidationResult.IsOk}
|
||||
type="submit"
|
||||
>
|
||||
{i18n.translate(I18nId.ActionAccept)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DuplicateFileDialog;
|
||||
@@ -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',
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
export enum I18nId {
|
||||
Title = 'title',
|
||||
ActionAccept = 'action.accept',
|
||||
}
|
||||
@@ -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<boolean> = (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<string> = (state = '', action) => {
|
||||
if (duplicateFileDialogShow.matches(action)) {
|
||||
return action.oldName;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({ isOpen, fileName });
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"title": "Duplicate '{fileName}'",
|
||||
"action": {
|
||||
"accept": "Duplicate"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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<readonly ExplorerFileInfo[]> = (state = [], action) => {
|
||||
|
||||
export default combineReducers({
|
||||
files,
|
||||
duplicateFileDialog,
|
||||
deleteFileAlert,
|
||||
newFileWizard,
|
||||
renameFileDialog,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof explorerDuplicateFile>,
|
||||
): 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<typeof explorerExportFile>,
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof fileStorageWriteFile>): Gene
|
||||
}
|
||||
}
|
||||
|
||||
function* handleCopyFile(
|
||||
db: FileStorageDb,
|
||||
action: ReturnType<typeof fileStorageCopyFile>,
|
||||
): 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((<Omit<FileMetadata, 'uuid'>>{
|
||||
...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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<typeof explorerDidFailToDuplicateFile>,
|
||||
): Generator {
|
||||
if (action.error.name === 'AbortError') {
|
||||
// user clicked cancel button - not an error
|
||||
return;
|
||||
}
|
||||
|
||||
yield* showUnexpectedError(I18nId.ExplorerFailedToDuplicate, action.error);
|
||||
}
|
||||
|
||||
function* showExplorerFailToExport(
|
||||
action: ReturnType<typeof explorerDidFailToExportFile>,
|
||||
): 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);
|
||||
|
||||
@@ -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.'"
|
||||
|
||||
@@ -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(
|
||||
<SettingsDrawer isOpen={true} onClose={() => undefined} />,
|
||||
);
|
||||
|
||||
const showDocs = settings.getByLabelText('Documentation');
|
||||
expect(showDocs).toBeChecked();
|
||||
|
||||
userEvent.keyboard('{ctrl}d{/ctrl}');
|
||||
|
||||
await waitFor(() => expect(showDocs).not.toBeChecked());
|
||||
});
|
||||
});
|
||||
|
||||
describe('darkMode setting switch', () => {
|
||||
|
||||
@@ -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<SettingsProps> = ({
|
||||
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<SettingsProps> = ({
|
||||
// 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');
|
||||
|
||||
Reference in New Issue
Block a user