& { children?: JSX.Element }
): JSX.Element => {
- const { children, isDragged, isSelected, item } = props;
+ const {
+ children,
+ isDragged,
+ isDropTargetValid,
+ isSelected,
+ item,
+ dragOverItem,
+ } = props;
+ const { isDragInProgress } = props;
+ const itemPath = getPathFromItem(item);
+ const dropTargetPath =
+ isDragInProgress && dragOverItem ? getPathFromItem(dragOverItem) : null;
+
+ const isExactDropTarget =
+ isDragInProgress &&
+ isDropTargetValid &&
+ isDirectory(item) &&
+ dropTargetPath === FileUtils.makePath(item.filename);
+ const isInDropTarget =
+ isDragInProgress && isDropTargetValid && dropTargetPath === itemPath;
+ const isInvalidDropTarget =
+ isDragInProgress && !isDropTargetValid && dropTargetPath === itemPath;
const icon = getItemIcon(item);
const depth = FileUtils.getDepth(item.filename);
@@ -79,6 +107,9 @@ export const DEFAULT_RENDER_ITEM = (
@@ -120,8 +151,8 @@ export const FileList = React.forwardRef(
onMove,
onSelect,
onSelectionChange = () => undefined,
- renderItem = DEFAULT_RENDER_ITEM,
- rowHeight = SingleClickItemList.DEFAULT_ROW_HEIGHT,
+ renderItem = renderFileListItem,
+ rowHeight = DEFAULT_ROW_HEIGHT,
} = props;
const [loadedViewport, setLoadedViewport] = useState(
() => ({
@@ -159,27 +190,50 @@ export const FileList = React.forwardRef(
[loadedViewport]
);
- const handleItemDrop = useCallback(
- (ranges: Range[], targetIndex: number) => {
- log.debug2('handleItemDrop', ranges, targetIndex);
-
+ const getMoveOperation = useCallback(
+ (
+ ranges: Range[],
+ targetIndex: number
+ ): { files: FileListItem[]; targetPath: string } => {
const draggedItems = getSelectedItems(ranges);
const [targetItem] = getSelectedItems([[targetIndex, targetIndex]]);
if (draggedItems.length === 0 || !targetItem) {
- return;
+ throw new Error('No items to move');
}
- const targetPath = FileUtils.getPath(targetItem.filename);
+ const targetPath = isDirectory(targetItem)
+ ? FileUtils.makePath(targetItem.filename)
+ : FileUtils.getPath(targetItem.filename);
if (
- draggedItems.some(({ filename }) => filename.startsWith(targetPath))
+ draggedItems.some(
+ ({ filename }) => FileUtils.getPath(filename) === 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;
+ // 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');
}
- onMove(draggedItems, targetPath);
+ return { files: draggedItems, targetPath };
},
- [getSelectedItems, onMove]
+ [getSelectedItems]
+ );
+
+ const handleItemDrop = useCallback(
+ (ranges: Range[], targetIndex: number) => {
+ log.debug2('handleItemDrop', ranges, targetIndex);
+
+ try {
+ const { files, targetPath } = getMoveOperation(ranges, targetIndex);
+ onMove(files, targetPath);
+ itemList.current?.deselectAll();
+ itemList.current?.selectItem(targetIndex);
+ itemList.current?.setKeyboardIndex(null);
+ itemList.current?.setShiftRange(null);
+ itemList.current?.focus();
+ } catch (e) {
+ log.error('Unable to complete move', e);
+ }
+ },
+ [getMoveOperation, onMove]
);
const handleItemSelect = useCallback(
@@ -214,10 +268,19 @@ export const FileList = React.forwardRef(
setViewport({ top, bottom });
}, []);
- const handleValidateDropTarget = useCallback(() => {
- log.debug('handleValidateDropTarget');
- return false;
- }, []);
+ const handleValidateDropTarget = useCallback(
+ (draggedRanges: Range[], targetIndex: number) => {
+ try {
+ getMoveOperation(draggedRanges, targetIndex);
+ log.debug('handleValidateDropTarget true');
+ return true;
+ } catch (e) {
+ log.debug('handleValidateDropTarget false');
+ return false;
+ }
+ },
+ [getMoveOperation]
+ );
useEffect(() => {
log.debug('updating table viewport', viewport);
@@ -261,8 +324,7 @@ export const FileList = React.forwardRef(
onViewportChange={handleViewportChange}
renderItem={renderItem}
rowHeight={rowHeight}
- // TODO: web-client-ui#86, re-enable drag and drop to move
- // isDraggable
+ isDraggable
isMultiSelect={isMultiSelect}
validateDropTarget={handleValidateDropTarget}
/>
diff --git a/packages/file-explorer/src/FileListContainer.tsx b/packages/file-explorer/src/FileListContainer.tsx
index 3651b6955e..f7a5ac5fd6 100644
--- a/packages/file-explorer/src/FileListContainer.tsx
+++ b/packages/file-explorer/src/FileListContainer.tsx
@@ -1,7 +1,6 @@
import {
ContextAction,
ContextActions,
- SingleClickItemList,
SingleClickRenderItemProps,
} from '@deephaven/components';
import React, {
@@ -13,9 +12,10 @@ import React, {
useState,
} from 'react';
import FileList, {
- DEFAULT_RENDER_ITEM,
+ renderFileListItem,
FileListItem,
UpdateableComponent,
+ DEFAULT_ROW_HEIGHT,
} from './FileList';
import { FileStorageTable, isDirectory } from './FileStorage';
import SHORTCUTS from './FileExplorerShortcuts';
@@ -58,7 +58,7 @@ export const FileListContainer = React.forwardRef(
onRename,
onSelect,
table,
- rowHeight = SingleClickItemList.DEFAULT_ROW_HEIGHT,
+ rowHeight = DEFAULT_ROW_HEIGHT,
validateRename = () => Promise.resolve(),
} = props;
const [renameItem, setRenameItem] = useState();
@@ -202,7 +202,7 @@ export const FileListContainer = React.forwardRef(
(itemProps: SingleClickRenderItemProps): JSX.Element => {
const { item } = itemProps;
if (renameItem && renameItem.filename === item.filename) {
- return DEFAULT_RENDER_ITEM({
+ return renderFileListItem({
...itemProps,
children: (
{
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/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;