diff --git a/frontend/src/js/components/Uploads/FileApiHelpers.ts b/frontend/src/js/components/Uploads/FileApiHelpers.ts index b60dbce7..53e1580b 100644 --- a/frontend/src/js/components/Uploads/FileApiHelpers.ts +++ b/frontend/src/js/components/Uploads/FileApiHelpers.ts @@ -84,8 +84,22 @@ export async function readDirectoryEntry( } return new Promise((resolve, reject) => { - entry - .createReader() - .readEntries((entries) => entriesToMap(entries).then(resolve), reject); + const reader = entry.createReader(); + const allEntries: FileSystemEntry[] = []; + + // readEntries() may return results in batches (e.g. Chrome/Safari cap at ~100). + // Must call repeatedly until an empty array is returned. + function readBatch() { + reader.readEntries((entries) => { + if (entries.length === 0) { + entriesToMap(allEntries).then(resolve, reject); + } else { + allEntries.push(...entries); + readBatch(); + } + }, reject); + } + + readBatch(); }); } diff --git a/frontend/src/js/components/Uploads/FilePicker.tsx b/frontend/src/js/components/Uploads/FilePicker.tsx index 3e4bb859..f480bc54 100644 --- a/frontend/src/js/components/Uploads/FilePicker.tsx +++ b/frontend/src/js/components/Uploads/FilePicker.tsx @@ -1,43 +1,6 @@ import React from "react"; import { Button } from "semantic-ui-react"; -import { - readFileEntry, - readDirectoryEntry, - FileSystemEntry, - FileSystemFileEntry, - FileSystemDirectoryEntry, -} from "./FileApiHelpers"; - -async function readDragEvent(e: React.DragEvent): Promise> { - const files = new Map(); - - for (const item of e.dataTransfer.items) { - if (item.webkitGetAsEntry()) { - const entry: FileSystemEntry | null = item.webkitGetAsEntry(); - - if (entry && entry.isFile) { - const file = await readFileEntry(entry as FileSystemFileEntry); - files.set(file.name, file as File); - } else if (entry && entry.isDirectory) { - const directoryFiles = await readDirectoryEntry( - entry as FileSystemDirectoryEntry, - ); - - for (const [path, file] of directoryFiles) { - files.set(path, file as File); - } - } - } else { - const file = item.getAsFile(); - - if (file) { - files.set(file.name, file); - } - } - } - - return files; -} +import { readFilesFromDragEvent } from "./dropZoneUtils"; type Props = { disabled: boolean; @@ -129,14 +92,21 @@ export default class FilePicker extends React.Component { onDrop = (e: React.DragEvent) => { e.preventDefault(); - // Prevents React re-uses the event since readDragEvent is asynchronous + // Prevents React re-uses the event since readFilesFromDragEvent is asynchronous e.persist(); this.setState({ readingFiles: true }); - readDragEvent(e).then((files) => { - this.props.onAddFiles(files); - this.setState({ readingFiles: false }); - }); + readFilesFromDragEvent(e) + .then((files) => { + this.props.onAddFiles(files); + }) + .catch((error) => { + console.error("Failed to read dropped files:", error); + window.alert(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + this.setState({ readingFiles: false }); + }); }; render() { diff --git a/frontend/src/js/components/Uploads/UploadFiles.tsx b/frontend/src/js/components/Uploads/UploadFiles.tsx index 6ff44754..fdc5a790 100644 --- a/frontend/src/js/components/Uploads/UploadFiles.tsx +++ b/frontend/src/js/components/Uploads/UploadFiles.tsx @@ -1,4 +1,4 @@ -import React, { useReducer, useState } from "react"; +import React, { useReducer, useState, useEffect } from "react"; import uuid from "uuid/v4"; import MdFileUpload from "react-icons/lib/md/file-upload"; import Modal from "../UtilComponents/Modal"; @@ -54,6 +54,13 @@ export type WorkspaceUploadMetadata = { export type UploadFile = { file: File; state: FileUploadState }; +/** Files dropped from the file system to be uploaded */ +export type DroppedFilesInfo = { + files: Map; + /** The target folder node where files should be uploaded, or null for root */ + targetFolder: TreeNode | null; +}; + type Props = { username: string; workspace: Workspace; @@ -62,6 +69,10 @@ type Props = { focusedWorkspaceEntry: TreeEntry | null; expandedNodes?: TreeNode[]; isAdmin: boolean; + /** Files dropped from the file system (via drag-and-drop) */ + droppedFiles?: DroppedFilesInfo; + /** Callback to clear the dropped files after they've been consumed */ + onClearDroppedFiles?: () => void; }; type State = { @@ -325,6 +336,28 @@ export default function UploadFiles(props: Props) { const [focusedWorkspaceFolder, setFocusedWorkspaceFolder] = useState | null>(null); + // Destructure for useEffect dependencies + const { droppedFiles, onClearDroppedFiles } = props; + + // Handle files dropped from the file system via drag-and-drop + useEffect(() => { + if (droppedFiles && droppedFiles.files.size > 0) { + // Set the target folder (null means root) + setFocusedWorkspaceFolder(droppedFiles.targetFolder); + + // Add the files + dispatch({ type: "Add_Files", files: droppedFiles.files }); + + // Open the modal + setOpen(true); + + // Clear the dropped files prop + if (onClearDroppedFiles) { + onClearDroppedFiles(); + } + } + }, [droppedFiles, onClearDroppedFiles]); + async function onSubmit() { const { username, workspace, collections, getResource } = props; if (!collections) { diff --git a/frontend/src/js/components/Uploads/dropZoneUtils.ts b/frontend/src/js/components/Uploads/dropZoneUtils.ts new file mode 100644 index 00000000..5c21b341 --- /dev/null +++ b/frontend/src/js/components/Uploads/dropZoneUtils.ts @@ -0,0 +1,124 @@ +import React from "react"; +import { + readFileEntry, + readDirectoryEntry, + FileSystemEntry, + FileSystemFileEntry, + FileSystemDirectoryEntry, +} from "./FileApiHelpers"; + +/** + * Reads files from a drag event - handles both direct file drops and directories. + * Works across all major browsers on all platforms using the File System Access API. + * + * Only two kinds of selection are accepted: + * 1. A single directory (its full hierarchy is preserved); or + * 2. One or more files with no directories. + * + * Any other combination (e.g. mixed files and folders, or multiple folders) is + * rejected to avoid the ambiguous duplicate-entry problem that arises when the + * OS includes both a folder and its visible children as separate DataTransfer + * items. + * + * @param e - The React drag event + * @returns A Map of file paths to File objects + * @throws {Error} if the selection doesn't match one of the two accepted shapes + */ +export async function readFilesFromDragEvent( + e: React.DragEvent, +): Promise> { + // Collect all entries and fallback files synchronously before any async work. + // The browser clears the DataTransfer after the event handler returns, + // so awaiting inside the loop would lose items beyond the first. + const entries: FileSystemEntry[] = []; + const fallbackFiles: File[] = []; + + for (const item of e.dataTransfer.items) { + const entry = item.webkitGetAsEntry(); + + if (entry) { + entries.push(entry); + } else { + // Fallback for browsers that don't support webkitGetAsEntry + const file = item.getAsFile(); + + if (file) { + fallbackFiles.push(file); + } + } + } + + const directoryEntries = entries.filter((e) => e.isDirectory); + const fileEntries = entries.filter((e) => e.isFile); + + // Validate the selection shape + if (directoryEntries.length > 1) { + throw new Error( + "Please drag a single folder, or one or more individual files. " + + "Dragging multiple folders at once is not supported.", + ); + } + + if ( + directoryEntries.length === 1 && + (fileEntries.length > 0 || fallbackFiles.length > 0) + ) { + throw new Error( + "Please drag either a single folder or individual files, but not both at the same time.", + ); + } + + // Now process collected entries asynchronously + const files = new Map(); + + if (directoryEntries.length === 1) { + // Single directory: read its full hierarchy + const directoryFiles = await readDirectoryEntry( + directoryEntries[0] as FileSystemDirectoryEntry, + ); + + for (const [path, file] of directoryFiles) { + files.set(path, file as File); + } + } else { + // Files only (no directories) + for (const entry of fileEntries) { + const file = await readFileEntry(entry as FileSystemFileEntry); + files.set(file.name, file as File); + } + + for (const file of fallbackFiles) { + files.set(file.name, file); + } + } + + return files; +} + +/** + * Checks if a drag event contains files from the file system (as opposed to + * internal application data like dragging items within the workspace). + * + * @param e - The React drag event + * @returns true if the drag event contains files from the file system and not internal app data + */ +export function dragEventContainsFiles(e: React.DragEvent): boolean { + // Internal tree drags set application/json — exclude those even if Files is also present + if (e.dataTransfer.types.includes("application/json")) { + return false; + } + + // Check if there are any files in the dataTransfer + if (e.dataTransfer.types.includes("Files")) { + return true; + } + + // Also check items for file entries + for (const item of e.dataTransfer.items) { + if (item.kind === "file") { + return true; + } + } + + return false; +} diff --git a/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx b/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx index 78e6fb79..2ca7a4b7 100644 --- a/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx +++ b/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx @@ -17,6 +17,10 @@ import { import { MAX_NUMBER_OF_CHILDREN } from "../../../util/resourceUtils"; import { SearchLink } from "../SearchLink"; import { getIdsOfEntriesToMove, sortEntries } from "../../../util/treeUtils"; +import { + dragEventContainsFiles, + readFilesFromDragEvent, +} from "../../Uploads/dropZoneUtils"; type Props = { onSelectLeaf: (leaf: TreeLeaf) => void; @@ -42,6 +46,8 @@ type Props = { onExpandNode: (entry: TreeNode) => void; onCollapseNode: (entry: TreeNode) => void; onContextMenu: (e: React.MouseEvent, entry: TreeEntry) => void; + /** Optional callback for handling file drops from the file system */ + onDropFiles?: (files: Map, targetFolderId: string) => void; }; type State = { @@ -174,13 +180,36 @@ export default class TreeBrowser extends React.Component, State> { onDrop = (e: React.DragEvent, idOfLocationToMoveTo: string) => { e.preventDefault(); + // Check if this is a file drop from the file system + if (dragEventContainsFiles(e) && this.props.onDropFiles) { + // Prevent React from reusing the event since readFilesFromDragEvent is asynchronous + e.persist(); + + this.setState({ hoveredOver: false }); + + readFilesFromDragEvent(e) + .then((files) => { + if (files.size > 0 && this.props.onDropFiles) { + this.props.onDropFiles(files, idOfLocationToMoveTo); + } + }) + .catch((error) => { + console.error("Failed to read dropped files:", error); + window.alert(error instanceof Error ? error.message : String(error)); + }); + return; + } + + // Handle internal item move const json = e.dataTransfer.getData("application/json"); - const { id: idOfDraggedEntry } = JSON.parse(json); - const idsOfEntriesToMove = getIdsOfEntriesToMove( - this.props.selectedEntries, - idOfDraggedEntry, - ); - this.props.onMoveItems(idsOfEntriesToMove, idOfLocationToMoveTo); + if (json) { + const { id: idOfDraggedEntry } = JSON.parse(json); + const idsOfEntriesToMove = getIdsOfEntriesToMove( + this.props.selectedEntries, + idOfDraggedEntry, + ); + this.props.onMoveItems(idsOfEntriesToMove, idOfLocationToMoveTo); + } this.setState({ hoveredOver: false, diff --git a/frontend/src/js/components/workspace/WorkspaceSummary.tsx b/frontend/src/js/components/workspace/WorkspaceSummary.tsx index eb2323fa..71fc8851 100644 --- a/frontend/src/js/components/workspace/WorkspaceSummary.tsx +++ b/frontend/src/js/components/workspace/WorkspaceSummary.tsx @@ -12,7 +12,7 @@ import { setWorkspaceFollowers } from "../../actions/workspaces/setWorkspaceFoll import { setWorkspaceIsPublic } from "../../actions/workspaces/setWorkspaceIsPublic"; import { renameWorkspace } from "../../actions/workspaces/renameWorkspace"; import { deleteWorkspace } from "../../actions/workspaces/deleteWorkspace"; -import UploadFiles from "../Uploads/UploadFiles"; +import UploadFiles, { DroppedFilesInfo } from "../Uploads/UploadFiles"; import { Collection } from "../../types/Collection"; import { TreeEntry, TreeNode } from "../../types/Tree"; import { getWorkspace } from "../../actions/workspaces/getWorkspace"; @@ -43,6 +43,10 @@ type Props = { expandedNodes: TreeNode[]; isAdmin: boolean; clearFocus: () => void; + /** Files dropped from the file system (via drag-and-drop) */ + droppedFiles?: DroppedFilesInfo; + /** Callback to clear the dropped files after they've been consumed */ + onClearDroppedFiles?: () => void; }; export default function WorkspaceSummary({ @@ -61,6 +65,8 @@ export default function WorkspaceSummary({ expandedNodes, isAdmin, clearFocus, + droppedFiles, + onClearDroppedFiles, }: Props) { const [isShowingMoreOptions, setIsShowingMoreOptions] = useState(false); @@ -126,6 +132,8 @@ export default function WorkspaceSummary({ focusedWorkspaceEntry={focusedEntry} expandedNodes={expandedNodes} isAdmin={isAdmin} + droppedFiles={droppedFiles} + onClearDroppedFiles={onClearDroppedFiles} />
diff --git a/frontend/src/js/components/workspace/Workspaces.tsx b/frontend/src/js/components/workspace/Workspaces.tsx index 2ed98ce6..fe73dbcb 100644 --- a/frontend/src/js/components/workspace/Workspaces.tsx +++ b/frontend/src/js/components/workspace/Workspaces.tsx @@ -34,6 +34,7 @@ import { isTreeNode, TreeEntry, TreeLeaf, + TreeNode, } from "../../types/Tree"; import { isWorkspaceLeaf, @@ -80,6 +81,22 @@ import { import MdGlobeIcon from "react-icons/lib/md/public"; import ReactTooltip from "react-tooltip"; import { FromNowDurationText } from "../UtilComponents/FromNowDurationText"; +import { DroppedFilesInfo } from "../Uploads/UploadFiles"; + +/** Recursively search a tree node for a child node by ID */ +function findNodeById( + node: TreeNode, + id: string, +): TreeNode | undefined { + for (const child of node.children) { + if (isTreeNode(child)) { + if (child.id === id) return child; + const found = findNodeById(child, id); + if (found) return found; + } + } + return undefined; +} type Props = ReturnType & ReturnType & @@ -109,6 +126,8 @@ type State = { }; itemsBeingMoved: number; itemsWithMoveSettled: number; + /** Files dropped from the file system via drag-and-drop */ + droppedFiles: DroppedFilesInfo | undefined; }; type ContextMenuEntry = { @@ -417,6 +436,7 @@ class WorkspacesUnconnected extends React.Component { }, itemsBeingMoved: 0, itemsWithMoveSettled: 0, + droppedFiles: undefined, }; poller: NodeJS.Timeout | null = null; @@ -757,6 +777,45 @@ class WorkspacesUnconnected extends React.Component { }); }; + /** + * Handles files dropped from the file system via drag-and-drop onto a folder in the workspace tree. + * Sets the droppedFiles state which triggers the upload modal to open with the files pre-populated. + */ + onDropFiles = (files: Map, targetFolderId: string) => { + const workspace = this.props.currentWorkspace; + if (!workspace) return; + + let targetFolder: TreeNode | null = null; + + if (targetFolderId !== workspace.rootNode.id) { + // Search expanded nodes first (cheapest), then the full tree + const foundNode = + this.props.expandedNodes.find((node) => node.id === targetFolderId) ?? + findNodeById(workspace.rootNode, targetFolderId); + + if (foundNode) { + targetFolder = foundNode; + } + } + // If targetFolderId is root or not found, targetFolder stays null (uploads to root) + + this.setState({ + droppedFiles: { + files, + targetFolder, + }, + }); + }; + + /** + * Clears the dropped files state after the upload modal has consumed them. + */ + onClearDroppedFiles = () => { + this.setState({ + droppedFiles: undefined, + }); + }; + onContextMenu = (e: React.MouseEvent, entry: TreeEntry) => { if (e.metaKey && e.shiftKey) { // override for devs to do "inspect element" @@ -1138,6 +1197,7 @@ class WorkspacesUnconnected extends React.Component { selectedEntries={this.props.selectedEntries} focusedEntry={this.props.focusedEntry} onMoveItems={this.onMoveItems} + onDropFiles={this.onDropFiles} onSelectLeaf={onSelectLeaf} columnsConfig={this.state.columnsConfig} onClickColumn={this.onClickColumn} @@ -1198,6 +1258,8 @@ class WorkspacesUnconnected extends React.Component { "CanPerformAdminOperations", )} clearFocus={this.clearFocus} + droppedFiles={this.state.droppedFiles} + onClearDroppedFiles={this.onClearDroppedFiles} />
{this.renderFolderTree(this.props.currentWorkspace)}