diff --git a/packages/code-studio/src/storage/grpc/GrpcFileStorage.ts b/packages/code-studio/src/storage/grpc/GrpcFileStorage.ts index cc6d341bf2..09036ea291 100644 --- a/packages/code-studio/src/storage/grpc/GrpcFileStorage.ts +++ b/packages/code-studio/src/storage/grpc/GrpcFileStorage.ts @@ -80,6 +80,16 @@ export class GrpcFileStorage implements FileStorage { }; } + async copyFile(name: string, newName: string): Promise { + const fileContents = await this.storageService.loadFile(this.addRoot(name)); + await this.storageService.saveFile( + this.addRoot(newName), + fileContents, + false + ); + this.refreshTables(); + } + async deleteFile(name: string): Promise { await this.storageService.deleteItem(this.addRoot(name)); this.refreshTables(); diff --git a/packages/components/src/ItemList.tsx b/packages/components/src/ItemList.tsx index 1b131e84d4..ca61a2072b 100644 --- a/packages/components/src/ItemList.tsx +++ b/packages/components/src/ItemList.tsx @@ -371,6 +371,8 @@ export class ItemList extends PureComponent< itemIndex: number, e: React.MouseEvent ): void { + this.setState({ focusIndex: itemIndex }); + // Update the selection, but don't consume the mouse event - it will trigger the context menu const { selectedRanges } = this.state; const isSelected = RangeUtils.isSelected(selectedRanges, itemIndex); diff --git a/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.tsx b/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.tsx index 5a1e586bf1..b3f8f53395 100644 --- a/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.tsx @@ -7,6 +7,7 @@ import FileExplorer, { FileStorageItem, FileUtils, NewItemModal, + isDirectory, } from '@deephaven/file-explorer'; import React, { ReactNode } from 'react'; import { connect, ConnectedProps } from 'react-redux'; @@ -94,6 +95,7 @@ export class FileExplorerPanel extends React.Component< super(props); this.handleFileSelect = this.handleFileSelect.bind(this); + this.handleCopyFile = this.handleCopyFile.bind(this); this.handleCreateFile = this.handleCreateFile.bind(this); this.handleCreateDirectory = this.handleCreateDirectory.bind(this); this.handleCreateDirectoryCancel = @@ -139,7 +141,7 @@ export class FileExplorerPanel extends React.Component< ); } - handleCreateDirectory(path?: string): void { + handleCreateDirectory(): void { this.setState({ showCreateFolder: true }); } @@ -157,6 +159,29 @@ export class FileExplorerPanel extends React.Component< fileStorage.createDirectory(path).catch(FileExplorerPanel.handleError); } + async handleCopyFile(file: FileStorageItem): Promise { + const { fileStorage } = this.props; + if (isDirectory(file)) { + log.error('Invalid item in handleCopyItem', file); + return; + } + let newName = FileUtils.getCopyFileName(file.filename); + const checkNewName = async (): Promise => { + try { + await fileStorage.info(newName); + return true; + } catch (error) { + return false; + } + }; + // await in loop is fine here, this isn't a parallel task + // eslint-disable-next-line no-await-in-loop + while (await checkNewName()) { + newName = FileUtils.getCopyFileName(newName); + } + await fileStorage.copyFile(file.filename, newName); + } + handleDelete(files: FileStorageItem[]): void { const { glEventHub } = this.props; files.forEach(file => { @@ -257,6 +282,9 @@ export class FileExplorerPanel extends React.Component< { + throw new Error('Method not implemented.'); + } + async deleteFile(name: string): Promise { this.items = this.items.filter(value => value.filename !== name); } diff --git a/packages/file-explorer/src/FileExplorer.tsx b/packages/file-explorer/src/FileExplorer.tsx index 0178e48d3d..64c4792b70 100644 --- a/packages/file-explorer/src/FileExplorer.tsx +++ b/packages/file-explorer/src/FileExplorer.tsx @@ -22,6 +22,9 @@ export interface FileExplorerProps { isMultiSelect?: boolean; focusedPath?: string; + onCopy?: (file: FileStorageItem) => void; + onCreateFile?: () => void; + onCreateFolder?: () => void; onDelete?: (files: FileStorageItem[]) => void; onRename?: (oldName: string, newName: string) => void; onSelect: (file: FileStorageItem, event: React.SyntheticEvent) => void; @@ -39,8 +42,11 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { storage, isMultiSelect = false, focusedPath, + onCopy = () => undefined, onDelete = () => undefined, onRename = () => undefined, + onCreateFile = () => undefined, + onCreateFolder = () => undefined, onSelect, onSelectionChange, rowHeight = DEFAULT_ROW_HEIGHT, @@ -80,6 +86,24 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { } }, []); + const handleCreateFile = useCallback(() => { + log.debug('handleCreateFile'); + onCreateFile(); + }, [onCreateFile]); + + const handleCreateFolder = useCallback(() => { + log.debug('handleCreateFolder'); + onCreateFolder(); + }, [onCreateFolder]); + + const handleCopyFile = useCallback( + (file: FileStorageItem) => { + log.debug('handleCopyFile', file.filename); + onCopy(file); + }, + [onCopy] + ); + const handleDelete = useCallback((files: FileStorageItem[]) => { log.debug('handleDelete, pending confirmation', files); setItemsToDelete(files); @@ -183,6 +207,9 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { isMultiSelect={isMultiSelect} focusedPath={focusedPath} showContextMenu + onCopy={handleCopyFile} + onCreateFolder={handleCreateFolder} + onCreateFile={handleCreateFile} onMove={handleMove} onDelete={handleDelete} onRename={handleRename} diff --git a/packages/file-explorer/src/FileList.tsx b/packages/file-explorer/src/FileList.tsx index 15d8796642..908101c891 100644 --- a/packages/file-explorer/src/FileList.tsx +++ b/packages/file-explorer/src/FileList.tsx @@ -81,6 +81,8 @@ export function FileList(props: FileListProps): JSX.Element { const [dragPlaceholder, setDragPlaceholder] = useState(); const [selectedRanges, setSelectedRanges] = useState([] as Range[]); + const focusedIndex = useRef(); + const itemList = useRef>(null); const fileList = useRef(null); @@ -279,9 +281,9 @@ export function FileList(props: FileListProps): JSX.Element { ); const handleSelectionChange = useCallback( - newSelectedRanges => { + (newSelectedRanges, force = false) => { log.debug2('handleSelectionChange', newSelectedRanges); - if (newSelectedRanges !== selectedRanges) { + if (force === true || newSelectedRanges !== selectedRanges) { setSelectedRanges(newSelectedRanges); const selectedItems = getItems(newSelectedRanges); onSelectionChange(selectedItems); @@ -299,6 +301,7 @@ export function FileList(props: FileListProps): JSX.Element { } else { onFocusChange(); } + focusedIndex.current = focusIndex; }, [getItems, onFocusChange] ); @@ -371,6 +374,23 @@ export function FileList(props: FileListProps): JSX.Element { [table] ); + // if the loadedViewport changes, re-fire the focused + // item and the selected range items as they could have + // been updated + useEffect( + function updateFocusAndSelection() { + if (focusedIndex.current != null) { + handleFocusChange(focusedIndex.current); + } + if (selectedRanges.length > 0) { + // force the update, as the selected range may be the same + // but the selected items may now be different + handleSelectionChange(selectedRanges, true); + } + }, + [loadedViewport, handleFocusChange, handleSelectionChange, selectedRanges] + ); + // Expand a folder if hovering over it useEffect( function expandFolderOnHover() { diff --git a/packages/file-explorer/src/FileListContainer.tsx b/packages/file-explorer/src/FileListContainer.tsx index 6a68d7b346..fab8338aaa 100644 --- a/packages/file-explorer/src/FileListContainer.tsx +++ b/packages/file-explorer/src/FileListContainer.tsx @@ -134,8 +134,8 @@ export function FileListContainer(props: FileListContainerProps): JSX.Element { } if (onCopy) { result.push({ - title: 'Copy', - description: 'Copy', + title: 'Copy File', + description: 'Copy the selected file', action: handleCopyAction, group: ContextActions.groups.low, disabled: focusedItem == null || isDirectory(focusedItem), diff --git a/packages/file-explorer/src/FileListItem.tsx b/packages/file-explorer/src/FileListItem.tsx index 6cba083a24..97d8394846 100644 --- a/packages/file-explorer/src/FileListItem.tsx +++ b/packages/file-explorer/src/FileListItem.tsx @@ -105,14 +105,12 @@ export function FileListItem(props: FileListRenderItemProps): JSX.Element { {depthLines}{' '} {' '} - {children ?? item.basename} - - {children ?? item.basename} - + {children ?? ( + <> + {item.basename} + {item.basename} + + )} ); diff --git a/packages/file-explorer/src/FileStorage.ts b/packages/file-explorer/src/FileStorage.ts index d2b845deca..dda81c6d85 100644 --- a/packages/file-explorer/src/FileStorage.ts +++ b/packages/file-explorer/src/FileStorage.ts @@ -84,6 +84,13 @@ export interface FileStorage { */ moveFile(name: string, newName: string): Promise; + /** + * Copy a file to a new location + * @param name The name of the file to copy + * @param newName The new file name, including path + */ + copyFile(name: string, newName: string): Promise; + /** * Get the info for the file at the specified path. * If the file does not exists, rejects with a FileNotFoundError