mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-14 18:46:17 +00:00
explorer: add basic file explorer
This is the beginnings of a file explorer. Most actions are not yet implemented.
This commit is contained in:
+26
-15
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}
|
||||
Reference in New Issue
Block a user