explorer: add basic file explorer

This is the beginnings of a file explorer. Most actions are not yet
implemented.
This commit is contained in:
David Lechner
2022-03-09 20:54:04 -06:00
parent 6afee4b6d6
commit 03d12feb89
6 changed files with 307 additions and 15 deletions
+26 -15
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
// Copyright (c) 2020-2022 The Pybricks Authors
import { Classes } from '@blueprintjs/core';
import React, { useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import SplitterLayout from 'react-splitter-layout';
import Editor, { EditorContext, EditorContextType, EditorType } from '../editor/Editor';
import Explorer from '../explorer/Explorer';
import { useSelector } from '../reducers';
import { toggleBoolean } from '../settings/actions';
import { BooleanSettingId } from '../settings/defaults';
@@ -176,21 +177,31 @@ const App: React.VoidFunctionComponent<AppProps> = (props) => {
localStorage.setItem('app-docs-split', String(value))
}
>
<SplitterLayout
vertical={true}
percentage={true}
secondaryInitialSize={Number(
localStorage.getItem('app-terminal-split') || 30,
)}
onSecondaryPaneSizeChange={(value): void =>
localStorage.setItem('app-terminal-split', String(value))
}
>
<Editor />
<div className="pb-app-terminal-padding h-100">
<Terminal />
<div className="h-100 w-100" style={{ display: 'flex' }}>
<div style={{ display: 'inline-block', width: 250 }}>
<Explorer />
</div>
</SplitterLayout>
<div style={{ display: 'inline-block' }}>
<SplitterLayout
vertical={true}
percentage={true}
secondaryInitialSize={Number(
localStorage.getItem('app-terminal-split') || 30,
)}
onSecondaryPaneSizeChange={(value): void =>
localStorage.setItem(
'app-terminal-split',
String(value),
)
}
>
<Editor />
<div className="pb-app-terminal-padding h-100">
<Terminal />
</div>
</SplitterLayout>
</div>
</div>
<div className="h-100 w-100">
{isDragging && <div className="h-100 w-100 p-absolute" />}
<Docs />
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender } from '../../test';
import Explorer from './Explorer';
describe('archive button', () => {
it('should be enabled if there are files', () => {
const explorer = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
expect(explorer.getByTitle('Backup all files')).toBeEnabled();
});
it('should be disabled if there are no files', () => {
const explorer = testRender(<Explorer />, {
fileStorage: { fileNames: [] },
});
expect(explorer.getByTitle('Backup all files')).toBeDisabled();
});
});
describe('list item', () => {
it('should show/hide buttons on hover', () => {
const explorer = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
const button = explorer.getByTitle('Rename test.file');
// by default, the buttons are hidden
expect(button).not.toBeVisible();
// but are visible when hovered
userEvent.hover(button);
expect(button).toBeVisible();
// and hide again when unhovered
userEvent.unhover(button);
expect(button).not.toBeVisible();
});
it('should not focus buttons on click', () => {
const explorer = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
const button = explorer.getByTitle('Rename test.file');
userEvent.click(button);
expect(button).not.toHaveFocus();
});
});
+185
View File
@@ -0,0 +1,185 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
// A file explorer control.
import {
Button,
ButtonGroup,
Divider,
IconName,
Tree,
TreeNodeInfo,
} from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { forwardRef, useImperativeHandle, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import {
fileStorageArchiveAllFiles,
fileStorageExportFile,
} from '../fileStorage/actions';
import { useSelector } from '../reducers';
import { ExplorerStringId } from './i18n';
import en from './i18n.en.json';
type ActionButtonProps = {
/** The icon to use for the button. */
icon: IconName;
/** The tooltip translation ID for the tooltip text. */
toolTipId: ExplorerStringId;
/** Replacements if required by `toolTipId` */
toolTipReplacements?: { [key: string]: string };
/** If provided, controls button disabled state. */
disabled?: boolean;
/** Callback for button click event. */
onClick: () => void;
};
const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = (props) => {
const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en });
return (
<Button
icon={props.icon}
title={i18n.translate(props.toolTipId, props.toolTipReplacements)}
disabled={props.disabled}
// prevent focus on click
onMouseDown={(e) => e.preventDefault()}
onClick={() => props.onClick()}
/>
);
};
type FileActionButtonGroupRef = {
/** Sets button group internal visible state. */
setVisible: (visible: boolean) => void;
};
type ActionButtonGroupProps = {
/** The name of the file (displayed to user) */
fileName: string;
};
const FileActionButtonGroup = forwardRef<
FileActionButtonGroupRef,
ActionButtonGroupProps
>((props, ref) => {
const dispatch = useDispatch();
const [visible, setVisible] = useState(false);
useImperativeHandle(ref, () => ({ setVisible }));
return (
<ButtonGroup minimal={true} style={visible ? {} : { display: 'none' }}>
<ActionButton
icon="edit"
toolTipId={ExplorerStringId.TreeItemRenameTooltip}
toolTipReplacements={{ fileName: props.fileName }}
onClick={() => alert('not implemented')}
/>
<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"
toolTipId={ExplorerStringId.TreeItemExportTooltip}
toolTipReplacements={{ fileName: props.fileName }}
onClick={() => dispatch(fileStorageExportFile(props.fileName))}
/>
<ActionButton
icon="trash"
toolTipId={ExplorerStringId.TreeItemDeleteTooltip}
toolTipReplacements={{ fileName: props.fileName }}
onClick={() => alert('not implemented')}
/>
</ButtonGroup>
);
});
FileActionButtonGroup.displayName = 'FileActionButtonGroup';
const Header: React.VFC = () => {
const dispatch = useDispatch();
const fileNames = useSelector((s) => s.fileStorage.fileNames);
return (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<ButtonGroup minimal={true}>
<ActionButton
icon="archive"
toolTipId={ExplorerStringId.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"
toolTipId={ExplorerStringId.HeaderImportTooltip}
onClick={() => alert('not implemented')}
/>
<ActionButton
icon="plus"
toolTipId={ExplorerStringId.HeaderAddNewTooltip}
onClick={() => alert('not implemented')}
/>
</ButtonGroup>
</div>
);
};
const FileTree: React.VFC = () => {
const fileNames = useSelector((s) => s.fileStorage.fileNames);
const treeContents = useMemo(
() =>
[...fileNames].map<
TreeNodeInfo<{
actionButtonGroupRef: React.RefObject<FileActionButtonGroupRef>;
}>
>((item, i) => {
const actionButtonGroupRef =
React.createRef<FileActionButtonGroupRef>();
return {
id: i,
label: item,
secondaryLabel: (
<FileActionButtonGroup
fileName={item}
ref={actionButtonGroupRef}
/>
),
nodeData: { actionButtonGroupRef },
};
}),
[fileNames],
);
return (
<Tree
contents={treeContents}
onNodeMouseEnter={(info) =>
info.nodeData?.actionButtonGroupRef.current?.setVisible(true)
}
onNodeMouseLeave={(info) =>
info.nodeData?.actionButtonGroupRef.current?.setVisible(false)
}
/>
);
};
const Explorer: React.VFC = () => {
return (
<div className="h-100" onContextMenu={(e) => e.preventDefault()}>
<Header />
<Divider />
<FileTree />
</div>
);
};
export default Explorer;
+14
View File
@@ -0,0 +1,14 @@
{
"explorer": {
"header": {
"exportAllTooltip": "Backup all files",
"importTooltip": "Import a file",
"addNewTooltip": "Create a new file"
},
"treeItem": {
"deleteTooltip": "Delete {fileName}",
"exportTooltip": "Export {fileName}",
"renameTooltip": "Rename {fileName}"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { lookup } from '../../test';
import { ExplorerStringId } from './i18n';
import en from './i18n.en.json';
describe('Ensure .json file has matches for ExplorerStringId', () => {
test.each(Object.values(ExplorerStringId))('%s', (id) => {
expect(lookup(en, id)).toBeDefined();
});
});
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
//
// Explorer translation keys.
export enum ExplorerStringId {
HeaderExportAllTooltip = 'explorer.header.exportAllTooltip',
HeaderImportTooltip = 'explorer.header.importTooltip',
HeaderAddNewTooltip = 'explorer.header.addNewTooltip',
TreeItemDeleteTooltip = 'explorer.treeItem.deleteTooltip',
TreeItemExportTooltip = 'explorer.treeItem.exportTooltip',
TreeItemRenameTooltip = 'explorer.treeItem.renameTooltip',
}