Files
pybricks-code/src/explorer/Explorer.tsx
T
2022-03-25 15:54:05 -05:00

373 lines
12 KiB
TypeScript

// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// A file explorer control.
import {
Button,
ButtonGroup,
Divider,
HotkeyConfig,
IconName,
useHotkeys,
} from '@blueprintjs/core';
import { I18n, useI18n } from '@shopify/react-i18n';
import React, { useCallback, useMemo, useState } from 'react';
import {
ControlledTreeEnvironment,
LiveDescriptors,
Tree,
TreeItem,
TreeItemIndex,
useTree,
useTreeEnvironment,
} from 'react-complex-tree';
import { useDispatch } from 'react-redux';
import { useDebounce } from 'usehooks-ts';
import {
fileStorageArchiveAllFiles,
fileStorageExportFile,
} from '../fileStorage/actions';
import { useSelector } from '../reducers';
import { isMacOS } from '../utils/os';
import { preventBrowserNativeContextMenu } from '../utils/react';
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
import { explorerDeleteFile, explorerImportFiles, explorerRenameFile } from './actions';
import { I18nId } from './i18n';
import NewFileWizard from './newFileWizard/NewFileWizard';
import RenameFileDialog from './renameFileDialog/RenameFileDialog';
import './explorer.scss';
type ActionButtonProps = {
/** The icon to use for the button. */
icon: IconName;
/** The tooltip/title text. */
tooltip: string;
/** If provided, controls button disabled state. */
disabled?: boolean;
/** If false, prevent focus. Default is true. */
focusable?: boolean;
/** Callback for button click event. */
onClick: () => void;
};
const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
icon,
tooltip,
disabled,
focusable,
onClick,
}) => {
return (
<Button
icon={icon}
title={tooltip}
disabled={disabled}
tabIndex={focusable === false ? -1 : undefined}
onFocus={focusable === false ? (e) => e.preventDefault() : undefined}
onClick={onClick}
/>
);
};
type FileTreeItemData = TreeItemData & { fileName: string };
type FileTreeItem = TreeItem<FileTreeItemData>;
type ActionButtonGroupProps = {
/** The name of the file (displayed to user) */
item: TreeItem;
/** Translation context. */
i18n: I18n;
};
const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps> = ({
item,
i18n,
}) => {
const dispatch = useDispatch();
const environment = useTreeEnvironment();
const fileName = environment.getItemTitle(item);
return (
<ButtonGroup
aria-hidden={true}
className="pb-explorer-file-action-button-group"
minimal={true}
>
<ActionButton
icon="edit"
tooltip={i18n.translate(I18nId.TreeItemRenameTooltip, { fileName })}
focusable={false}
onClick={() => dispatch(explorerRenameFile(fileName))}
/>
<ActionButton
// NB: the "import" icon has an arrow pointing down, which is
// what we want here since import is analogous to download
// and it also matches the direction of the arrow on the
// archive icon which is also used to indicate an export/
// download operation
icon="import"
tooltip={i18n.translate(I18nId.TreeItemExportTooltip, { fileName })}
focusable={false}
onClick={() => dispatch(fileStorageExportFile(fileName))}
/>
<ActionButton
icon="trash"
tooltip={i18n.translate(I18nId.TreeItemDeleteTooltip, { fileName })}
focusable={false}
onClick={() => dispatch(explorerDeleteFile(fileName))}
/>
</ButtonGroup>
);
};
type HeaderProps = {
/** Translation context. */
i18n: I18n;
};
const Header: React.VoidFunctionComponent<HeaderProps> = ({ i18n }) => {
const [isNewFileWizardOpen, setIsNewFileWizardOpen] = useState(false);
const dispatch = useDispatch();
const fileNames = useSelector((s) => s.fileStorage.fileNames);
return (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<ButtonGroup minimal={true}>
<ActionButton
icon="archive"
tooltip={i18n.translate(I18nId.HeaderExportAllTooltip)}
disabled={fileNames.length === 0}
onClick={() => dispatch(fileStorageArchiveAllFiles())}
/>
<ActionButton
// NB: the "export" icon has an arrow pointing up, which is
// what we want here since import is analogous to upload
// even though this is the "import" action
icon="export"
tooltip={i18n.translate(I18nId.HeaderImportTooltip)}
onClick={() => dispatch(explorerImportFiles())}
/>
<ActionButton
icon="plus"
tooltip={i18n.translate(I18nId.HeaderAddNewTooltip)}
onClick={() => setIsNewFileWizardOpen(true)}
/>
<NewFileWizard
isOpen={isNewFileWizardOpen}
onClose={() => setIsNewFileWizardOpen(false)}
/>
</ButtonGroup>
</div>
);
};
/**
* Accessibility live descriptors.
* @param i18n Translation context.
*/
function useLiveDescriptors(i18n: I18n): LiveDescriptors {
return useMemo(
() => ({
introduction: `
<p>${i18n.translate(I18nId.TreeLiveDescriptorIntroAccessibilityGuide, {
treeLabel: '{treeLabel}',
})}</p>
<p>${i18n.translate(I18nId.TreeLiveDescriptorIntroNavigation)}</p>
<ul>
<li>${i18n.translate(
I18nId.TreeLiveDescriptorIntroKeybindingsPrimaryAction,
{ key: '{keybinding:primaryAction}' },
)}</li>
<li>${i18n.translate(
I18nId.TreeLiveDescriptorIntroKeybindingsRename,
{ key: 'f2' },
)}</li>
<li>${i18n.translate(
I18nId.TreeLiveDescriptorIntroKeybindingsExport,
{ key: `${isMacOS() ? 'cmd' : 'ctrl'}+e` },
)}</li>
<li>${i18n.translate(
I18nId.TreeLiveDescriptorIntroKeybindingsDelete,
{ key: 'delete' },
)}</li>
</ul>
`,
renamingItem: 'not used',
searching: `<p>${i18n.translate(I18nId.TreeLiveDescriptorSearching)}</p>`,
programmaticallyDragging: 'not used',
programmaticallyDraggingTarget: 'not used',
}),
[i18n],
);
}
/**
* Adds additional key bindings to {@link renderers.renderTreeContainer}.
*
* REVISIT: maybe there will be a better way to do this some day:
* https://github.com/lukasbach/react-complex-tree/issues/47
*/
const renderTreeContainer: typeof renderers.renderTreeContainer = (props) => {
const dispatch = useDispatch();
const { treeId } = useTree();
const environment = useTreeEnvironment();
const focusedItem = environment.viewState[treeId]?.focusedItem;
const isActiveTree = environment.activeTreeId === treeId;
const hotKeyActive =
isActiveTree; /* && !dnd.isProgrammaticallyDragging && !isRenaming */
const handleRenameKeyDown = useCallback(() => {
if (focusedItem !== undefined) {
const fileName = environment.getItemTitle(environment.items[focusedItem]);
dispatch(explorerRenameFile(fileName));
}
}, [environment]);
const handleDeleteKeyDown = useCallback(() => {
if (focusedItem !== undefined) {
const fileName = environment.getItemTitle(environment.items[focusedItem]);
dispatch(explorerDeleteFile(fileName));
}
}, [environment]);
const handleExportKeyDown = useCallback(() => {
if (focusedItem !== undefined) {
const fileName = environment.getItemTitle(environment.items[focusedItem]);
dispatch(fileStorageExportFile(fileName));
}
}, [environment]);
const hotkeys = useMemo<readonly HotkeyConfig[]>(
() => [
{
combo: 'f2',
label: 'Rename',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleRenameKeyDown,
},
{
combo: 'del',
label: 'Delete',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleDeleteKeyDown,
},
{
combo: 'mod+e',
label: 'Export',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleExportKeyDown,
},
],
[hotKeyActive, handleDeleteKeyDown],
);
const { handleKeyDown } = useHotkeys(hotkeys);
return <div onKeyDown={handleKeyDown}>{renderers.renderTreeContainer(props)}</div>;
};
type FileTreeProps = {
/** Translation context. */
i18n: I18n;
};
const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
const [focusedItem, setFocusedItem] = useState<TreeItemIndex>();
const fileNames = useSelector((s) => s.fileStorage.fileNames);
const debouncedFileNames = useDebounce(fileNames);
const liveDescriptors = useLiveDescriptors(i18n);
const rootItemIndex = '/';
const treeItems = useMemo(
() =>
debouncedFileNames.reduce(
(obj, fileName) => {
const index = `/${fileName}`;
obj[index] = {
index,
data: {
fileName,
icon: 'document',
secondaryLabel: (
<TreeItemContext.Consumer>
{(item) => (
<FileActionButtonGroup
item={item}
i18n={i18n}
/>
)}
</TreeItemContext.Consumer>
),
},
};
return obj;
},
{
[rootItemIndex]: {
index: rootItemIndex,
data: { fileName: '/' },
hasChildren: true,
children: debouncedFileNames.map((n) => `/${n}`),
},
} as Record<TreeItemIndex, FileTreeItem>,
),
[debouncedFileNames],
);
const getItemTitle = useCallback((item: FileTreeItem) => item.data.fileName, []);
const treeId = 'pb-explorer-file-tree';
const viewState = useMemo(
() => ({ [treeId]: { focusedItem } }),
[treeId, focusedItem],
);
return (
<ControlledTreeEnvironment<FileTreeItemData>
{...renderers}
renderTreeContainer={renderTreeContainer}
items={treeItems}
getItemTitle={getItemTitle}
viewState={viewState}
liveDescriptors={liveDescriptors}
canRename={false} // we implement our own rename handler
onFocusItem={(item) => setFocusedItem(item.index)}
>
<div className="pb-explorer-file-tree">
<Tree
treeId={treeId}
rootItem={rootItemIndex}
treeLabel={i18n.translate(I18nId.TreeLabel)}
/>
</div>
</ControlledTreeEnvironment>
);
};
const Explorer: React.VFC = () => {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useI18n();
return (
<div className="h-100" onContextMenu={preventBrowserNativeContextMenu}>
<Header i18n={i18n} />
<Divider />
<FileTree i18n={i18n} />
<RenameFileDialog />
</div>
);
};
export default Explorer;