mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 17:45:22 +00:00
explorer: add action for activating file
this simply forwards the action to the editor module
This commit is contained in:
@@ -27,3 +27,33 @@ export const editorGetValueResponse = createAction((id: number, value: string) =
|
||||
id,
|
||||
value,
|
||||
}));
|
||||
/**
|
||||
* Request to activate a file (open or bring to foreground if already open).
|
||||
* @param fileName The file name.
|
||||
*/
|
||||
export const editorActivateFile = createAction((fileName: string) => ({
|
||||
type: 'editor.action.activateFile',
|
||||
fileName,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link editorActivateFile} succeeded.
|
||||
* @param fileName The file name.
|
||||
*/
|
||||
export const editorDidActivateFile = createAction((fileName: string) => ({
|
||||
type: 'editor.action.didActivateFile',
|
||||
fileName,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link editorActivateFile} failed.
|
||||
* @param fileName The file name.
|
||||
* @param error The error that was raised.
|
||||
*/
|
||||
export const editorDidFailToActivateFile = createAction(
|
||||
(fileName: string, error: Error) => ({
|
||||
type: 'editor.action.didFailToActivateFile',
|
||||
fileName,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import React from 'react';
|
||||
import { testRender, uuid } from '../../test';
|
||||
import Explorer from './Explorer';
|
||||
import {
|
||||
explorerActivateFile,
|
||||
explorerArchiveAllFiles,
|
||||
explorerDeleteFile,
|
||||
explorerExportFile,
|
||||
@@ -81,22 +82,16 @@ describe('new file button', () => {
|
||||
});
|
||||
|
||||
describe('tree item', () => {
|
||||
it('should dispatch action when button is clicked', async () => {
|
||||
it('should dispatch action when clicked', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
|
||||
expect(
|
||||
explorer.queryByRole('dialog', { name: "Rename 'test.file'" }),
|
||||
).toBeNull();
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
// NB: this button is intentionally not accessible (by role) since
|
||||
// there is a keyboard shortcut.
|
||||
const button = explorer.getByTitle('Rename test.file');
|
||||
userEvent.click(treeItem);
|
||||
|
||||
userEvent.click(button);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerRenameFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerActivateFile('test.file'));
|
||||
});
|
||||
|
||||
it('should dispatch action when key is pressed', async () => {
|
||||
@@ -104,69 +99,98 @@ describe('tree item', () => {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
|
||||
expect(
|
||||
explorer.queryByRole('dialog', { name: "Rename 'test.file'" }),
|
||||
).toBeNull();
|
||||
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
userEvent.click(treeItem);
|
||||
userEvent.keyboard('{f2}');
|
||||
userEvent.keyboard('{enter}');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerRenameFile('test.file'));
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerActivateFile('test.file'));
|
||||
});
|
||||
|
||||
it('should dispatch delete action when button is clicked', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
describe('rename', () => {
|
||||
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('Rename test.file');
|
||||
|
||||
userEvent.click(button);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerRenameFile('test.file'));
|
||||
});
|
||||
|
||||
// NB: this button is intentionally not accessible (by role) since
|
||||
// there is a keyboard shortcut.
|
||||
const button = explorer.getByTitle('Delete test.file');
|
||||
it('should dispatch action when key is pressed', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
|
||||
userEvent.click(button);
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerDeleteFile('test.file'));
|
||||
userEvent.click(treeItem);
|
||||
userEvent.keyboard('{f2}');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerRenameFile('test.file'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch delete action when key is pressed', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
describe('export', () => {
|
||||
it('should dispatch export 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('Export test.file');
|
||||
|
||||
userEvent.click(button);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerExportFile('test.file'));
|
||||
});
|
||||
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
it('should dispatch export action when key is pressed', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
|
||||
userEvent.click(treeItem);
|
||||
userEvent.keyboard('{del}');
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerDeleteFile('test.file'));
|
||||
userEvent.click(treeItem);
|
||||
userEvent.keyboard('{ctrl}e');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerExportFile('test.file'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch export action when button is clicked', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
describe('delete', () => {
|
||||
it('should dispatch delete 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('Delete test.file');
|
||||
|
||||
userEvent.click(button);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerDeleteFile('test.file'));
|
||||
});
|
||||
|
||||
// NB: this button is intentionally not accessible (by role) since
|
||||
// there is a keyboard shortcut.
|
||||
const button = explorer.getByTitle('Export test.file');
|
||||
it('should dispatch delete action when key is pressed', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
});
|
||||
|
||||
userEvent.click(button);
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerExportFile('test.file'));
|
||||
});
|
||||
userEvent.click(treeItem);
|
||||
userEvent.keyboard('{del}');
|
||||
|
||||
it('should dispatch export action when key is pressed', async () => {
|
||||
const [explorer, dispatch] = testRender(<Explorer />, {
|
||||
explorer: { files: [testFile] },
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerDeleteFile('test.file'));
|
||||
});
|
||||
|
||||
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
|
||||
|
||||
userEvent.click(treeItem);
|
||||
userEvent.keyboard('{ctrl}e');
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(explorerExportFile('test.file'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { isMacOS } from '../utils/os';
|
||||
import { preventBrowserNativeContextMenu } from '../utils/react';
|
||||
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
|
||||
import {
|
||||
explorerActivateFile,
|
||||
explorerArchiveAllFiles,
|
||||
explorerDeleteFile,
|
||||
explorerExportFile,
|
||||
@@ -336,6 +337,8 @@ const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
|
||||
[treeId, focusedItem],
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<ControlledTreeEnvironment<FileTreeItemData>
|
||||
{...renderers}
|
||||
@@ -346,6 +349,9 @@ const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
|
||||
liveDescriptors={liveDescriptors}
|
||||
canRename={false} // we implement our own rename handler
|
||||
onFocusItem={(item) => setFocusedItem(item.index)}
|
||||
onPrimaryAction={(item) =>
|
||||
dispatch(explorerActivateFile(item.data.fileName))
|
||||
}
|
||||
>
|
||||
<div className="pb-explorer-file-tree">
|
||||
<Tree
|
||||
|
||||
@@ -100,6 +100,37 @@ export const explorerDidFailToCreateNewFile = createAction((error: Error) => ({
|
||||
error,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Request to activate a file (open or bring to foreground if already open).
|
||||
* @param fileName The file name.
|
||||
*/
|
||||
export const explorerActivateFile = createAction((fileName: string) => ({
|
||||
type: 'explorer.action.activateFile',
|
||||
fileName,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link explorerActivateFile} succeeded.
|
||||
* @param fileName The file name.
|
||||
*/
|
||||
export const explorerDidActivateFile = createAction((fileName: string) => ({
|
||||
type: 'explorer.action.didActivateFile',
|
||||
fileName,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link explorerActivateFile} failed.
|
||||
* @param fileName The file name.
|
||||
* @param error The error that was raised.
|
||||
*/
|
||||
export const explorerDidFailToActivateFile = createAction(
|
||||
(fileName: string, error: Error) => ({
|
||||
type: 'explorer.action.didFailToActivateFile',
|
||||
fileName,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Action that requests to rename a file.
|
||||
* @param fileName The file name.
|
||||
|
||||
@@ -5,6 +5,11 @@ import * as browserFsAccess from 'browser-fs-access';
|
||||
import { FileWithHandle } from 'browser-fs-access';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { AsyncSaga } from '../../test';
|
||||
import {
|
||||
editorActivateFile,
|
||||
editorDidActivateFile,
|
||||
editorDidFailToActivateFile,
|
||||
} from '../editor/actions';
|
||||
import {
|
||||
fileStorageDidDumpAllFiles,
|
||||
fileStorageDidFailToDumpAllFiles,
|
||||
@@ -21,11 +26,14 @@ import {
|
||||
import { pythonFileExtension } from '../pybricksMicropython/lib';
|
||||
import {
|
||||
Hub,
|
||||
explorerActivateFile,
|
||||
explorerArchiveAllFiles,
|
||||
explorerCreateNewFile,
|
||||
explorerDidActivateFile,
|
||||
explorerDidArchiveAllFiles,
|
||||
explorerDidCreateNewFile,
|
||||
explorerDidExportFile,
|
||||
explorerDidFailToActivateFile,
|
||||
explorerDidFailToArchiveAllFiles,
|
||||
explorerDidFailToExportFile,
|
||||
explorerDidFailToImportFiles,
|
||||
@@ -180,6 +188,39 @@ describe('handleExplorerCreateNewFile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleExplorerActivateFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(explorer);
|
||||
|
||||
saga.put(explorerActivateFile('test.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(editorActivateFile('test.file'));
|
||||
});
|
||||
|
||||
it('should propagate error', async () => {
|
||||
const testError = new Error('test error');
|
||||
saga.put(editorDidFailToActivateFile('test.file', testError));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidFailToActivateFile('test.file', testError),
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate success', async () => {
|
||||
saga.put(editorDidActivateFile('test.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidActivateFile('test.file'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleExplorerRenameFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
takeEvery,
|
||||
takeLatest,
|
||||
} from 'typed-redux-saga/macro';
|
||||
import {
|
||||
editorActivateFile,
|
||||
editorDidActivateFile,
|
||||
editorDidFailToActivateFile,
|
||||
} from '../editor/actions';
|
||||
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
|
||||
import {
|
||||
fileStorageDidDumpAllFiles,
|
||||
@@ -37,11 +42,14 @@ import {
|
||||
import { RootState } from '../reducers';
|
||||
import { defined, ensureError, timestamp } from '../utils';
|
||||
import {
|
||||
explorerActivateFile,
|
||||
explorerArchiveAllFiles,
|
||||
explorerCreateNewFile,
|
||||
explorerDidActivateFile,
|
||||
explorerDidArchiveAllFiles,
|
||||
explorerDidCreateNewFile,
|
||||
explorerDidExportFile,
|
||||
explorerDidFailToActivateFile,
|
||||
explorerDidFailToArchiveAllFiles,
|
||||
explorerDidFailToCreateNewFile,
|
||||
explorerDidFailToExportFile,
|
||||
@@ -192,6 +200,39 @@ function* handleExplorerCreateNewFile(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects user triggered action to editor module.
|
||||
* @param action
|
||||
*/
|
||||
function* handleExplorerActivateFile(
|
||||
action: ReturnType<typeof explorerActivateFile>,
|
||||
): Generator {
|
||||
yield* put(editorActivateFile(action.fileName));
|
||||
|
||||
const { didActivate, didFailToActivate } = yield* race({
|
||||
didActivate: take(
|
||||
editorDidActivateFile.when((a) => a.fileName === action.fileName),
|
||||
),
|
||||
didFailToActivate: take(
|
||||
editorDidFailToActivateFile.when((a) => a.fileName === action.fileName),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToActivate) {
|
||||
yield* put(
|
||||
explorerDidFailToActivateFile(
|
||||
didFailToActivate.fileName,
|
||||
didFailToActivate.error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
defined(didActivate);
|
||||
|
||||
yield* put(explorerDidActivateFile(didActivate.fileName));
|
||||
}
|
||||
|
||||
/** Connects user initiate rename file actions to the rename file dialog. */
|
||||
function* handleExplorerRenameFile(
|
||||
action: ReturnType<typeof explorerRenameFile>,
|
||||
@@ -273,6 +314,7 @@ export default function* (): Generator {
|
||||
yield* takeEvery(explorerArchiveAllFiles, handleExplorerArchiveAllFiles);
|
||||
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
|
||||
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
|
||||
yield* takeEvery(explorerActivateFile, handleExplorerActivateFile);
|
||||
// takeLatest should ensure that if we trigger a new rename before the
|
||||
// previous one is finished, the old one will be canceled. We don't expect
|
||||
// this to happen in practice though.
|
||||
|
||||
Reference in New Issue
Block a user