diff --git a/packages/code-studio/src/dashboard/panels/FileExplorerPanel.tsx b/packages/code-studio/src/dashboard/panels/FileExplorerPanel.tsx index 631cab3e07..815f836e59 100644 --- a/packages/code-studio/src/dashboard/panels/FileExplorerPanel.tsx +++ b/packages/code-studio/src/dashboard/panels/FileExplorerPanel.tsx @@ -2,10 +2,9 @@ import Log from '@deephaven/log'; import { getFileStorage } from '@deephaven/redux'; import FileExplorer, { FileExplorerToolbar, - FileListItem, FileStorage, + FileStorageItem, NewItemModal, - UpdateableComponent, } from '@deephaven/file-explorer'; import GoldenLayout from 'golden-layout'; import React, { ReactNode } from 'react'; @@ -65,7 +64,6 @@ export class FileExplorerPanel extends React.Component< ); this.handleDelete = this.handleDelete.bind(this); this.handleRename = this.handleRename.bind(this); - this.handleResize = this.handleResize.bind(this); this.handleSessionOpened = this.handleSessionOpened.bind(this); this.handleSessionClosed = this.handleSessionClosed.bind(this); this.handleShow = this.handleShow.bind(this); @@ -85,8 +83,6 @@ export class FileExplorerPanel extends React.Component< } } - private fileExplorer = React.createRef(); - handleCreateFile(): void { const { glEventHub } = this.props; const { session, language } = this.state; @@ -121,7 +117,7 @@ export class FileExplorerPanel extends React.Component< fileStorage.createDirectory(path).catch(FileExplorerPanel.handleError); } - handleDelete(files: FileListItem[]): void { + handleDelete(files: FileStorageItem[]): void { const { glEventHub } = this.props; files.forEach(file => { glEventHub.emit(NotebookEvent.CLOSE_FILE, { @@ -131,7 +127,7 @@ export class FileExplorerPanel extends React.Component< }); } - handleFileSelect(file: FileListItem): void { + handleFileSelect(file: FileStorageItem): void { log.debug('fileSelect', file); if (file.type === 'directory') { return; @@ -159,10 +155,6 @@ export class FileExplorerPanel extends React.Component< glEventHub.emit(NotebookEvent.RENAME_FILE, oldName, newName); } - handleResize(): void { - this.fileExplorer.current?.updateDimensions(); - } - handleSessionOpened( session: DhSession, { language }: { language: string } @@ -182,7 +174,6 @@ export class FileExplorerPanel extends React.Component< handleShow(): void { this.setState({ isShown: true }); - this.fileExplorer.current?.updateDimensions(); } isHidden(): boolean { @@ -202,7 +193,6 @@ export class FileExplorerPanel extends React.Component< glEventHub={glEventHub} onSessionOpen={this.handleSessionOpened} onSessionClose={this.handleSessionClosed} - onResize={this.handleResize} onShow={this.handleShow} > { this.notebook = notebook; }} diff --git a/packages/code-studio/src/styleguide/Grids.jsx b/packages/code-studio/src/styleguide/Grids.jsx index 2a4e93148b..237549cafe 100644 --- a/packages/code-studio/src/styleguide/Grids.jsx +++ b/packages/code-studio/src/styleguide/Grids.jsx @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react'; import { Grid, MockGridModel, MockTreeGridModel } from '@deephaven/grid'; -import { IrisGrid } from '@deephaven/iris-grid/dist/IrisGrid'; +import IrisGrid from '@deephaven/iris-grid/dist/IrisGrid'; import MockIrisGridTreeModel from './MockIrisGridTreeModel'; class Grids extends PureComponent { diff --git a/packages/code-studio/src/styleguide/MockIrisGridTreeModel.js b/packages/code-studio/src/styleguide/MockIrisGridTreeModel.js index ff73b9c4ef..413069e9f8 100644 --- a/packages/code-studio/src/styleguide/MockIrisGridTreeModel.js +++ b/packages/code-studio/src/styleguide/MockIrisGridTreeModel.js @@ -41,6 +41,18 @@ class MockIrisGridTreeModel extends IrisGridModel { return true; } + get pendingRowCount() { + return 0; + } + + set pendingRowCount(count) {} + + get pendingDataMap() { + return new Map(); + } + + set pendingDataMap(value) {} + textForCell(column, row) { return ( this.editedData[column]?.[row] ?? this.model.textForCell(column, row) diff --git a/packages/code-studio/src/styleguide/StyleGuideInit.jsx b/packages/code-studio/src/styleguide/StyleGuideInit.jsx index c2ec5ae49d..70ac2a84a6 100644 --- a/packages/code-studio/src/styleguide/StyleGuideInit.jsx +++ b/packages/code-studio/src/styleguide/StyleGuideInit.jsx @@ -6,7 +6,7 @@ import { setWorkspace as setWorkspaceAction, } from '@deephaven/redux'; import StyleGuide from './StyleGuide'; -import WorkspaceStorage from '../dashboard/WorkspaceStorage'; +import LocalWorkspaceStorage from '../dashboard/LocalWorkspaceStorage'; /** * Initialize data needed for the styleguide @@ -15,7 +15,7 @@ const StyleGuideInit = props => { const { workspace, setWorkspace } = props; useEffect(() => { - setWorkspace(WorkspaceStorage.makeDefaultWorkspace()); + setWorkspace(LocalWorkspaceStorage.makeDefaultWorkspace()); }, [setWorkspace]); return <>{workspace && }; diff --git a/packages/components/src/DraggableItemList.tsx b/packages/components/src/DraggableItemList.tsx index 6e7d380386..c3e4e0f005 100644 --- a/packages/components/src/DraggableItemList.tsx +++ b/packages/components/src/DraggableItemList.tsx @@ -5,7 +5,11 @@ import { Draggable, Droppable, DraggableChildrenFn } from 'react-beautiful-dnd'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { vsGripper } from '@deephaven/icons'; import { RangeUtils, Range } from '@deephaven/utils'; -import ItemList, { RenderItemProps, DefaultListItem } from './ItemList'; +import ItemList, { + RenderItemProps, + DefaultListItem, + ItemListProps, +} from './ItemList'; import { Tooltip } from './popper'; import './DraggableItemList.scss'; @@ -18,33 +22,19 @@ type DraggableRenderItemFn = ( props: DraggableRenderItemProps ) => React.ReactNode; -type DraggableItemListProps = { +type DraggableItemListProps = Omit< + ItemListProps, + 'overscanCount' | 'focusSelector' | 'isDragSelect' +> & { className: string; draggingItemClassName: string; - // Total item count - itemCount: number; - rowHeight: number; - // Offset of the top item in the items array - offset: number; - // Item object format expected by the default renderItem function - // Can be anything as long as it's supported by the renderItem - items: T[]; - // Whether to allow dropping items in this list isDropDisabled: boolean; // Whether to allow dragging items from this list isDragDisabled: boolean; - // Whether to allow multiple selections in this item list - isMultiSelect: boolean; - // Set to true if you want the list to scroll when new items are added and it's already at the bottom - isStickyBottom: boolean; - // Fired when an item is clicked. With multiple selection, fired on double click. - onSelect(index: number): void; - onSelectionChange(ranges: Range[]): void; - onViewportChange(): void; - selectedRanges: Range[]; - disableSelect: boolean; + renderItem: DraggableRenderItemFn; style: React.CSSProperties; + // The prefix to add to all draggable item IDs draggablePrefix: string; // The ID to give the droppable list @@ -74,12 +64,16 @@ class DraggableItemList extends PureComponent< offset: 0, items: [], rowHeight: DraggableItemList.DEFAULT_ROW_HEIGHT, + isDoubleClickSelect: true, isDropDisabled: false, isDragDisabled: false, isMultiSelect: false, isStickyBottom: false, disableSelect: false, style: null, + onFocusChange(): void { + // no-op + }, onSelect(): void { // no-op }, @@ -165,16 +159,16 @@ class DraggableItemList extends PureComponent< itemList: React.RefObject>; + selectItem(itemIndex: number): void { + this.itemList.current?.selectItem(itemIndex); + } + focusItem(itemIndex: number): void { - if (this.itemList.current) { - this.itemList.current.focusItem(itemIndex); - } + this.itemList.current?.focusItem(itemIndex); } scrollToItem(itemIndex: number): void { - if (this.itemList.current) { - this.itemList.current.scrollToItem(itemIndex); - } + this.itemList.current?.scrollToItem(itemIndex); } getCachedDraggableItem = memoize( @@ -183,7 +177,7 @@ class DraggableItemList extends PureComponent< renderItem: DraggableRenderItemFn, item: T, itemIndex: number, - isKeyboardSelected: boolean, + isFocused: boolean, isSelected: boolean, isDragDisabled: boolean, style: React.CSSProperties @@ -211,7 +205,7 @@ class DraggableItemList extends PureComponent< {renderItem({ item, itemIndex, - isKeyboardSelected, + isFocused, isSelected, style, isClone: false, @@ -238,7 +232,7 @@ class DraggableItemList extends PureComponent< ) => ({ item, itemIndex, - isKeyboardSelected, + isFocused, isSelected, style, }: RenderItemProps) => @@ -247,7 +241,7 @@ class DraggableItemList extends PureComponent< renderItem, item, itemIndex, - isKeyboardSelected, + isFocused, isSelected, isDragDisabled, style @@ -287,7 +281,7 @@ class DraggableItemList extends PureComponent< {renderItem({ item, itemIndex, - isKeyboardSelected: false, + isFocused: false, isSelected: true, style: {}, isClone: true, @@ -306,18 +300,20 @@ class DraggableItemList extends PureComponent< draggablePrefix, draggingItemClassName, droppableId, + isDoubleClickSelect, isDragDisabled, isDropDisabled, isMultiSelect, isStickyBottom, itemCount, items, - onViewportChange, offset, + onFocusChange, + onSelect, + onViewportChange, renderItem, rowHeight, selectedRanges, - onSelect, style, } = this.props; return ( @@ -349,11 +345,13 @@ class DraggableItemList extends PureComponent< > {}, - onViewportChange = () => {}, -} = {}) { - return mount( - - ); -} - -it('mounts and unmounts properly', () => { - const itemList = makeItemList(); - itemList.unmount(); -}); - -it('Sends the proper signal when an item is clicked', () => { - const onSelect = jest.fn(); - const itemList = makeItemList({ onSelect }); - - itemList.find('.item-list-item').at(3).simulate('mousedown', {}); - - itemList.find('.item-list-item').at(3).simulate('mouseup', {}); - - expect(onSelect).toHaveBeenCalledWith(3); - - itemList.unmount(); -}); - -it('handles keyboard up and down properly', () => { - const itemList = makeItemList(); - - expect(itemList.state('keyboardIndex')).toBe(null); - - itemList.simulate('keydown', { key: 'ArrowDown' }); - - expect(itemList.state('keyboardIndex')).toBe(0); - - itemList.simulate('keydown', { key: 'ArrowDown' }); - itemList.simulate('keydown', { key: 'ArrowDown' }); - itemList.simulate('keydown', { key: 'ArrowDown' }); - - expect(itemList.state('keyboardIndex')).toBe(3); - - itemList.simulate('keydown', { key: 'ArrowUp' }); - itemList.simulate('keydown', { key: 'ArrowUp' }); - - expect(itemList.state('keyboardIndex')).toBe(1); - - itemList.unmount(); -}); diff --git a/packages/components/src/ItemList.test.tsx b/packages/components/src/ItemList.test.tsx new file mode 100644 index 0000000000..900f26523f --- /dev/null +++ b/packages/components/src/ItemList.test.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import ItemList from './ItemList'; + +function makeItems(count = 20) { + const items = []; + + for (let i = 0; i < count; i += 1) { + items.push({ value: i, displayValue: `${i}` }); + } + + return items; +} + +function makeItemList({ + isDoubleClickSelect = false, + isMultiSelect = false, + itemCount = 100, + rowHeight = 20, + offset = 0, + items = makeItems(), + onSelect = jest.fn(), + onSelectionChange = jest.fn(), + onViewportChange = jest.fn(), +} = {}) { + return mount( + + ); +} + +it('mounts and unmounts properly', () => { + const itemList = makeItemList(); + itemList.unmount(); +}); + +describe('mouse', () => { + function clickItem( + itemList: ReactWrapper, + itemIndex: number, + options = {} + ): void { + itemList + .find('.item-list-item') + .at(itemIndex) + .simulate('mousedown', options); + + itemList.find('.item-list-item').at(itemIndex).simulate('mouseup', options); + } + + function doubleClickItem( + itemList: ReactWrapper, + itemIndex: number, + options = {} + ): void { + itemList + .find('.item-list-item') + .at(itemIndex) + .simulate('dblclick', options); + } + + it('sends onSelect when an item is clicked', () => { + const onSelect = jest.fn(); + const itemList = makeItemList({ onSelect }); + + clickItem(itemList, 3); + + expect(onSelect).toHaveBeenCalledWith(3); + + itemList.unmount(); + }); + + it('sends onSelect only when double clicked if isDoubleClickSelect is true', () => { + const onSelect = jest.fn(); + const itemList = makeItemList({ onSelect, isDoubleClickSelect: true }); + + clickItem(itemList, 3); + + expect(onSelect).not.toHaveBeenCalled(); + + doubleClickItem(itemList, 3); + + expect(onSelect).toHaveBeenCalledWith(3); + + itemList.unmount(); + }); + + it('extends the selection when shift clicked', () => { + const onSelect = jest.fn(); + const onSelectionChange = jest.fn(); + const itemList = makeItemList({ + isMultiSelect: true, + onSelect, + onSelectionChange, + }); + + clickItem(itemList, 3); + + expect(onSelect).toHaveBeenCalledWith(3); + expect(onSelectionChange).toHaveBeenCalledWith([[3, 3]]); + + onSelectionChange.mockClear(); + onSelect.mockClear(); + + clickItem(itemList, 6, { shiftKey: true }); + + expect(onSelect).not.toHaveBeenCalled(); + expect(onSelectionChange).toHaveBeenCalledWith([[3, 6]]); + + itemList.unmount(); + }); + + it('selects multiple items with Ctrl+Click', () => { + const onSelect = jest.fn(); + const onSelectionChange = jest.fn(); + const itemList = makeItemList({ + isMultiSelect: true, + onSelect, + onSelectionChange, + }); + + clickItem(itemList, 3); + + expect(onSelect).toHaveBeenCalledWith(3); + expect(onSelectionChange).toHaveBeenCalledWith([[3, 3]]); + + onSelectionChange.mockClear(); + onSelect.mockClear(); + + clickItem(itemList, 6, { ctrlKey: true }); + + expect(onSelect).not.toHaveBeenCalled(); + expect(onSelectionChange).toHaveBeenCalledWith([ + [3, 3], + [6, 6], + ]); + + itemList.unmount(); + }); +}); + +it('handles keyboard up and down properly', () => { + const itemList = makeItemList(); + + expect(itemList.state('focusIndex')).toBe(null); + + itemList.simulate('keydown', { key: 'ArrowDown' }); + + expect(itemList.state('focusIndex')).toBe(0); + + itemList.simulate('keydown', { key: 'ArrowDown' }); + itemList.simulate('keydown', { key: 'ArrowDown' }); + itemList.simulate('keydown', { key: 'ArrowDown' }); + + expect(itemList.state('focusIndex')).toBe(3); + + itemList.simulate('keydown', { key: 'ArrowUp' }); + itemList.simulate('keydown', { key: 'ArrowUp' }); + + expect(itemList.state('focusIndex')).toBe(1); + + itemList.unmount(); +}); diff --git a/packages/components/src/ItemList.tsx b/packages/components/src/ItemList.tsx index a904544c4f..e6ae34e20b 100644 --- a/packages/components/src/ItemList.tsx +++ b/packages/components/src/ItemList.tsx @@ -22,14 +22,19 @@ export interface DefaultListItem { export type RenderItemProps = { item: T; itemIndex: number; - isKeyboardSelected: boolean; + isFocused: boolean; isSelected: boolean; style: React.CSSProperties; }; -type RenderItemFn = (props: RenderItemProps) => React.ReactNode; +export type RenderItemFn = (props: RenderItemProps) => React.ReactNode; -type ItemListProps = { +export type ItemDragEventHandler = ( + index: number, + event: React.DragEvent +) => void; + +export type ItemListProps = { // Total item count itemCount: number; rowHeight: number; @@ -40,14 +45,17 @@ type ItemListProps = { // Default renderItem will look for a `displayValue` property, fallback // to the `value` property, or stringify the object if neither are defined items: T[]; + // Whether selection requires a double click or not + isDoubleClickSelect: boolean; // Whether to allow dragging to change the selection after clicking isDragSelect: boolean; // Whether to allow multiple selections in this item list isMultiSelect: boolean; // Set to true if you want the list to scroll when new items are added and it's already at the bottom isStickyBottom: boolean; - // Fired when an item gets selected via keyboard - onKeyboardSelect(): void; + // Fired when an item gets focused + onFocusChange(index: number | null): void; + // Fired when an item is clicked. With multiple selection, fired on double click. onSelect(index: number): void; onSelectionChange(ranges: Range[]): void; @@ -60,7 +68,7 @@ type ItemListProps = { }; type ItemListState = { - keyboardIndex: number | null; + focusIndex: number | null; mouseDownIndex: number | null; selectedRanges: Range[]; overscanStartIndex: number; @@ -74,7 +82,10 @@ type ItemListState = { * Show items in a long scrollable list. * Can be navigated via keyboard or mouse. */ -class ItemList extends PureComponent, ItemListState> { +export class ItemList extends PureComponent< + ItemListProps, + ItemListState +> { static CACHE_SIZE = 1000; static DEFAULT_ROW_HEIGHT = 20; @@ -87,6 +98,8 @@ class ItemList extends PureComponent, ItemListState> { items: [], rowHeight: ItemList.DEFAULT_ROW_HEIGHT, + isDoubleClickSelect: false, + isDragSelect: true, isMultiSelect: false, @@ -95,7 +108,7 @@ class ItemList extends PureComponent, ItemListState> { disableSelect: false, - onKeyboardSelect(): void { + onFocusChange(): void { // no-op }, onSelect(): void { @@ -149,7 +162,7 @@ class ItemList extends PureComponent, ItemListState> { const { isStickyBottom, selectedRanges } = props; this.state = { - keyboardIndex: null, + focusIndex: null, mouseDownIndex: null, selectedRanges, overscanStartIndex: 0, @@ -173,6 +186,7 @@ class ItemList extends PureComponent, ItemListState> { ): void { const { selectedRanges: propSelectedRanges } = this.props; const { + focusIndex, isStuckToBottom, scrollOffset, height, @@ -198,6 +212,11 @@ class ItemList extends PureComponent, ItemListState> { const { onSelectionChange } = this.props; onSelectionChange(selectedRanges); } + + if (focusIndex !== prevState.focusIndex) { + const { onFocusChange } = this.props; + onFocusChange(focusIndex); + } } componentWillUnmount(): void { @@ -208,10 +227,6 @@ class ItemList extends PureComponent, ItemListState> { listContainer: React.RefObject; - setKeyboardIndex(keyboardIndex: number | null): void { - this.setState({ keyboardIndex }); - } - getItemSelected = memoize( (index: number, selectedRanges: Range[]) => RangeUtils.isSelected(selectedRanges, index), @@ -223,17 +238,16 @@ class ItemList extends PureComponent, ItemListState> { itemIndex: number, key: number, item: T, - isKeyboardSelected: boolean, + isFocused: boolean, isSelected: boolean, renderItem: RenderItemFn, style: React.CSSProperties, - onKeyboardSelect: (i: number, item: HTMLDivElement) => void, disableSelect: boolean ) => { const content = renderItem({ item, itemIndex, - isKeyboardSelected, + isFocused, isSelected, style, }); @@ -244,11 +258,10 @@ class ItemList extends PureComponent, ItemListState> { onMouseDown={this.handleItemMouseDown} onFocus={this.handleItemFocus} onBlur={this.handleItemBlur} - onKeyboardSelect={onKeyboardSelect} disableSelect={disableSelect} onMouseMove={this.handleItemMouseMove} onMouseUp={this.handleItemMouseUp} - isKeyboardSelected={isKeyboardSelected} + isFocused={isFocused} isSelected={isSelected} itemIndex={itemIndex} style={style} @@ -290,15 +303,16 @@ class ItemList extends PureComponent, ItemListState> { return component; }); - getItemData = memoize((items: T[], selectedRanges: Range[]) => ({ - items, - selectedRanges, - })); + getItemData = memoize( + (items: T[], selectedRanges: Range[], renderItem: RenderItemFn) => ({ + items, + selectedRanges, + renderItem, + }) + ); focus(): void { - if (this.listContainer.current != null) { - this.listContainer.current.focus(); - } + this.listContainer.current?.focus(); } getElement(itemIndex: number): Element | null { @@ -316,6 +330,9 @@ class ItemList extends PureComponent, ItemListState> { focusItem(itemIndex: number): void { const { disableSelect } = this.props; if (disableSelect) return; + + this.setState({ focusIndex: itemIndex }); + const element = this.getElement(itemIndex); if (element instanceof HTMLElement) { element.focus(); @@ -330,9 +347,9 @@ class ItemList extends PureComponent, ItemListState> { } handleItemDoubleClick(itemIndex: number): void { - const { isMultiSelect, onSelect } = this.props; + const { isDoubleClickSelect, onSelect } = this.props; - if (isMultiSelect) { + if (isDoubleClickSelect) { this.setState( ({ selectedRanges }) => ({ selectedRanges: RangeUtils.selectRange(selectedRanges, [ @@ -371,7 +388,7 @@ class ItemList extends PureComponent, ItemListState> { return; } - this.setState({ keyboardIndex: index, mouseDownIndex: index }); + this.setState({ mouseDownIndex: index }); window.addEventListener('mouseup', this.handleWindowMouseUp); @@ -387,16 +404,16 @@ class ItemList extends PureComponent, ItemListState> { !this.listContainer.current.contains(e.relatedTarget)) ) { // Next focused element is outside of the ItemList - this.setKeyboardIndex(null); + this.setState({ focusIndex: null }); } } handleItemFocus(itemIndex: number, e: React.FocusEvent): void { log.debug2('item focus', itemIndex, e.target); this.setState(state => { - const { keyboardIndex } = state; - if (keyboardIndex !== itemIndex) { - return { keyboardIndex: itemIndex }; + const { focusIndex } = state; + if (focusIndex !== itemIndex) { + return { focusIndex: itemIndex }; } return null; }); @@ -408,7 +425,7 @@ class ItemList extends PureComponent, ItemListState> { if (mouseDownIndex == null || disableSelect) return; - this.setState({ keyboardIndex: itemIndex, isDragging: true }); + this.setState({ isDragging: true }); if (isDragSelect || mouseDownIndex === itemIndex) { this.focusItem(itemIndex); @@ -437,7 +454,7 @@ class ItemList extends PureComponent, ItemListState> { } handleItemMouseUp(index: number, e: React.MouseEvent): void { - const { isMultiSelect, onSelect } = this.props; + const { isDoubleClickSelect, onSelect } = this.props; const { mouseDownIndex, isDragging } = this.state; if ( @@ -450,18 +467,17 @@ class ItemList extends PureComponent, ItemListState> { } if (mouseDownIndex === index && !isDragging) { + const isShiftDown = e.shiftKey; + const isModifierDown = ContextActionUtils.isModifierKeyDown(e); this.focusItem(index); - this.toggleSelect( - index, - e.shiftKey, - ContextActionUtils.isModifierKeyDown(e) - ); - if (!isMultiSelect) { + this.toggleSelect(index, isShiftDown, isModifierDown); + + if (!isDoubleClickSelect && !isShiftDown && !isModifierDown) { onSelect(index); } } - this.setState({ mouseDownIndex: null, isDragging: false }); + this.resetMouseState(); } handleItemsRendered({ overscanStartIndex }: ListOnItemsRenderedProps): void { @@ -477,13 +493,13 @@ class ItemList extends PureComponent, ItemListState> { } handleWindowMouseUp(): void { - this.setState({ mouseDownIndex: null, isDragging: false }); + this.resetMouseState(); window.removeEventListener('mouseup', this.handleWindowMouseUp); } handleKeyDown(e: React.KeyboardEvent): void { const { isMultiSelect, itemCount, onSelect } = this.props; - const { keyboardIndex: oldFocus } = this.state; + const { focusIndex: oldFocus } = this.state; let newFocus = oldFocus; if (e.key === 'Enter' || e.key === ' ') { @@ -519,8 +535,6 @@ class ItemList extends PureComponent, ItemListState> { this.focusItem(newFocus); - this.setState({ keyboardIndex: newFocus }); - const { selectedRanges } = this.state; if (e.shiftKey && selectedRanges.length > 0) { const lastRange = selectedRanges[selectedRanges.length - 1]; @@ -561,6 +575,10 @@ class ItemList extends PureComponent, ItemListState> { }); } + resetMouseState(): void { + this.setState({ mouseDownIndex: null, isDragging: false }); + } + scrollToBottom(): void { const { itemCount } = this.props; if (this.list.current) { @@ -685,14 +703,8 @@ class ItemList extends PureComponent, ItemListState> { index: number; style: React.CSSProperties; }): React.ReactElement | null { - const { - items, - offset, - renderItem, - onKeyboardSelect, - disableSelect, - } = this.props; - const { keyboardIndex, selectedRanges } = this.state; + const { items, offset, renderItem, disableSelect } = this.props; + const { focusIndex, selectedRanges } = this.state; if (itemIndex < offset || itemIndex >= offset + items.length) { return null; } @@ -702,17 +714,22 @@ class ItemList extends PureComponent, ItemListState> { itemIndex, itemIndex, item, - itemIndex === keyboardIndex && !disableSelect, + itemIndex === focusIndex && !disableSelect, this.getItemSelected(itemIndex, selectedRanges), renderItem, style, - onKeyboardSelect, disableSelect ); } render(): JSX.Element { - const { items, itemCount, overscanCount, rowHeight } = this.props; + const { + items, + itemCount, + overscanCount, + renderItem, + rowHeight, + } = this.props; const { selectedRanges } = this.state; return ( @@ -725,8 +742,8 @@ class ItemList extends PureComponent, ItemListState> { itemSize={rowHeight} // This prop isn't actually used by us, it is passed to the render function by react-window // Used here to force a re-render of the List component. - // Otherwise it doesn't know to call the render again when selection changes - itemData={this.getItemData(items, selectedRanges)} + // Otherwise it doesn't know to call the render again when selection or renderItem changes + itemData={this.getItemData(items, selectedRanges, renderItem)} onScroll={this.handleScroll} onItemsRendered={this.handleItemsRendered} ref={this.list} diff --git a/packages/components/src/ItemListItem.scss b/packages/components/src/ItemListItem.scss index f851632fac..7db7b6878c 100644 --- a/packages/components/src/ItemListItem.scss +++ b/packages/components/src/ItemListItem.scss @@ -36,7 +36,7 @@ $item-list-selected-color: $white; color: $item-list-selected-color; } -.item-list-item.keyboard-active { +.item-list-item.is-focused { .item-list-item-content { background-color: $item-list-keyboard-selected-bg; color: $item-list-selected-color; diff --git a/packages/components/src/ItemListItem.tsx b/packages/components/src/ItemListItem.tsx index 2a39b7123f..c3d85a131c 100644 --- a/packages/components/src/ItemListItem.tsx +++ b/packages/components/src/ItemListItem.tsx @@ -7,7 +7,7 @@ const log = Log.module('ItemListItem'); interface ItemListItemProps { isDraggable: boolean; - isKeyboardSelected: boolean; + isFocused: boolean; isSelected: boolean; itemIndex: number; disableSelect: boolean; @@ -20,7 +20,6 @@ interface ItemListItemProps { onDrop(index: number, e: React.DragEvent): void; onDoubleClick(index: number, e: React.MouseEvent): void; onFocus(index: number, e: React.FocusEvent): void; - onKeyboardSelect(index: number, item: HTMLDivElement): void; onMouseDown(index: number, e: React.MouseEvent): void; onMouseMove(index: number, e: React.MouseEvent): void; onMouseUp(index: number, e: React.MouseEvent): void; @@ -32,7 +31,7 @@ class ItemListItem extends Component> { static defaultProps = { children: null, isDraggable: false, - isKeyboardSelected: false, + isFocused: false, isSelected: false, itemIndex: 0, disableSelect: false, @@ -64,9 +63,6 @@ class ItemListItem extends Component> { onFocus(): void { // no-op }, - onKeyboardSelect(): void { - // no-op - }, onMouseDown(): void { // no-op }, @@ -103,32 +99,6 @@ class ItemListItem extends Component> { this.itemRef = React.createRef(); } - componentDidMount(): void { - const { isKeyboardSelected, itemIndex, onKeyboardSelect } = this.props; - if (isKeyboardSelected && this.itemRef.current) { - onKeyboardSelect(itemIndex, this.itemRef.current); - } - } - - componentDidUpdate(prevProps: ItemListItemProps): void { - const { isKeyboardSelected: oldIsKeyboardSelected } = prevProps; - const { - isKeyboardSelected, - itemIndex, - onKeyboardSelect, - disableSelect, - } = this.props; - - if ( - isKeyboardSelected && - !oldIsKeyboardSelected && - this.itemRef.current && - !disableSelect - ) { - onKeyboardSelect(itemIndex, this.itemRef.current); - } - } - itemRef: React.RefObject; handleBlur(e: React.FocusEvent): void { @@ -194,19 +164,13 @@ class ItemListItem extends Component> { } render(): JSX.Element { - const { - isDraggable, - isKeyboardSelected, - isSelected, - style, - children, - } = this.props; + const { isDraggable, isFocused, isSelected, style, children } = this.props; return (
= { - item: T; - itemIndex: number; - isKeyboardSelected: boolean; - isSelected: boolean; - isDragInProgress: boolean; - isDragged: boolean; - isDropTargetValid: boolean; - dragOverItem: T; -}; - -type SingleClickRenterItemFn = ( - props: SingleClickRenderItemProps -) => React.ReactNode; - -type SingleClickItemListProps = { - // Total item count - itemCount: number; - rowHeight: number; - - // Offset of the top item in the items array - offset: number; - // Item object format expected by the default renderItem function - // Can be anything as long as it's supported by the renderItem - items: T[]; - - isDraggable: boolean; - - // Whether to allow multiple selections in this item list - isMultiSelect: boolean; - - // Set to true if you want the list to scroll when new items are added and it's already at the bottom - isStickyBottom: boolean; - - onDrop(ranges: Range[], index: number): void; - - // Fired when an item gets selected via keyboard - onKeyboardSelect(): void; - - // Fired when an item is clicked. With multiple selection, fired on double click. - onSelect(index: number): void; - onSelectionChange( - selectedRanges: Range[], - keyboardIndex: number | null - ): void; - onViewportChange(topRow: number, bottomRow: number): void; - - disableSelect: boolean; - - renderItem: SingleClickRenterItemFn; - validateDropTarget(draggedRanges: Range[], targetIndex: number): boolean; -}; - -type SingleClickItemListState = { - keyboardIndex: number | null; - selectedRanges: Range[]; - draggedRanges: Range[]; - dragOverIndex: number | null; - shiftRange: Range | null; - topRow: number | null; - bottomRow: number | null; - isStuckToBottom: boolean; - isDropTargetValid: boolean; -}; - -const log = Log.module('SingleClickItemList'); - -/** - * Show items in a long scrollable list. - * Can be navigated via keyboard or mouse. - */ -export class SingleClickItemList< - T extends SingleClickRenderItemBase -> extends PureComponent, SingleClickItemListState> { - static CACHE_SIZE = 1000; - - static DEFAULT_ROW_HEIGHT = 20; - - static DRAG_PLACEHOLDER_OFFSET_X = 20; - - static defaultProps = { - items: [], - rowHeight: SingleClickItemList.DEFAULT_ROW_HEIGHT, - isDraggable: false, - isMultiSelect: false, - isStickyBottom: false, - disableSelect: false, - onKeyboardSelect(): void { - // no-op - }, - onSelect(): void { - // no-op - }, - onSelectionChange(): void { - // no-op - }, - onDrop(): void { - // no-op - }, - renderItem: SingleClickItemList.renderItem, - validateDropTarget: undefined, - }; - - static renderItem

({ - item, - }: SingleClickRenderItemProps

): React.ReactNode { - return item.itemName; - } - - constructor(props: SingleClickItemListProps) { - super(props); - - this.handleItemBlur = this.handleItemBlur.bind(this); - this.handleItemFocus = this.handleItemFocus.bind(this); - this.handleItemClick = this.handleItemClick.bind(this); - this.handleItemDragStart = this.handleItemDragStart.bind(this); - this.handleItemDragOver = this.handleItemDragOver.bind(this); - this.handleItemDragEnd = this.handleItemDragEnd.bind(this); - this.handleItemDrop = this.handleItemDrop.bind(this); - this.handleItemMouseDown = this.handleItemMouseDown.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleScroll = this.handleScroll.bind(this); - - this.list = React.createRef(); - this.dragPlaceholder = null; - - const { isStickyBottom } = props; - - this.state = { - keyboardIndex: null, - selectedRanges: [], - draggedRanges: [], - dragOverIndex: null, - shiftRange: null, - topRow: null, - bottomRow: null, - isStuckToBottom: isStickyBottom, - isDropTargetValid: false, - }; - } - - componentDidMount(): void { - const { isStickyBottom } = this.props; - if ( - isStickyBottom && - this.list.current && - this.list.current.scrollHeight > this.list.current.clientHeight - ) { - this.scrollToBottom(); - } else { - this.updateViewport(); - } - } - - componentDidUpdate( - prevProps: SingleClickItemListProps, - prevState: SingleClickItemListState - ): void { - const { - isStuckToBottom, - keyboardIndex, - topRow, - bottomRow, - selectedRanges, - } = this.state; - - if (isStuckToBottom && !this.isListAtBottom()) { - this.scrollToBottom(); - } - - if (topRow !== prevState.topRow || bottomRow !== prevState.bottomRow) { - this.sendViewportUpdate(); - } - - if ( - selectedRanges !== prevState.selectedRanges || - keyboardIndex !== prevState.keyboardIndex - ) { - const { onSelectionChange } = this.props; - onSelectionChange(selectedRanges, keyboardIndex); - } - - this.updateViewport(); - } - - list: React.RefObject; - - dragPlaceholder: HTMLDivElement | null; - - setKeyboardIndex(keyboardIndex: number | null): void { - this.setState({ keyboardIndex }); - } - - setShiftRange(shiftRange: Range | null): void { - this.setState({ shiftRange }); - } - - getItemSelected = memoize( - (index: number, selectedRanges: Range[]): boolean => - RangeUtils.isSelected(selectedRanges, index), - { max: SingleClickItemList.CACHE_SIZE } - ); - - getCachedItem = memoize( - ( - itemIndex, - key, - item, - rowHeight, - isKeyboardSelected, - isSelected, - isDragInProgress, - isDragged, - isDropTargetValid, - dragOverItem, - renderItem - ) => { - const style = { height: rowHeight }; - const { isDraggable, onKeyboardSelect, disableSelect } = this.props; - const content = renderItem({ - item, - itemIndex, - isKeyboardSelected, - isSelected, - isDragInProgress, - isDragged, - isDropTargetValid, - dragOverItem, - }); - - return ( - - {content} - - ); - }, - { max: SingleClickItemList.CACHE_SIZE } - ); - - getCachedItems = memoize( - ( - items, - rowHeight, - offset, - keyboardIndex, - selectedRanges, - draggedRanges, - dragOverIndex, - isDropTargetValid, - renderItem - ) => { - const itemElements = []; - const dragOverItem = dragOverIndex != null ? items[dragOverIndex] : null; - const isDragInProgress = draggedRanges.length > 0; - for (let i = 0; i < items.length; i += 1) { - const item = items[i]; - const itemIndex = i + offset; - const key = itemIndex; - const isKeyboardSelected = itemIndex === keyboardIndex; - const isSelected = this.getItemSelected(itemIndex, selectedRanges); - const isDragged = this.getItemSelected(itemIndex, draggedRanges); - const element = this.getCachedItem( - itemIndex, - key, - item, - rowHeight, - isKeyboardSelected, - isSelected, - isDragInProgress, - isDragged, - isDropTargetValid, - dragOverItem, - renderItem - ); - itemElements.push(element); - } - return itemElements; - }, - { max: 1 } - ); - - getDragPlaceholder(draggedRanges: Range[]): string | null { - const count = draggedRanges.reduce( - (acc, next) => acc + next[1] - next[0] + 1, - 0 - ); - if (count === 0) { - return null; - } - if (count === 1) { - const index = draggedRanges[0][0]; - const { items } = this.props; - return items[index].itemName; - } - return `${count} items`; - } - - isDropTargetValid = memoize((validateDropTarget, draggedRanges, index) => - typeof validateDropTarget === 'function' - ? validateDropTarget(draggedRanges, index) - : false - ); - - focus(): void { - this.list.current?.focus(); - } - - handleItemClick(index: number, e: React.MouseEvent): void { - // This happens after handleItemMouseDown, so shouldn't contain overlapping functionality - const { isMultiSelect, onSelect } = this.props; - const { selectedRanges, keyboardIndex: oldFocus, shiftRange } = this.state; - - log.debug( - 'handleItemClick', - index, - oldFocus, - isMultiSelect, - e.shiftKey, - ContextActionUtils.isModifierKeyDown(e) - ); - - if (isMultiSelect && e.shiftKey) { - const range: Range = [ - Math.min(oldFocus ?? index, index), - Math.max(oldFocus ?? index, index), - ]; - if (shiftRange != null) { - this.deselectRange(shiftRange); - } - this.setShiftRange(range); - this.selectRange(range); - } else if (isMultiSelect && ContextActionUtils.isModifierKeyDown(e)) { - this.setShiftRange(null); - this.setKeyboardIndex(index); - if (this.getItemSelected(index, selectedRanges)) { - this.deselectItem(index); - } else { - this.selectItem(index); - } - } else { - this.deselectAll(); - this.setShiftRange(null); - this.setKeyboardIndex(index); - this.selectItem(index); - onSelect(index); - } - } - - handleItemDragStart(index: number, e: React.DragEvent): void { - const { selectedRanges } = this.state; - log.debug('handleItemDragStart', index, selectedRanges); - let draggedRanges: Range[]; - if (this.getItemSelected(index, selectedRanges)) { - // Dragging selected ranges - draggedRanges = [...selectedRanges]; - } else { - draggedRanges = [[index, index]]; - } - this.setState({ - draggedRanges, - }); - - const dragPlaceholder = document.createElement('div'); - dragPlaceholder.innerHTML = `

${this.getDragPlaceholder( - draggedRanges - )}
`; - dragPlaceholder.className = 'single-click-item-list-dnd-placeholder'; - document.body.appendChild(dragPlaceholder); - e.dataTransfer.setDragImage(dragPlaceholder, 0, 0); - this.dragPlaceholder = dragPlaceholder; - } - - handleItemDragOver(index: number): void { - this.setState(({ dragOverIndex, draggedRanges }) => { - if (index === dragOverIndex) { - return null; - } - const { validateDropTarget } = this.props; - const isDropTargetValid = this.isDropTargetValid( - validateDropTarget, - draggedRanges, - index - ); - log.debug('handleItemDragOver', index); - return { - dragOverIndex: index, - isDropTargetValid, - }; - }); - } - - handleItemDragEnd(index: number): void { - if (this.dragPlaceholder) { - document.body.removeChild(this.dragPlaceholder); - } - log.debug('handleItemDragEnd', index); - // Drag end is triggered after drop - // Also drop isn't triggered if drag end is outside of the list - this.setState({ - draggedRanges: [], - dragOverIndex: null, - }); - } - - handleItemDrop(index: number): void { - const { draggedRanges } = this.state; - log.debug('handleItemDrop', index, draggedRanges); - const { onDrop } = this.props; - onDrop(draggedRanges, index); - } - - handleItemMouseDown( - index: number, - e: React.MouseEvent - ): void { - const { selectedRanges } = this.state; - log.debug('handleItemMouseDown', index, e.button); - if (e.button === 2) { - this.setKeyboardIndex(index); - this.setShiftRange(null); - if (!this.getItemSelected(index, selectedRanges)) { - this.deselectAll(); - this.selectItem(index); - } - } - } - - handleItemBlur( - itemIndex: number, - { currentTarget, relatedTarget }: React.FocusEvent - ): void { - log.debug2('item blur', itemIndex, currentTarget, relatedTarget); - if ( - !relatedTarget || - (relatedTarget instanceof Element && - !this.list.current?.contains(relatedTarget) && - !relatedTarget.classList?.contains('context-menu-container')) - ) { - // Next focused element is outside of the SingleClickItemList - this.setKeyboardIndex(null); - } - } - - handleItemFocus( - itemIndex: number, - e: React.FocusEvent - ): void { - log.debug2('item focus', itemIndex, e.target); - this.setState(state => { - const { keyboardIndex } = state; - if (keyboardIndex !== itemIndex) { - return { keyboardIndex: itemIndex }; - } - return null; - }); - } - - handleKeyDown(e: React.KeyboardEvent): void { - const { itemCount, isMultiSelect, onSelect } = this.props; - const { keyboardIndex: oldFocus } = this.state; - let newFocus = oldFocus; - - log.debug('handleKeyDown', e.key); - - if (e.key === ' ') { - this.deselectAll(); - this.setShiftRange(null); - onSelect(oldFocus ?? 0); - return; - } - - if (e.key === 'Escape') { - this.resetSelection(); - return; - } - - if (e.key === 'ArrowUp') { - if (newFocus == null) { - newFocus = itemCount - 1; - } else if (newFocus > 0) { - newFocus -= 1; - } - } else if (e.key === 'ArrowDown') { - if (newFocus == null) { - newFocus = 0; - } else if (newFocus < itemCount - 1) { - newFocus += 1; - } - } else { - return; - } - - if (oldFocus !== newFocus) { - e.stopPropagation(); - e.preventDefault(); - - this.setKeyboardIndex(newFocus); - - if (isMultiSelect && e.shiftKey) { - this.toggleItem(newFocus); - } - - this.scrollIntoView(newFocus); - } - } - - handleScroll(): void { - this.updateStickyBottom(); - this.updateViewport(); - } - - scrollToBottom(): void { - window.requestAnimationFrame(() => { - if (this.list.current) { - this.list.current.scrollTop = this.list.current.scrollHeight; - } - }); - } - - scrollIntoView(itemIndex: number): void { - if (!this.list.current) { - return; - } - - const { itemCount, rowHeight } = this.props; - const { clientHeight, scrollTop } = this.list.current; - - const itemTop = itemIndex * rowHeight; - const itemBottom = itemTop + rowHeight; - - let newTop = scrollTop; - - if (itemTop < scrollTop) { - newTop = itemIndex * rowHeight; - } else if (scrollTop + clientHeight - rowHeight < itemBottom) { - const listBottom = scrollTop + clientHeight; - const deltaBottom = itemBottom - listBottom; - newTop = scrollTop + deltaBottom + rowHeight; - } - - newTop = Math.min( - newTop, - Math.max(itemCount * rowHeight - clientHeight, scrollTop) - ); - - if (newTop !== scrollTop) { - window.requestAnimationFrame(() => { - if (this.list.current) { - this.list.current.scrollTop = newTop; - } - }); - } - } - - resetSelection(): void { - this.deselectAll(); - this.setShiftRange(null); - this.setKeyboardIndex(null); - } - - deselectAll(): void { - this.setState({ selectedRanges: [] }); - } - - deselectItem(index: number): void { - this.deselectRange([index, index]); - } - - deselectRange(range: Range): void { - RangeUtils.validateRange(range); - - this.setState(({ selectedRanges }) => ({ - selectedRanges: RangeUtils.deselectRange(selectedRanges, range), - })); - } - - toggleItem(index: number): void { - this.setState(({ selectedRanges }) => { - if (this.getItemSelected(index, selectedRanges)) { - return { - selectedRanges: RangeUtils.deselectRange(selectedRanges, [ - index, - index, - ]), - }; - } - return { - selectedRanges: RangeUtils.selectRange(selectedRanges, [index, index]), - }; - }); - } - - selectItem(index: number): void { - this.selectRange([index, index]); - } - - selectRange(range: Range): void { - RangeUtils.validateRange(range); - - this.setState(({ selectedRanges }) => ({ - selectedRanges: RangeUtils.selectRange(selectedRanges, range), - })); - } - - sendViewportUpdate(): void { - const { topRow, bottomRow } = this.state; - if (topRow != null && bottomRow != null) { - const { onViewportChange } = this.props; - onViewportChange(topRow, bottomRow); - } - } - - isListAtBottom(): boolean { - return ( - this.list.current !== null && - this.list.current.scrollTop >= - this.list.current.scrollHeight - this.list.current.offsetHeight - ); - } - - updateStickyBottom(): void { - const { isStickyBottom } = this.props; - - const isStuckToBottom = isStickyBottom && this.isListAtBottom(); - - this.setState({ isStuckToBottom }); - } - - updateViewport(): void { - if (!this.list.current || this.list.current.clientHeight === 0) { - return; - } - - const { rowHeight } = this.props; - const top = this.list.current.scrollTop; - const bottom = top + this.list.current.clientHeight; - - const topRow = Math.floor(top / rowHeight); - const bottomRow = Math.ceil(bottom / rowHeight); - - this.setState({ topRow, bottomRow }); - } - - render(): JSX.Element { - const { items, itemCount, offset, rowHeight, renderItem } = this.props; - const { - isDropTargetValid, - keyboardIndex, - selectedRanges, - draggedRanges, - dragOverIndex, - } = this.state; - const itemElements = this.getCachedItems( - items, - rowHeight, - offset, - keyboardIndex, - selectedRanges, - draggedRanges, - dragOverIndex, - isDropTargetValid, - renderItem - ); - - return ( -
-
-
- {itemElements} -
-
-
- ); - } -} - -export default SingleClickItemList; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 26b37c5a05..3c1d7dece2 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -30,7 +30,7 @@ export { default as DraggableItemList } from './DraggableItemList'; export * from './DraggableItemList'; export { default as DragUtils } from './DragUtils'; export { default as HierarchicalCheckboxMenu } from './HierarchicalCheckboxMenu'; -export { default as ItemList } from './ItemList'; +export * from './ItemList'; export { default as ItemListItem } from './ItemListItem'; export { default as LoadingOverlay } from './LoadingOverlay'; export { default as LoadingSpinner } from './LoadingSpinner'; @@ -44,7 +44,6 @@ export { default as RadioItem } from './RadioItem'; export { default as Select } from './Select'; export { default as SearchInput } from './SearchInput'; export { default as SelectValueList } from './SelectValueList'; -export * from './SingleClickItemList'; export * from './shortcuts'; export { default as SocketedButton } from './SocketedButton'; export { default as ThemeExport } from './ThemeExport'; diff --git a/packages/console/src/Console.jsx b/packages/console/src/Console.jsx index 4ab3effe38..b6431ea33f 100644 --- a/packages/console/src/Console.jsx +++ b/packages/console/src/Console.jsx @@ -582,7 +582,11 @@ export class Console extends PureComponent { } handleDragEnter(e) { - if (!e.dataTransfer || !e.dataTransfer.items) { + if ( + !e.dataTransfer || + !e.dataTransfer.items || + e.dataTransfer.items.length === 0 + ) { return; } e.preventDefault(); diff --git a/packages/console/src/command-history/CommandHistory.jsx b/packages/console/src/command-history/CommandHistory.jsx index 733d29f861..7ea0a21077 100644 --- a/packages/console/src/command-history/CommandHistory.jsx +++ b/packages/console/src/command-history/CommandHistory.jsx @@ -332,6 +332,7 @@ class CommandHistory extends Component { onViewportChange={this.handleViewportChange} renderItem={this.renderItem} rowHeight={CommandHistory.ITEM_HEIGHT} + isDoubleClickSelect isMultiSelect isStickyBottom /> diff --git a/packages/console/src/command-history/CommandHistoryItem.scss b/packages/console/src/command-history/CommandHistoryItem.scss index 60318b9952..9a9cbc68b7 100644 --- a/packages/console/src/command-history/CommandHistoryItem.scss +++ b/packages/console/src/command-history/CommandHistoryItem.scss @@ -32,7 +32,7 @@ $selection-hover-color: $interfacewhite; border: 1px solid transparent; //we need a spacer border so stuff doesn't move on us when we apply a border-color } - .item-list-item.keyboard-active { + .item-list-item.is-focused { // We don't want the keyboard selection to appear, only items that are actually selected background-color: transparent; color: $text-muted; diff --git a/packages/file-explorer/package-lock.json b/packages/file-explorer/package-lock.json index 50274def10..73989cf634 100644 --- a/packages/file-explorer/package-lock.json +++ b/packages/file-explorer/package-lock.json @@ -1515,6 +1515,40 @@ "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==", "dev": true }, + "@fortawesome/fontawesome-common-types": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.35.tgz", + "integrity": "sha512-IHUfxSEDS9dDGqYwIW7wTN6tn/O8E0n5PcAHz9cAaBoZw6UpG20IG/YM3NNLaGPwPqgjBAFjIURzqoQs3rrtuw==", + "dev": true + }, + "@fortawesome/fontawesome-svg-core": { + "version": "1.2.35", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.35.tgz", + "integrity": "sha512-uLEXifXIL7hnh2sNZQrIJWNol7cTVIzwI+4qcBIq9QWaZqUblm0IDrtSqbNg+3SQf8SMGHkiSigD++rHmCHjBg==", + "dev": true, + "requires": { + "@fortawesome/fontawesome-common-types": "^0.2.35" + } + }, + "@fortawesome/react-fontawesome": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.14.tgz", + "integrity": "sha512-4wqNb0gRLVaBm/h+lGe8UfPPivcbuJ6ecI4hIgW0LjI7kzpYB9FkN0L9apbVzg+lsBdcTf0AlBtODjcSX5mmKA==", + "dev": true, + "requires": { + "prop-types": "^15.7.2" + } + }, + "@hypnosphi/create-react-context": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz", + "integrity": "sha512-V1klUed202XahrWJLLOT3EXNeCpFHCcJntdFGI15ntCwau+jfT386w7OFTMaCqOgXUH1fa0w/I1oZs+i/Rfr0A==", + "dev": true, + "requires": { + "gud": "^1.0.0", + "warning": "^4.0.3" + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -5861,6 +5895,15 @@ "yallist": "^3.0.2" } }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6485,6 +6528,17 @@ "mkdirp": "^0.5.1", "rimraf": "^2.5.4", "run-queue": "^1.0.0" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } } }, "copy-descriptor": { @@ -7004,6 +7058,20 @@ "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", "dev": true }, + "deep-equal": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", + "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", + "dev": true, + "requires": { + "is-arguments": "^1.0.4", + "is-date-object": "^1.0.1", + "is-regex": "^1.0.4", + "object-is": "^1.0.1", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.2.0" + } + }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -7196,6 +7264,15 @@ "utila": "~0.4" } }, + "dom-helpers": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", + "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.1.2" + } + }, "dom-serializer": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", @@ -8117,6 +8194,17 @@ "klaw": "^1.0.0", "path-is-absolute": "^1.0.0", "rimraf": "^2.2.8" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } } } } @@ -11622,7 +11710,8 @@ "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "js-yaml": { "version": "3.14.1", @@ -11867,6 +11956,12 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=", + "dev": true + }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -11877,6 +11972,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "requires": { "js-tokens": "^3.0.0 || ^4.0.0" } @@ -12325,6 +12421,17 @@ "mkdirp": "^0.5.1", "rimraf": "^2.5.4", "run-queue": "^1.0.3" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } } }, "ms": { @@ -12665,7 +12772,8 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true }, "object-copy": { "version": "0.1.0", @@ -12704,6 +12812,16 @@ "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==", "dev": true }, + "object-is": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", + "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -13285,6 +13403,12 @@ "@babel/runtime": "^7.14.0" } }, + "popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", + "dev": true + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -13583,6 +13707,7 @@ "version": "15.7.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -14108,7 +14233,8 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true }, "react-lifecycles-compat": { "version": "3.0.4", @@ -14179,6 +14305,48 @@ "use-latest": "^1.0.0" } }, + "react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "dev": true, + "requires": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + } + }, + "reactstrap": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/reactstrap/-/reactstrap-8.9.0.tgz", + "integrity": "sha512-pmf33YjpNZk1IfrjqpWCUMq9hk6GzSnMWBAofTBNIRJQB1zQ0Au2kzv3lPUAFsBYgWEuI9iYa/xKXHaboSiMkQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "classnames": "^2.2.3", + "prop-types": "^15.5.8", + "react-popper": "^1.3.6", + "react-transition-group": "^2.3.1" + }, + "dependencies": { + "react-popper": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.11.tgz", + "integrity": "sha512-VSA/bS+pSndSF2fiasHK/PTEEAyOpX60+H5EPAjoArr8JGm+oihu4UbrqcEBpQibJxBVCpYyjAX7abJ+7DoYVg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.1.2", + "@hypnosphi/create-react-context": "^0.3.1", + "deep-equal": "^1.1.1", + "popper.js": "^1.14.4", + "prop-types": "^15.6.1", + "typed-styles": "^0.0.7", + "warning": "^4.0.2" + } + } + } + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -14589,9 +14757,9 @@ "dev": true }, "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "dev": true, "requires": { "glob": "^7.1.3" @@ -16115,6 +16283,12 @@ "mime-types": "~2.1.24" } }, + "typed-styles": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz", + "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==", + "dev": true + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/packages/file-explorer/package.json b/packages/file-explorer/package.json index df4a5f2b9e..bbc1c9d2ce 100644 --- a/packages/file-explorer/package.json +++ b/packages/file-explorer/package.json @@ -46,9 +46,12 @@ "@deephaven/utils": "^0.1.4", "@fortawesome/fontawesome-svg-core": "^1.2.32", "@fortawesome/react-fontawesome": "^0.1.12", + "@types/react-beautiful-dnd": "^13.0.0", "classnames": "^2.3.1", + "lodash.throttle": "^4.1.1", "prop-types": "^15.7.2", "react": "^16.0.0", + "react-beautiful-dnd": "^13.0.0", "react-dom": "^16.0.0", "reactstrap": "^8.4.1", "webdav": "^4.6.0" @@ -75,6 +78,7 @@ "gh-pages": "^2.2.0", "identity-obj-proxy": "^3.0.0", "jest": "26.6.0", + "lodash.throttle": "^4.1.1", "npm-run-all": "^4.1.5", "prop-types": "^15.7.2", "react": "^16.14.0", diff --git a/packages/file-explorer/src/FileExplorer.scss b/packages/file-explorer/src/FileExplorer.scss index 059ab655bb..5df0916338 100644 --- a/packages/file-explorer/src/FileExplorer.scss +++ b/packages/file-explorer/src/FileExplorer.scss @@ -1,4 +1,9 @@ +@import '../../components/scss/custom.scss'; + +$file-explorer-bg: $gray-900; + .file-explorer { + background: $file-explorer-bg; position: relative; flex-grow: 1; diff --git a/packages/file-explorer/src/FileExplorer.tsx b/packages/file-explorer/src/FileExplorer.tsx index 08b5e209cb..9123c40f61 100644 --- a/packages/file-explorer/src/FileExplorer.tsx +++ b/packages/file-explorer/src/FileExplorer.tsx @@ -1,17 +1,13 @@ -import { BasicModal, SingleClickItemList } from '@deephaven/components'; +import { BasicModal } from '@deephaven/components'; import Log from '@deephaven/log'; import { CancelablePromise, PromiseUtils } from '@deephaven/utils'; -import React, { - Ref, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react'; -import { FileListItem, UpdateableComponent } from './FileList'; -import FileStorage, { FileStorageTable, isDirectory } from './FileStorage'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { DEFAULT_ROW_HEIGHT } from './FileList'; +import FileStorage, { + FileStorageItem, + FileStorageTable, + isDirectory, +} from './FileStorage'; import './FileExplorer.scss'; import FileListContainer from './FileListContainer'; import FileUtils from './FileUtils'; @@ -25,9 +21,9 @@ export interface FileExplorerProps { isMultiSelect?: boolean; - onDelete?: (files: FileListItem[]) => void; + onDelete?: (files: FileStorageItem[]) => void; onRename?: (oldName: string, newName: string) => void; - onSelect: (file: FileListItem) => void; + onSelect: (file: FileStorageItem) => void; /** Height of each item in the list */ rowHeight?: number; @@ -36,151 +32,169 @@ export interface FileExplorerProps { /** * Component that displays and allows interaction with the file system in the provided FileStorage. */ -export const FileExplorer = React.forwardRef( - (props: FileExplorerProps, ref: Ref) => { - const { - storage, - isMultiSelect = false, - onDelete = () => undefined, - onRename = () => undefined, - onSelect, - rowHeight = SingleClickItemList.DEFAULT_ROW_HEIGHT, - } = props; - const fileListContainer = useRef(null); - const [itemsToDelete, setItemsToDelete] = useState([]); - const [table, setTable] = useState(); - - useEffect(() => { - let tablePromise: CancelablePromise; - async function initTable() { - log.debug('initTable'); - - tablePromise = PromiseUtils.makeCancelable(storage.getTable(), t => - t.close() - ); - - try { - setTable(await tablePromise); - } catch (e) { - if (!PromiseUtils.isCanceled(e)) { - log.error('Unable to initialize table', e); - } +export const FileExplorer = (props: FileExplorerProps): JSX.Element => { + const { + storage, + isMultiSelect = false, + onDelete = () => undefined, + onRename = () => undefined, + onSelect, + rowHeight = DEFAULT_ROW_HEIGHT, + } = props; + const [itemsToDelete, setItemsToDelete] = useState([]); + const [table, setTable] = useState(); + + useEffect(() => { + let tablePromise: CancelablePromise; + async function initTable() { + log.debug('initTable'); + + tablePromise = PromiseUtils.makeCancelable(storage.getTable(), t => + t.close() + ); + + try { + setTable(await tablePromise); + } catch (e) { + if (!PromiseUtils.isCanceled(e)) { + log.error('Unable to initialize table', e); } } - initTable(); - return () => { - tablePromise.cancel(); - }; - }, [storage]); - - const handleError = useCallback((e: Error) => { - if (!PromiseUtils.isCanceled(e)) { - log.error(e); - } - }, []); - - const handleDelete = useCallback((files: FileListItem[]) => { - log.debug('handleDelete, pending confirmation', files); - setItemsToDelete(files); - }, []); - - const handleDeleteConfirm = useCallback(() => { - log.debug('handleDeleteConfirm', itemsToDelete); - itemsToDelete.forEach(file => - storage.deleteFile( + } + initTable(); + return () => { + tablePromise.cancel(); + }; + }, [storage]); + + const handleError = useCallback((e: Error) => { + if (!PromiseUtils.isCanceled(e)) { + log.error(e); + } + }, []); + + const handleDelete = useCallback((files: FileStorageItem[]) => { + log.debug('handleDelete, pending confirmation', files); + setItemsToDelete(files); + }, []); + + const handleDeleteConfirm = useCallback(() => { + log.debug('handleDeleteConfirm', itemsToDelete); + itemsToDelete.forEach(file => + storage.deleteFile( + isDirectory(file) ? FileUtils.makePath(file.filename) : file.filename + ) + ); + onDelete(itemsToDelete); + setItemsToDelete([]); + }, [itemsToDelete, onDelete, storage]); + + const handleDeleteCancel = useCallback(() => { + log.debug('handleDeleteCancel'); + setItemsToDelete([]); + }, []); + + const handleMove = useCallback( + (files: FileStorageItem[], path: string) => { + const filesToMove = FileUtils.reducePaths( + files.map(file => isDirectory(file) ? FileUtils.makePath(file.filename) : file.filename ) ); - onDelete(itemsToDelete); - setItemsToDelete([]); - }, [itemsToDelete, onDelete, storage]); - - const handleDeleteCancel = useCallback(() => { - log.debug('handleDeleteCancel'); - setItemsToDelete([]); - }, []); - - const handleRename = useCallback( - (item: FileListItem, newName: string) => { - let name = item.filename; - const isDir = isDirectory(item); - if (isDir && !name.endsWith('/')) { - name = `${name}/`; - } - let destination = `${FileUtils.getParent(name)}${newName}`; - if (isDir && !destination.endsWith('/')) { - destination = `${destination}/`; - } - log.debug2('handleRename', name, destination); - storage.moveFile(name, destination).catch(handleError); - onRename(name, destination); - }, - [handleError, onRename, storage] - ); - const handleValidateRename = useCallback( - async (renameItem: FileListItem, newName: string): Promise => { - if (newName === renameItem.basename) { - // Same name is fine - return undefined; - } - FileUtils.validateName(newName); - - const newValue = `${FileUtils.getPath(renameItem.filename)}${newName}`; - try { - const fileInfo = await storage.info(newValue); - throw new FileExistsError(fileInfo); - } catch (e) { - if (!(e instanceof FileNotFoundError)) { - throw e; - } - // The file does not exist, fine to save at that path + filesToMove.forEach(file => { + const newFile = FileUtils.isPath(file) + ? `${path}${FileUtils.getBaseName( + file.substring(0, file.length - 1) + )}/` + : `${path}${FileUtils.getBaseName(file)}`; + storage + .moveFile(file, newFile) + .then(() => { + // Each moved file triggers a rename so parent knows something has happened + // We signal each individually if for some reason there's an error moving one of the files + onRename(file, newFile); + }) + .catch(handleError); + }); + }, + [handleError, onRename, storage] + ); + + const handleRename = useCallback( + (item: FileStorageItem, newName: string) => { + let name = item.filename; + const isDir = isDirectory(item); + if (isDir && !name.endsWith('/')) { + name = `${name}/`; + } + let destination = `${FileUtils.getParent(name)}${newName}`; + if (isDir && !destination.endsWith('/')) { + destination = `${destination}/`; + } + log.debug2('handleRename', name, destination); + storage.moveFile(name, destination).catch(handleError); + onRename(name, destination); + }, + [handleError, onRename, storage] + ); + + const handleValidateRename = useCallback( + async (renameItem: FileStorageItem, newName: string): Promise => { + if (newName === renameItem.basename) { + // Same name is fine + return undefined; + } + FileUtils.validateName(newName); + + const newValue = `${FileUtils.getPath(renameItem.filename)}${newName}`; + try { + const fileInfo = await storage.info(newValue); + throw new FileExistsError(fileInfo); + } catch (e) { + if (!(e instanceof FileNotFoundError)) { + throw e; } - }, - [storage] - ); - - useImperativeHandle(ref, () => ({ - updateDimensions: () => { - fileListContainer.current?.updateDimensions(); - }, - })); - - const isDeleteConfirmationShown = itemsToDelete.length > 0; - const deleteConfirmationMessage = useMemo(() => { - if (itemsToDelete.length === 1) { - return `Are you sure you want to delete "${itemsToDelete[0].itemName}"?`; + // The file does not exist, fine to save at that path } - return `Are you sure you want to delete the selected files?`; - }, [itemsToDelete]); - - return ( -
- {table && ( - - )} - 0; + const deleteConfirmationMessage = useMemo(() => { + if (itemsToDelete.length === 1) { + return `Are you sure you want to delete "${itemsToDelete[0].filename}"?`; + } + return `Are you sure you want to delete the selected files?`; + }, [itemsToDelete]); + + return ( +
+ {table && ( + -
- ); - } -); + )} + +
+ ); +}; FileExplorer.displayName = 'FileExplorer'; diff --git a/packages/file-explorer/src/FileList.scss b/packages/file-explorer/src/FileList.scss index ce52f87e86..d82a9fb800 100644 --- a/packages/file-explorer/src/FileList.scss +++ b/packages/file-explorer/src/FileList.scss @@ -2,7 +2,15 @@ $depth-line-color: $gray-600; $depth-margin: 5px; -$depth-indentation: 9px; +$depth-indentation: 8px; +$item-list-color: $text-muted; +$item-list-selected-nofocus-bg: mix($primary, $gray-700, 12%); +$item-list-selected-bg: mix($primary, $gray-700, 35%); +$item-list-selection-border-color: mix($primary, $gray-700, 55%); +$item-list-focused-bg: rgba($primary, 0.75); +$item-list-hover-bg: $primary; +$item-list-selected-color: $white; +$item-list-drop-target-color: $white; .file-list { position: absolute; @@ -13,8 +21,111 @@ $depth-indentation: 9px; .file-list-depth-line { margin-left: $depth-margin; + padding-bottom: 2px; width: $depth-indentation; height: 100%; border-left: 1px solid $depth-line-color; + box-sizing: content-box; + } + + .item-list-scroll-pane { + border: none; + } + + .item-list-item { + padding: 0 $input-btn-padding-x; + color: $item-list-color; + transition: $btn-transition; + border: 1px solid transparent; // we need a spacer border so stuff doesn't move on us when we apply a border-color + -webkit-user-drag: none; // we need to disable webkit-user-drag or else Chrome switches to a Copy icon when dragging on Mac + + &.is-focused { + background-color: $item-list-selected-nofocus-bg; + } + + &.active { + background-color: $item-list-selected-nofocus-bg; + } + + &:focus, + &.active, + &:hover, + &.is-focused { + color: $item-list-selected-color; + } + } + + .item-icon { + margin-right: $spacer-1; + } + + &:focus-within { + &.item-list-item.is-focused { + background-color: $item-list-focused-bg; + } + + //for selected items, apply border on left and right + .item-list-item.active { + background-color: $item-list-selected-bg; + color: $item-list-selected-color; + border-left-color: $item-list-selection-border-color; + border-right-color: $item-list-selection-border-color; + } + } + + &.is-dragging { + cursor: grabbing; + + .item-list-item { + &.active { + opacity: 0.5; + } + } + + .is-in-drop-target, + .is-exact-drop-target { + color: $item-list-drop-target-color; + } + } + + /* stylelint-disable no-descending-specificity */ + &:not(.is-dragging) { + .item-list-item:hover, + &:focus-within .item-list-item.active:hover { + background-color: $item-list-hover-bg; + color: $item-list-selected-color; + transition: none; //to make things feel more responsive don't transition the hover in + } + } + + //apply border to top of the first item in the list if its selected, and the first selected after a non-selected item + &:focus-within .item-list-item:not(.active) + .active, + &:focus-within .item-list-item.active:first-of-type { + border-top: 1px solid $item-list-selection-border-color; + } + + //there's no easy way to get the last select item in a grouping, so we apply the end border + //to the TOP of the first non-selected item, ie. the previous selection group + &:focus-within .item-list-item.active + .item-list-item:not(.active) { + border-top: 1px solid $item-list-selection-border-color; + } + + //since there is no item after the last item in teh selection, we apply the border to the bottom of the last selected element + &:focus-within .item-list-item.active:last-of-type { + border-bottom: 1px solid $item-list-selection-border-color; + } + /* stylelint-enable no-descending-specificity */ +} + +.file-list-dnd-placeholder { + position: absolute; + top: -100px; + right: 0; + + .dnd-placeholder-content { + padding: $spacer-1 $spacer-3; + background: $primary-dark; + color: $foreground; + border-radius: $border-radius; } } diff --git a/packages/file-explorer/src/FileList.test.tsx b/packages/file-explorer/src/FileList.test.tsx new file mode 100644 index 0000000000..e547a9815b --- /dev/null +++ b/packages/file-explorer/src/FileList.test.tsx @@ -0,0 +1,63 @@ +import { FileStorageItem } from './FileStorage'; +import { getMoveOperation } from './FileList'; + +describe('getMoveOperation', () => { + function makeFile(basename: string, path = '/'): FileStorageItem { + const filename = `${path}${basename}`; + return { + basename, + filename, + type: 'file', + id: filename, + }; + } + + function makeDirectory(name: string, path = '/'): FileStorageItem { + const file = makeFile(name, path); + file.type = 'directory'; + return file; + } + + it('succeeds if moving files from root to within a directory', () => { + const targetPath = '/target/'; + const targetDirectory = makeDirectory('target'); + const targetItem = makeFile('targetItem', targetPath); + const draggedItems = [makeFile('foo.txt'), makeFile('bar.txt')]; + expect(getMoveOperation(draggedItems, targetItem)).toEqual({ + files: draggedItems, + targetPath, + }); + expect(getMoveOperation(draggedItems, targetDirectory)).toEqual({ + files: draggedItems, + targetPath, + }); + }); + + it('succeeds moving files from directory into root', () => { + const targetPath = '/'; + const targetItem = makeFile('targetItem', targetPath); + const path = '/baz/'; + const draggedItems = [makeFile('foo.txt', path), makeFile('bar.txt', path)]; + expect(getMoveOperation(draggedItems, targetItem)).toEqual({ + files: draggedItems, + targetPath, + }); + }); + + it('fails if no items selected to move', () => { + expect(() => getMoveOperation([], makeFile('foo.txt'))).toThrow(); + }); + + it('fails if trying to move files within same directory', () => { + const path = '/baz/'; + const targetItem = makeFile('targetItem', path); + const draggedItems = [makeFile('foo.txt', path), makeFile('bar.txt')]; + expect(() => getMoveOperation(draggedItems, targetItem)).toThrow(); + }); + + it('fails to move a directory into a child directory', () => { + expect(() => + getMoveOperation([makeDirectory('foo')], makeDirectory('bar', '/foo/')) + ).toThrow(); + }); +}); diff --git a/packages/file-explorer/src/FileList.tsx b/packages/file-explorer/src/FileList.tsx index 8740af0da9..47372f9ca8 100644 --- a/packages/file-explorer/src/FileList.tsx +++ b/packages/file-explorer/src/FileList.tsx @@ -1,39 +1,25 @@ -import { - Range, - SingleClickItemList, - SingleClickRenderItemBase, - SingleClickRenderItemProps, -} from '@deephaven/components'; +import { ItemList, Range, RenderItemProps } from '@deephaven/components'; import { dhPython, vsCode, vsFolder, vsFolderOpened } from '@deephaven/icons'; import Log from '@deephaven/log'; +import { RangeUtils } from '@deephaven/utils'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; import classNames from 'classnames'; import React, { - Ref, useCallback, useEffect, - useImperativeHandle, + useMemo, useRef, useState, } from 'react'; -import { - DirectoryStorageItem, - FileStorageItem, - FileStorageTable, - isDirectory, -} from './FileStorage'; +import { FileStorageItem, FileStorageTable, isDirectory } from './FileStorage'; import './FileList.scss'; import FileUtils, { MIME_TYPE } from './FileUtils'; const log = Log.module('FileList'); -export type FileListItem = SingleClickRenderItemBase & FileStorageItem; - -export type DirectoryListItem = FileListItem & DirectoryStorageItem; - export type LoadedViewport = { - items: FileListItem[]; + items: FileStorageItem[]; offset: number; itemCount: number; }; @@ -43,28 +29,82 @@ export type ListViewport = { bottom: number; }; +export type FileListRenderItemProps = RenderItemProps & { + children?: JSX.Element; + dropTargetItem?: FileStorageItem; + draggedItems?: FileStorageItem[]; + isDragInProgress: boolean; + isDropTargetValid: boolean; + + onDragStart(index: number, e: React.DragEvent): void; + onDragOver(index: number, e: React.DragEvent): void; + onDragEnd(index: number, e: React.DragEvent): void; + onDrop(index: number, e: React.DragEvent): void; +}; + export interface FileListProps { table: FileStorageTable; isMultiSelect?: boolean; - onMove: (files: FileListItem[], path: string) => void; - onSelect: (file: FileListItem) => void; - onSelectionChange?: ( - selectedItems: FileListItem[], - keyboardSelectedItem?: FileListItem - ) => void; + onFocusChange?: (focusedItem?: FileStorageItem) => void; + onMove: (files: FileStorageItem[], path: string) => void; + onSelect: (file: FileStorageItem) => void; + onSelectionChange?: (selectedItems: FileStorageItem[]) => void; - renderItem?: (props: SingleClickRenderItemProps) => JSX.Element; + renderItem?: (props: FileListRenderItemProps) => JSX.Element; /** Height of each item in the list */ rowHeight?: number; + + overscanCount?: number; } -export const DEFAULT_RENDER_ITEM = ( - props: SingleClickRenderItemProps & { children?: JSX.Element } +export const getPathFromItem = (file: FileStorageItem): string => + isDirectory(file) + ? FileUtils.makePath(file.filename) + : FileUtils.getPath(file.filename); + +export const DEFAULT_ROW_HEIGHT = 26; + +// How long you need to hover over a directory before it expands +export const DRAG_HOVER_TIMEOUT = 500; + +const ITEM_LIST_CLASS_NAME = 'item-list-scroll-pane'; + +export const renderFileListItem = ( + props: FileListRenderItemProps ): JSX.Element => { - const { children, isDragged, isSelected, item } = props; + const { + children, + draggedItems, + isDragInProgress, + isDropTargetValid, + isSelected, + item, + itemIndex, + dropTargetItem, + onDragStart, + onDragOver, + onDragEnd, + onDrop, + } = props; + + const isDragged = + draggedItems?.some(draggedItem => draggedItem.id === item.id) ?? false; + const itemPath = getPathFromItem(item); + const dropTargetPath = + isDragInProgress && dropTargetItem ? getPathFromItem(dropTargetItem) : null; + + const isExactDropTarget = + isDragInProgress && + isDropTargetValid && + isDirectory(item) && + dropTargetPath === itemPath; + const isInDropTarget = + isDragInProgress && isDropTargetValid && dropTargetPath === itemPath; + const isInvalidDropTarget = + isDragInProgress && !isDropTargetValid && dropTargetPath === itemPath; const icon = getItemIcon(item); const depth = FileUtils.getDepth(item.filename); @@ -77,10 +117,23 @@ export const DEFAULT_RENDER_ITEM = ( return (
onDragStart(itemIndex, e)} + onDragOver={e => onDragOver(itemIndex, e)} + onDragEnd={e => onDragEnd(itemIndex, e)} + onDrop={e => onDrop(itemIndex, e)} + draggable + role="presentation" > {depthLines}{' '} {' '} @@ -91,10 +144,10 @@ export const DEFAULT_RENDER_ITEM = ( /** * Get the icon definition for a file or folder item - * @param {FileListItem} item Item to get the icon for + * @param {FileStorageItem} item Item to get the icon for * @returns {IconDefinition} Icon definition to pass in the FontAwesomeIcon icon prop */ -export function getItemIcon(item: FileListItem): IconDefinition { +export function getItemIcon(item: FileStorageItem): IconDefinition { if (isDirectory(item)) { return item.isExpanded ? vsFolderOpened : vsFolder; } @@ -107,168 +160,409 @@ export function getItemIcon(item: FileListItem): IconDefinition { } } -export type UpdateableComponent = { updateDimensions: () => void }; +/** + * Get the move operation for the current selection and the given target. Throws if the operation is invalid. + */ +export function getMoveOperation( + draggedItems: FileStorageItem[], + targetItem: FileStorageItem +): { files: FileStorageItem[]; targetPath: string } { + if (draggedItems.length === 0 || !targetItem) { + throw new Error('No items to move'); + } + + const targetPath = getPathFromItem(targetItem); + if ( + draggedItems.some( + ({ filename }) => FileUtils.getPath(filename) === targetPath + ) + ) { + // Cannot drop if target is one of the dragged items is already in the target folder + throw new Error('File already in the destination folder'); + } + if ( + draggedItems.some( + item => + isDirectory(item) && + targetPath.startsWith(FileUtils.makePath(item.filename)) + ) + ) { + // Cannot drop if target is a child of one of the directories being moved + throw new Error('Destination folder cannot be a child of a dragged folder'); + } + return { files: draggedItems, targetPath }; +} /** * Component that displays and allows interaction with the file system in the provided FileStorageTable. */ -export const FileList = React.forwardRef( - (props: FileListProps, ref: Ref) => { - const { - isMultiSelect = false, - table, - onMove, - onSelect, - onSelectionChange = () => undefined, - renderItem = DEFAULT_RENDER_ITEM, - rowHeight = SingleClickItemList.DEFAULT_ROW_HEIGHT, - } = props; - const [loadedViewport, setLoadedViewport] = useState( - () => ({ - items: [], - offset: 0, - itemCount: 0, - }) - ); - const [viewport, setViewport] = useState({ - top: 0, - bottom: 0, - }); - const itemList = useRef>(null); +export const FileList = (props: FileListProps): JSX.Element => { + const { + isMultiSelect = false, + table, + onFocusChange = () => undefined, + onMove, + onSelect, + onSelectionChange = () => undefined, + renderItem = renderFileListItem, + rowHeight = DEFAULT_ROW_HEIGHT, + overscanCount = ItemList.DEFAULT_OVERSCAN, + } = props; + const [loadedViewport, setLoadedViewport] = useState(() => ({ + items: [], + offset: 0, + itemCount: 0, + })); + const [viewport, setViewport] = useState({ + top: 0, + bottom: 0, + }); - const getSelectedItems = useCallback( - (ranges: Range[]): FileListItem[] => { - if (ranges.length === 0 || !loadedViewport) { - return []; - } + const [dropTargetItem, setDropTargetItem] = useState(); + const [draggedItems, setDraggedItems] = useState(); + const [dragPlaceholder, setDragPlaceholder] = useState(); + const [selectedRanges, setSelectedRanges] = useState([] as Range[]); + const itemList = useRef>(null); + const fileList = useRef(null); + + const getItems = useCallback( + (ranges: Range[]): FileStorageItem[] => { + if (ranges.length === 0 || !loadedViewport) { + return []; + } - const items = [] as FileListItem[]; - for (let i = 0; i < ranges.length; i += 1) { - const range = ranges[i]; - for (let j = range[0]; j <= range[1]; j += 1) { - if ( - j >= loadedViewport.offset && - j < loadedViewport.offset + loadedViewport.items.length - ) { - items.push(loadedViewport.items[j - loadedViewport.offset]); - } + const items = [] as FileStorageItem[]; + for (let i = 0; i < ranges.length; i += 1) { + const range = ranges[i]; + for (let j = range[0]; j <= range[1]; j += 1) { + if ( + j >= loadedViewport.offset && + j < loadedViewport.offset + loadedViewport.items.length + ) { + items.push(loadedViewport.items[j - loadedViewport.offset]); } } - return items; - }, - [loadedViewport] - ); - - const handleItemDrop = useCallback( - (ranges: Range[], targetIndex: number) => { - log.debug2('handleItemDrop', ranges, targetIndex); - - const draggedItems = getSelectedItems(ranges); - const [targetItem] = getSelectedItems([[targetIndex, targetIndex]]); - if (draggedItems.length === 0 || !targetItem) { - return; - } + } + return items; + }, + [loadedViewport] + ); + + const getItem = useCallback( + (itemIndex: number): FileStorageItem | undefined => { + const items = getItems([[itemIndex, itemIndex]]); + if (items.length > 0) { + return items[0]; + } + }, + [getItems] + ); - const targetPath = FileUtils.getPath(targetItem.filename); - if ( - draggedItems.some(({ filename }) => filename.startsWith(targetPath)) - ) { - // Cannot drop if target is one of the dragged items - // or at least one of the dragged items is already in the target folder - return; + /** + * Get the placeholder text to show when a drag operation is in progress + */ + const getDragPlaceholderText = useCallback(() => { + const count = RangeUtils.count(selectedRanges); + if (count === 0) { + return null; + } + + if (count === 1) { + const index = selectedRanges[0][0]; + const item = getItem(index); + if (item != null) { + return item.filename; + } + } + return `${count} items`; + }, [getItem, selectedRanges]); + + /** + * Drop the currently dragged items at the currently set drop target. + * If an itemIndex is provided, focus that index after the drop. + */ + const dropItems = useCallback( + (itemIndex?: number) => { + if (!draggedItems || !dropTargetItem) { + return; + } + + log.debug('dropItems', draggedItems, 'to', itemIndex); + + try { + const { files, targetPath } = getMoveOperation( + draggedItems, + dropTargetItem + ); + onMove(files, targetPath); + if (itemIndex != null) { + setSelectedRanges([[itemIndex, itemIndex]]); + itemList.current?.focusItem(itemIndex); } - onMove(draggedItems, targetPath); - }, - [getSelectedItems, onMove] - ); - - const handleItemSelect = useCallback( - itemIndex => { - const item = loadedViewport.items[itemIndex - loadedViewport.offset]; - if (item !== undefined) { - log.debug('handleItemSelect', item); - - onSelect(item); - if (isDirectory(item)) { - table?.setExpanded(item.filename, !item.isExpanded); - } + } catch (err) { + log.error('Unable to complete move', err); + } + }, + [draggedItems, dropTargetItem, onMove] + ); + + const handleSelect = useCallback( + (itemIndex: number) => { + const item = loadedViewport.items[itemIndex - loadedViewport.offset]; + if (item !== undefined) { + log.debug('handleItemClick', item); + + onSelect(item); + if (isDirectory(item)) { + table?.setExpanded(item.filename, !item.isExpanded); } - }, - [loadedViewport, onSelect, table] - ); - - const handleSelectionChange = useCallback( - (selectedRanges, keyboardIndex) => { - log.debug2('handleSelectionChange', selectedRanges, keyboardIndex); - const selectedItems = getSelectedItems(selectedRanges); - const [keyboardSelectedItem] = getSelectedItems([ - [keyboardIndex, keyboardIndex], - ]); - onSelectionChange(selectedItems, keyboardSelectedItem); - }, - [getSelectedItems, onSelectionChange] - ); - - const handleViewportChange = useCallback((top: number, bottom: number) => { + } + }, + [loadedViewport, onSelect, table] + ); + + const handleItemDragStart = useCallback( + (itemIndex: number, e: React.DragEvent) => { + log.debug2('handleItemDragStart', itemIndex, selectedRanges); + + let draggedRanges = selectedRanges; + if (!RangeUtils.isSelected(selectedRanges, itemIndex)) { + draggedRanges = [[itemIndex, itemIndex]]; + setSelectedRanges(draggedRanges); + } + + setDraggedItems(getItems(draggedRanges)); + + // We need to reset reset the mouse state since we steal the drag + itemList.current?.resetMouseState(); + + const newDragPlaceholder = document.createElement('div'); + newDragPlaceholder.innerHTML = `
${getDragPlaceholderText()}
`; + newDragPlaceholder.className = 'file-list-dnd-placeholder'; + document.body.appendChild(newDragPlaceholder); + e.dataTransfer.setDragImage(newDragPlaceholder, 0, 0); + e.dataTransfer.effectAllowed = 'move'; + setDragPlaceholder(newDragPlaceholder); + }, + [getDragPlaceholderText, getItems, selectedRanges] + ); + + const handleItemDragOver = useCallback( + (itemIndex: number, e: React.DragEvent) => { + e.preventDefault(); + + log.debug2('handleItemDragOver', e); + setDropTargetItem(getItem(itemIndex)); + }, + [getItem] + ); + + const handleItemDragEnd = useCallback( + (itemIndex: number, e: React.DragEvent) => { + log.debug('handleItemDragEnd', itemIndex); + + dragPlaceholder?.remove(); + + // Drag end is triggered after drop + // Also drop isn't triggered if drag end is outside of the list + setDraggedItems(undefined); + setDropTargetItem(undefined); + setDragPlaceholder(undefined); + }, + [dragPlaceholder] + ); + + const handleItemDrop = useCallback( + (itemIndex: number, e: React.DragEvent) => { + dropItems(itemIndex); + }, + [dropItems] + ); + + const handleItemDragExit = useCallback(() => { + log.debug2('handleItemDragExit'); + setDropTargetItem(undefined); + }, []); + + const handleListDragOver = useCallback( + (e: React.DragEvent) => { + if ( + e.target instanceof Element && + e.target.classList.contains(ITEM_LIST_CLASS_NAME) + ) { + // Need to prevent default to enable drop + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#droptargets + e.preventDefault(); + + log.debug2('handleListDragOver', e); + setDropTargetItem({ + type: 'directory', + filename: '/', + basename: '/', + id: '/', + }); + } + }, + [] + ); + + const handleListDrop = useCallback( + (e: React.DragEvent) => { + if ( + e.target instanceof Element && + e.target.classList.contains(ITEM_LIST_CLASS_NAME) + ) { + log.debug('handleListDrop'); + dropItems(); + } + }, + [dropItems] + ); + + const handleSelectionChange = useCallback( + newSelectedRanges => { + log.debug2('handleSelectionChange', newSelectedRanges); + if (newSelectedRanges !== selectedRanges) { + setSelectedRanges(newSelectedRanges); + const selectedItems = getItems(newSelectedRanges); + onSelectionChange(selectedItems); + } + }, + [getItems, onSelectionChange, selectedRanges] + ); + + const handleFocusChange = useCallback( + focusIndex => { + log.debug2('handleFocusChange', focusIndex); + if (focusIndex != null) { + const [focusedItem] = getItems([[focusIndex, focusIndex]]); + onFocusChange(focusedItem); + } else { + onFocusChange(); + } + }, + [getItems, onFocusChange] + ); + + const handleViewportChange = useCallback( + (top: number, bottom: number) => { log.debug('handleViewportChange', top, bottom); - setViewport({ top, bottom }); - }, []); + if (top !== viewport.top || bottom !== viewport.bottom) { + setViewport({ top, bottom }); + } + }, + [viewport] + ); - const handleValidateDropTarget = useCallback(() => { - log.debug('handleValidateDropTarget'); + const isDropTargetValid = useMemo(() => { + if (!draggedItems || !dropTargetItem) { return false; - }, []); - - useEffect(() => { - log.debug('updating table viewport', viewport); - table?.setViewport(viewport); - }, [table, viewport]); - - useEffect(() => { - const listenerRemover = table.onUpdate(newViewport => { - setLoadedViewport({ - items: newViewport.items.map(item => ({ - ...item, - itemName: item.basename, - })), - offset: newViewport.offset, - itemCount: table.size, - }); + } + + try { + getMoveOperation(draggedItems, dropTargetItem); + log.debug('handleValidateDropTarget true'); + return true; + } catch (e) { + log.debug('handleValidateDropTarget false'); + return false; + } + }, [draggedItems, dropTargetItem]); + + useEffect(() => { + log.debug('updating table viewport', viewport); + table?.setViewport({ + top: Math.max(0, viewport.top - overscanCount), + bottom: viewport.bottom + overscanCount, + }); + }, [overscanCount, table, viewport]); + + // Listen for table updates + useEffect(() => { + const listenerRemover = table.onUpdate(newViewport => { + setLoadedViewport({ + items: newViewport.items.map(item => ({ + ...item, + itemName: item.basename, + })), + offset: newViewport.offset, + itemCount: table.size, }); - return () => { - listenerRemover(); - }; - }, [table]); - - useImperativeHandle(ref, () => ({ - updateDimensions: () => { - requestAnimationFrame(() => { - itemList.current?.updateViewport(); - }); - }, - })); - - return ( -
- -
- ); - } -); + }); + return () => { + listenerRemover(); + }; + }, [table]); + + // Expand a folder if hovering over it + useEffect(() => { + if ( + dropTargetItem != null && + isDirectory(dropTargetItem) && + dropTargetItem.filename !== '/' + ) { + const timeout = setTimeout(() => { + if (!dropTargetItem.isExpanded) { + table?.setExpanded(dropTargetItem.filename, true); + } + }, DRAG_HOVER_TIMEOUT); + return () => clearTimeout(timeout); + } + }, [dropTargetItem, table]); + + const renderWrapper = useCallback( + itemProps => + renderItem({ + ...itemProps, + isDragInProgress: draggedItems != null, + dropTargetItem, + draggedItems, + isDropTargetValid, + onDragStart: handleItemDragStart, + onDragEnd: handleItemDragEnd, + onDragOver: handleItemDragOver, + onDragExit: handleItemDragExit, + onDrop: handleItemDrop, + }), + [ + handleItemDragEnd, + handleItemDragExit, + handleItemDragOver, + handleItemDragStart, + handleItemDrop, + draggedItems, + dropTargetItem, + isDropTargetValid, + renderItem, + ] + ); + + return ( +
+ +
+ ); +}; export default FileList; diff --git a/packages/file-explorer/src/FileListContainer.tsx b/packages/file-explorer/src/FileListContainer.tsx index 3651b6955e..f7762bb1b5 100644 --- a/packages/file-explorer/src/FileListContainer.tsx +++ b/packages/file-explorer/src/FileListContainer.tsx @@ -1,23 +1,11 @@ -import { - ContextAction, - ContextActions, - SingleClickItemList, - SingleClickRenderItemProps, -} from '@deephaven/components'; -import React, { - Ref, - useCallback, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react'; +import { ContextAction, ContextActions } from '@deephaven/components'; +import React, { useCallback, useMemo, useState } from 'react'; import FileList, { - DEFAULT_RENDER_ITEM, - FileListItem, - UpdateableComponent, + renderFileListItem, + DEFAULT_ROW_HEIGHT, + FileListRenderItemProps, } from './FileList'; -import { FileStorageTable, isDirectory } from './FileStorage'; +import { FileStorageItem, FileStorageTable, isDirectory } from './FileStorage'; import SHORTCUTS from './FileExplorerShortcuts'; import './FileExplorer.scss'; import FileUtils from './FileUtils'; @@ -31,12 +19,12 @@ export interface FileListContainerProps { onCreateFile?: (path?: string) => void; onCreateFolder?: (path?: string) => void; - onCopy?: (file: FileListItem) => void; - onDelete?: (files: FileListItem[]) => void; - onMove?: (files: FileListItem[], path: string) => void; - onRename?: (file: FileListItem, newName: string) => void; - onSelect: (file: FileListItem) => void; - validateRename?: (file: FileListItem, newName: string) => Promise; + onCopy?: (file: FileStorageItem) => void; + onDelete?: (files: FileStorageItem[]) => void; + onMove?: (files: FileStorageItem[], path: string) => void; + onRename?: (file: FileStorageItem, newName: string) => void; + onSelect: (file: FileStorageItem) => void; + validateRename?: (file: FileStorageItem, newName: string) => Promise; /** Height of each item in the list */ rowHeight?: number; @@ -45,205 +33,194 @@ export interface FileListContainerProps { /** * Component that displays and allows interaction with the file system in the provided FileStorage. */ -export const FileListContainer = React.forwardRef( - (props: FileListContainerProps, ref: Ref) => { - const { - isMultiSelect = false, - showContextMenu = false, - onCreateFile, - onCreateFolder, - onCopy, - onDelete, - onMove = () => undefined, - onRename, - onSelect, - table, - rowHeight = SingleClickItemList.DEFAULT_ROW_HEIGHT, - validateRename = () => Promise.resolve(), - } = props; - const [renameItem, setRenameItem] = useState(); - const [selectedItems, setSelectedItems] = useState([] as FileListItem[]); - const [ - keyboardSelectedItem, - setKeyboardSelectedItem, - ] = useState(); - const fileList = useRef(null); - - const handleSelectionChange = useCallback( - (newSelectedItems, newKeyboardSelectedItem) => { - setSelectedItems(newSelectedItems); - setKeyboardSelectedItem(newKeyboardSelectedItem); - }, - [] - ); - - const handleCopyAction = useCallback(() => { - if (keyboardSelectedItem) { - onCopy?.(keyboardSelectedItem); - } - }, [keyboardSelectedItem, onCopy]); - - const handleDeleteAction = useCallback(() => { - if (selectedItems.length > 0) { - onDelete?.(selectedItems); - } - }, [onDelete, selectedItems]); - - const handleNewFileAction = useCallback(() => { - onCreateFile?.(); - }, [onCreateFile]); - - const handleNewFolderAction = useCallback(() => { - if (keyboardSelectedItem) { - onCreateFolder?.(FileUtils.getPath(keyboardSelectedItem.filename)); - } - }, [keyboardSelectedItem, onCreateFolder]); - - const handleRenameAction = useCallback(() => { - if (keyboardSelectedItem) { - setRenameItem(keyboardSelectedItem); - } - }, [keyboardSelectedItem]); - - const handleRenameCancel = useCallback((): void => { - setRenameItem(undefined); - }, []); - - const handleRenameSubmit = useCallback( - (newName: string): void => { - if (renameItem) { - onRename?.(renameItem, newName); - setRenameItem(undefined); - } - }, - [onRename, renameItem] - ); - - const actions = useMemo(() => { +export const FileListContainer = ( + props: FileListContainerProps +): JSX.Element => { + const { + isMultiSelect = false, + showContextMenu = false, + onCreateFile, + onCreateFolder, + onCopy, + onDelete, + onMove = () => undefined, + onRename, + onSelect, + table, + rowHeight = DEFAULT_ROW_HEIGHT, + validateRename = () => Promise.resolve(), + } = props; + const [renameItem, setRenameItem] = useState(); + const [selectedItems, setSelectedItems] = useState([] as FileStorageItem[]); + const [focusedItem, setFocusedItem] = useState(); + + const handleSelectionChange = useCallback(newSelectedItems => { + setSelectedItems(newSelectedItems); + }, []); + + const handleFocusChange = useCallback(newFocusedItem => { + setFocusedItem(newFocusedItem); + }, []); + + const handleCopyAction = useCallback(() => { + if (focusedItem) { + onCopy?.(focusedItem); + } + }, [focusedItem, onCopy]); + + const handleDeleteAction = useCallback(() => { + if (selectedItems.length > 0) { + onDelete?.(selectedItems); + } + }, [onDelete, selectedItems]); + + const handleNewFileAction = useCallback(() => { + onCreateFile?.(); + }, [onCreateFile]); + + const handleNewFolderAction = useCallback(() => { + if (focusedItem) { + onCreateFolder?.(FileUtils.getPath(focusedItem.filename)); + } + }, [focusedItem, onCreateFolder]); + + const handleRenameAction = useCallback(() => { + if (focusedItem) { + setRenameItem(focusedItem); + } + }, [focusedItem]); + + const handleRenameCancel = useCallback((): void => { + setRenameItem(undefined); + }, []); + + const handleRenameSubmit = useCallback( + (newName: string): void => { if (renameItem) { - // While renaming, we don't want to enable any of the context actions or it may interfere with renaming input - return []; + onRename?.(renameItem, newName); + setRenameItem(undefined); } - - const result = [] as ContextAction[]; - if (onCreateFile) { - result.push({ - title: 'New File', - description: 'Create new file', - action: handleNewFileAction, - group: ContextActions.groups.medium, - }); - } - if (onCreateFolder) { - result.push({ - title: 'New Folder', - description: 'Create new folder', - action: handleNewFolderAction, - group: ContextActions.groups.medium, - }); - } - if (onCopy) { - result.push({ - title: 'Copy', - description: 'Copy', - action: handleCopyAction, - group: ContextActions.groups.low, - disabled: - keyboardSelectedItem == null || isDirectory(keyboardSelectedItem), - }); - } - if (onDelete && selectedItems.length > 0) { - result.push({ - title: 'Delete', - description: 'Delete', - shortcut: SHORTCUTS.FILE_EXPLORER.DELETE, - action: handleDeleteAction, - group: ContextActions.groups.low, - }); + }, + [onRename, renameItem] + ); + + const actions = useMemo(() => { + if (renameItem) { + // While renaming, we don't want to enable any of the context actions or it may interfere with renaming input + return []; + } + + const result = [] as ContextAction[]; + if (onCreateFile) { + result.push({ + title: 'New File', + description: 'Create new file', + action: handleNewFileAction, + group: ContextActions.groups.medium, + }); + } + if (onCreateFolder) { + result.push({ + title: 'New Folder', + description: 'Create new folder', + action: handleNewFolderAction, + group: ContextActions.groups.medium, + }); + } + if (onCopy) { + result.push({ + title: 'Copy', + description: 'Copy', + action: handleCopyAction, + group: ContextActions.groups.low, + disabled: focusedItem == null || isDirectory(focusedItem), + }); + } + if (onDelete && selectedItems.length > 0) { + result.push({ + title: 'Delete', + description: 'Delete', + shortcut: SHORTCUTS.FILE_EXPLORER.DELETE, + action: handleDeleteAction, + group: ContextActions.groups.low, + }); + } + if (onRename) { + result.push({ + title: 'Rename', + description: 'Rename', + shortcut: SHORTCUTS.FILE_EXPLORER.RENAME, + action: handleRenameAction, + group: ContextActions.groups.low, + disabled: focusedItem == null, + }); + } + return result; + }, [ + handleCopyAction, + handleDeleteAction, + handleNewFileAction, + handleNewFolderAction, + handleRenameAction, + focusedItem, + onCopy, + onCreateFile, + onCreateFolder, + onDelete, + onRename, + selectedItems, + renameItem, + ]); + + const validateRenameItem = useCallback( + (newName: string): Promise => { + if (renameItem) { + return validateRename(renameItem, newName); } - if (onRename) { - result.push({ - title: 'Rename', - description: 'Rename', - shortcut: SHORTCUTS.FILE_EXPLORER.RENAME, - action: handleRenameAction, - group: ContextActions.groups.low, - disabled: keyboardSelectedItem == null, + return Promise.reject(new Error('No rename item')); + }, + [renameItem, validateRename] + ); + + const renderItem = useCallback( + (itemProps: FileListRenderItemProps): JSX.Element => { + const { item } = itemProps; + if (renameItem && renameItem.filename === item.filename) { + return renderFileListItem({ + ...itemProps, + children: ( + + ), }); } - return result; - }, [ - handleCopyAction, - handleDeleteAction, - handleNewFileAction, - handleNewFolderAction, - handleRenameAction, - keyboardSelectedItem, - onCopy, - onCreateFile, - onCreateFolder, - onDelete, - onRename, - selectedItems, - renameItem, - ]); - - const validateRenameItem = useCallback( - (newName: string): Promise => { - if (renameItem) { - return validateRename(renameItem, newName); - } - return Promise.reject(new Error('No rename item')); - }, - [renameItem, validateRename] - ); - - const renderItem = useCallback( - (itemProps: SingleClickRenderItemProps): JSX.Element => { - const { item } = itemProps; - if (renameItem && renameItem.filename === item.filename) { - return DEFAULT_RENDER_ITEM({ - ...itemProps, - children: ( - - ), - }); - } - return DEFAULT_RENDER_ITEM(itemProps); - }, - [handleRenameCancel, handleRenameSubmit, renameItem, validateRenameItem] - ); - - useImperativeHandle(ref, () => ({ - updateDimensions: () => { - fileList.current?.updateDimensions(); - }, - })); - - return ( -
- {table && ( - - )} - {showContextMenu && } -
- ); - } -); + return renderFileListItem(itemProps); + }, + [handleRenameCancel, handleRenameSubmit, renameItem, validateRenameItem] + ); + + return ( +
+ {table && ( + + )} + {showContextMenu && } +
+ ); +}; FileListContainer.displayName = 'FileListContainer'; diff --git a/packages/file-explorer/src/FileListItemEditor.tsx b/packages/file-explorer/src/FileListItemEditor.tsx index 39765f066a..535c479fa2 100644 --- a/packages/file-explorer/src/FileListItemEditor.tsx +++ b/packages/file-explorer/src/FileListItemEditor.tsx @@ -11,12 +11,12 @@ import classNames from 'classnames'; import Log from '@deephaven/log'; import { PromiseUtils } from '@deephaven/utils'; import './FileListItemEditor.scss'; -import { FileListItem } from './FileList'; +import { FileStorageItem } from './FileStorage'; const log = Log.module('FileListItemEditor'); export interface FileListItemEditorProps { - item: FileListItem; + item: FileStorageItem; onCancel: () => void; onSubmit: (newName: string) => void; validate?: (newName: string) => Promise; diff --git a/packages/file-explorer/src/FileUtils.test.ts b/packages/file-explorer/src/FileUtils.test.ts index f84f512c94..ff57a2cc72 100644 --- a/packages/file-explorer/src/FileUtils.test.ts +++ b/packages/file-explorer/src/FileUtils.test.ts @@ -143,3 +143,18 @@ describe('validateItemName for files', () => { testValidFileName('.extension-no-name'); }); }); + +it('reduces a selection', () => { + function testReduction(paths: string[], expectedPaths: string[]) { + expect(FileUtils.reducePaths(paths)).toEqual(expectedPaths); + } + + testReduction([], []); + testReduction(['/'], ['/']); + testReduction(['/', '/foo/', '/foo/bar.txt'], ['/']); + testReduction(['/foo/', '/foo/bar.txt'], ['/foo/']); + testReduction( + ['/foo/baz.txt', '/foo/bar.txt'], + ['/foo/baz.txt', '/foo/bar.txt'] + ); +}); diff --git a/packages/file-explorer/src/FileUtils.ts b/packages/file-explorer/src/FileUtils.ts index 6e6c6bb517..34b788e97c 100644 --- a/packages/file-explorer/src/FileUtils.ts +++ b/packages/file-explorer/src/FileUtils.ts @@ -204,6 +204,19 @@ export class FileUtils { ); } } + + /** + * Reduce the provided paths to the minimum selection. + * Removes any nested files or directories if a parent is already selected. + * @param paths The paths to reduce + */ + static reducePaths(paths: string[]): string[] { + const folders = paths.filter(path => FileUtils.isPath(path)); + return paths.filter( + path => + !folders.some(folder => path !== folder && path.startsWith(folder)) + ); + } } export default FileUtils; diff --git a/packages/file-explorer/src/NewItemModal.tsx b/packages/file-explorer/src/NewItemModal.tsx index 657d93b422..e4870716e7 100644 --- a/packages/file-explorer/src/NewItemModal.tsx +++ b/packages/file-explorer/src/NewItemModal.tsx @@ -11,8 +11,7 @@ import { } from '@deephaven/utils'; import Log from '@deephaven/log'; import FileExplorer from './FileExplorer'; -import { FileListItem } from './FileList'; -import FileStorage, { FileType } from './FileStorage'; +import FileStorage, { FileStorageItem, FileType } from './FileStorage'; import FileUtils from './FileUtils'; import './NewItemModal.scss'; @@ -141,7 +140,7 @@ class NewItemModal extends PureComponent { private cancelableValidatePromise?: CancelablePromise; - private cancelableExistingItemPromise?: CancelablePromise; + private cancelableExistingItemPromise?: CancelablePromise; private pending = new Pending(); @@ -223,7 +222,7 @@ class NewItemModal extends PureComponent { this.setState({ value }); } - handleSelect(item: FileListItem): void { + handleSelect(item: FileStorageItem): void { log.debug('handleSelect', item); if (item.type === 'directory') { this.setState({ path: FileUtils.makePath(item.filename) }); diff --git a/packages/file-explorer/src/WebdavFileStorage.ts b/packages/file-explorer/src/WebdavFileStorage.ts index 18211d0790..7ff0cf7794 100644 --- a/packages/file-explorer/src/WebdavFileStorage.ts +++ b/packages/file-explorer/src/WebdavFileStorage.ts @@ -1,5 +1,7 @@ /* eslint-disable class-methods-use-this */ + import { FileStat, WebDAVClient } from 'webdav/web'; +import throttle from 'lodash.throttle'; import FileNotFoundError from './FileNotFoundError'; import FileStorage, { File, @@ -10,6 +12,8 @@ import FileUtils from './FileUtils'; import WebdavFileStorageTable from './WebdavFileStorageTable'; export class WebdavFileStorage implements FileStorage { + private static readonly REFRESH_THROTTLE = 150; + readonly client; private tables = [] as WebdavFileStorageTable[]; @@ -83,9 +87,9 @@ export class WebdavFileStorage implements FileStorage { } } - private refreshTables(): void { + private refreshTables = throttle(() => { this.tables.every(table => table.refresh().catch(() => undefined)); - } + }, WebdavFileStorage.REFRESH_THROTTLE); } export default WebdavFileStorage; diff --git a/packages/file-explorer/src/index.ts b/packages/file-explorer/src/index.ts index afa64f9a99..4426c451d6 100644 --- a/packages/file-explorer/src/index.ts +++ b/packages/file-explorer/src/index.ts @@ -3,7 +3,7 @@ import FileExplorer from './FileExplorer'; export * from './FileExplorer'; export * from './FileListContainer'; export * from './FileList'; -export type { default as FileStorage } from './FileStorage'; +export * from './FileStorage'; export { default as NewItemModal } from './NewItemModal'; export { default as FileExplorerToolbar } from './FileExplorerToolbar'; export { default as FileUtils } from './FileUtils'; diff --git a/packages/iris-grid/src/PartitionSelectorSearch.jsx b/packages/iris-grid/src/PartitionSelectorSearch.jsx index ad1c78c1a4..3b7c9a5382 100644 --- a/packages/iris-grid/src/PartitionSelectorSearch.jsx +++ b/packages/iris-grid/src/PartitionSelectorSearch.jsx @@ -96,8 +96,7 @@ class PartitionSelectorSearch extends Component { } case 'ArrowDown': if (itemCount > 0) { - this.itemList.setKeyboardIndex(1); - this.itemList.focus(); + this.itemList.focusItem(1); } event.stopPropagation(); event.preventDefault(); @@ -128,7 +127,7 @@ class PartitionSelectorSearch extends Component { handleInputFocus() { if (this.itemList) { - this.itemList.setKeyboardIndex(0); + this.itemList.focusItem(0); } }