From cdef33b51f885531aa5e0973653797a9b01c9402 Mon Sep 17 00:00:00 2001 From: hoyla Date: Sun, 15 Feb 2026 10:51:21 +0000 Subject: [PATCH 1/9] feat: Add drag-and-drop file upload to workspace tree Users can now drag files and folders from their file system directly onto folders in the workspace tree to trigger an upload to that location. Changes: - Add dropZoneUtils.ts with shared utilities for handling file drops - Update TreeBrowser to detect and handle file system drops vs internal moves - Update UploadFiles to accept pre-populated files via droppedFiles prop - Wire up Workspaces component to pass dropped files to upload modal Cross-platform compatible using the File System Access API (webkitGetAsEntry) which is supported by all major browsers. --- .../src/js/components/Uploads/FilePicker.tsx | 43 +--------- .../src/js/components/Uploads/UploadFiles.tsx | 32 ++++++- .../js/components/Uploads/dropZoneUtils.ts | 83 +++++++++++++++++++ .../UtilComponents/TreeBrowser/index.tsx | 39 +++++++-- .../components/workspace/WorkspaceSummary.tsx | 10 ++- .../js/components/workspace/Workspaces.tsx | 46 ++++++++++ 6 files changed, 205 insertions(+), 48 deletions(-) create mode 100644 frontend/src/js/components/Uploads/dropZoneUtils.ts diff --git a/frontend/src/js/components/Uploads/FilePicker.tsx b/frontend/src/js/components/Uploads/FilePicker.tsx index 3e4bb859..5d045004 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,11 +92,11 @@ 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) => { + readFilesFromDragEvent(e).then((files) => { this.props.onAddFiles(files); this.setState({ readingFiles: false }); }); diff --git a/frontend/src/js/components/Uploads/UploadFiles.tsx b/frontend/src/js/components/Uploads/UploadFiles.tsx index 6ff44754..5325fdf6 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,25 @@ export default function UploadFiles(props: Props) { const [focusedWorkspaceFolder, setFocusedWorkspaceFolder] = useState | null>(null); + // Handle files dropped from the file system via drag-and-drop + useEffect(() => { + if (props.droppedFiles && props.droppedFiles.files.size > 0) { + // Set the target folder (null means root) + setFocusedWorkspaceFolder(props.droppedFiles.targetFolder); + + // Add the files + dispatch({ type: "Add_Files", files: props.droppedFiles.files }); + + // Open the modal + setOpen(true); + + // Clear the dropped files prop + if (props.onClearDroppedFiles) { + props.onClearDroppedFiles(); + } + } + }, [props.droppedFiles, props.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..93fd8b58 --- /dev/null +++ b/frontend/src/js/components/Uploads/dropZoneUtils.ts @@ -0,0 +1,83 @@ +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. + * + * @param e - The React drag event + * @returns A Map of file paths to File objects + */ +export async function readFilesFromDragEvent( + 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 { + // Fallback for browsers that don't support webkitGetAsEntry + const file = item.getAsFile(); + + if (file) { + 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 + */ +export function dragEventContainsFiles(e: React.DragEvent): boolean { + // 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; +} + +/** + * Checks if a drag event contains only internal application data (e.g., items + * being moved within the workspace tree). + * + * @param e - The React drag event + * @returns true if the drag event contains internal app data + */ +export function dragEventContainsInternalData(e: React.DragEvent): boolean { + return e.dataTransfer.types.includes("application/json"); +} diff --git a/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx b/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx index 78e6fb79..040dbdd9 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 = { @@ -49,6 +55,7 @@ type State = { draggingColumn: string | null; initialX: number | null; hoveredOver: boolean; + readingDroppedFiles: boolean; }; export default class TreeBrowser extends React.Component, State> { @@ -61,6 +68,7 @@ export default class TreeBrowser extends React.Component, State> { draggingColumn: null, initialX: null, hoveredOver: false, + readingDroppedFiles: false, }; } @@ -174,13 +182,32 @@ 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({ readingDroppedFiles: true, hoveredOver: false }); + + readFilesFromDragEvent(e).then((files) => { + if (files.size > 0 && this.props.onDropFiles) { + this.props.onDropFiles(files, idOfLocationToMoveTo); + } + this.setState({ readingDroppedFiles: false }); + }); + 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 80899a51..a07d184c 100644 --- a/frontend/src/js/components/workspace/Workspaces.tsx +++ b/frontend/src/js/components/workspace/Workspaces.tsx @@ -80,6 +80,7 @@ import { import MdGlobeIcon from "react-icons/lib/md/public"; import ReactTooltip from "react-tooltip"; import { FromNowDurationText } from "../UtilComponents/FromNowDurationText"; +import { DroppedFilesInfo } from "../Uploads/UploadFiles"; type Props = ReturnType & ReturnType & @@ -109,6 +110,8 @@ type State = { }; itemsBeingMoved: number; itemsWithMoveSettled: number; + /** Files dropped from the file system via drag-and-drop */ + droppedFiles: DroppedFilesInfo | undefined; }; type ContextMenuEntry = { @@ -417,6 +420,7 @@ class WorkspacesUnconnected extends React.Component { }, itemsBeingMoved: 0, itemsWithMoveSettled: 0, + droppedFiles: undefined, }; poller: NodeJS.Timeout | null = null; @@ -757,6 +761,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) => { + // Find the target folder node from expandedNodes or check if it's the root + const workspace = this.props.currentWorkspace; + if (!workspace) return; + + let targetFolder: TreeNode | null = null; + + if (targetFolderId !== workspace.rootNode.id) { + // Look for the folder in expanded nodes + const foundNode = this.props.expandedNodes.find( + (node) => node.id === targetFolderId + ); + if (foundNode) { + targetFolder = foundNode; + } + } + // If targetFolderId is root or not found in expanded nodes, 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" @@ -1133,6 +1176,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} @@ -1193,6 +1237,8 @@ class WorkspacesUnconnected extends React.Component { "CanPerformAdminOperations", )} clearFocus={this.clearFocus} + droppedFiles={this.state.droppedFiles} + onClearDroppedFiles={this.onClearDroppedFiles} />
{this.renderFolderTree(this.props.currentWorkspace)} From 2d89fc28962dfc4dea7ff1dff3429d549981056e Mon Sep 17 00:00:00 2001 From: hoyla Date: Sun, 15 Feb 2026 11:00:39 +0000 Subject: [PATCH 2/9] style: Apply prettier formatting --- frontend/src/js/components/Uploads/dropZoneUtils.ts | 4 ++-- frontend/src/js/components/workspace/Workspaces.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/js/components/Uploads/dropZoneUtils.ts b/frontend/src/js/components/Uploads/dropZoneUtils.ts index 93fd8b58..1865b70c 100644 --- a/frontend/src/js/components/Uploads/dropZoneUtils.ts +++ b/frontend/src/js/components/Uploads/dropZoneUtils.ts @@ -15,7 +15,7 @@ import { * @returns A Map of file paths to File objects */ export async function readFilesFromDragEvent( - e: React.DragEvent + e: React.DragEvent, ): Promise> { const files = new Map(); @@ -28,7 +28,7 @@ export async function readFilesFromDragEvent( files.set(file.name, file as File); } else if (entry && entry.isDirectory) { const directoryFiles = await readDirectoryEntry( - entry as FileSystemDirectoryEntry + entry as FileSystemDirectoryEntry, ); for (const [path, file] of directoryFiles) { diff --git a/frontend/src/js/components/workspace/Workspaces.tsx b/frontend/src/js/components/workspace/Workspaces.tsx index a07d184c..5dce6cff 100644 --- a/frontend/src/js/components/workspace/Workspaces.tsx +++ b/frontend/src/js/components/workspace/Workspaces.tsx @@ -775,7 +775,7 @@ class WorkspacesUnconnected extends React.Component { if (targetFolderId !== workspace.rootNode.id) { // Look for the folder in expanded nodes const foundNode = this.props.expandedNodes.find( - (node) => node.id === targetFolderId + (node) => node.id === targetFolderId, ); if (foundNode) { targetFolder = foundNode; From ff3ff8ea459ed5dd6a1746452659af62f92f10cc Mon Sep 17 00:00:00 2001 From: hoyla Date: Sun, 15 Feb 2026 11:07:40 +0000 Subject: [PATCH 3/9] fix: Add missing TreeNode import --- frontend/src/js/components/workspace/Workspaces.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/js/components/workspace/Workspaces.tsx b/frontend/src/js/components/workspace/Workspaces.tsx index 5dce6cff..2547aa56 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, From 129d264a59bd841e56c115f6eef6a24e07fffe28 Mon Sep 17 00:00:00 2001 From: hoyla Date: Sun, 15 Feb 2026 11:15:00 +0000 Subject: [PATCH 4/9] fix: Destructure props for useEffect dependencies (eslint) --- .../src/js/components/Uploads/UploadFiles.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/frontend/src/js/components/Uploads/UploadFiles.tsx b/frontend/src/js/components/Uploads/UploadFiles.tsx index 5325fdf6..fdc5a790 100644 --- a/frontend/src/js/components/Uploads/UploadFiles.tsx +++ b/frontend/src/js/components/Uploads/UploadFiles.tsx @@ -336,24 +336,27 @@ 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 (props.droppedFiles && props.droppedFiles.files.size > 0) { + if (droppedFiles && droppedFiles.files.size > 0) { // Set the target folder (null means root) - setFocusedWorkspaceFolder(props.droppedFiles.targetFolder); + setFocusedWorkspaceFolder(droppedFiles.targetFolder); // Add the files - dispatch({ type: "Add_Files", files: props.droppedFiles.files }); + dispatch({ type: "Add_Files", files: droppedFiles.files }); // Open the modal setOpen(true); // Clear the dropped files prop - if (props.onClearDroppedFiles) { - props.onClearDroppedFiles(); + if (onClearDroppedFiles) { + onClearDroppedFiles(); } } - }, [props.droppedFiles, props.onClearDroppedFiles]); + }, [droppedFiles, onClearDroppedFiles]); async function onSubmit() { const { username, workspace, collections, getResource } = props; From 15e43107eebc9b07e40e59359a16a82b159836e3 Mon Sep 17 00:00:00 2001 From: hoyla Date: Mon, 2 Mar 2026 15:51:05 +0000 Subject: [PATCH 5/9] fix: Loop readEntries() to handle batched directory reads Chrome and Safari may return at most ~100 entries per readEntries() call. The spec requires calling it repeatedly until an empty array is returned. Without this, dropping a Finder folder with >100 files would silently lose everything after the first batch. --- .../js/components/Uploads/FileApiHelpers.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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(); }); } From efea5a027d69cc02e8cde100983eb937744dcd37 Mon Sep 17 00:00:00 2001 From: hoyla Date: Mon, 2 Mar 2026 15:53:59 +0000 Subject: [PATCH 6/9] fix: Harden drag-and-drop file upload - Tighten file detection: exclude drags containing application/json to prevent internal tree drags being misidentified as file drops - Add .catch() to readFilesFromDragEvent in TreeBrowser to handle errors (permissions, broken symlinks) instead of swallowing them - Fix collapsed folder drop target: search workspace tree recursively instead of only expandedNodes, so drops onto collapsed folders go to the correct folder rather than silently falling back to root - Remove unused readingDroppedFiles state from TreeBrowser - Remove unused dragEventContainsInternalData export from dropZoneUtils --- .../js/components/Uploads/dropZoneUtils.ts | 18 +++++-------- .../UtilComponents/TreeBrowser/index.tsx | 19 ++++++------- .../js/components/workspace/Workspaces.tsx | 27 ++++++++++++++----- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/frontend/src/js/components/Uploads/dropZoneUtils.ts b/frontend/src/js/components/Uploads/dropZoneUtils.ts index 1865b70c..af56f331 100644 --- a/frontend/src/js/components/Uploads/dropZoneUtils.ts +++ b/frontend/src/js/components/Uploads/dropZoneUtils.ts @@ -53,9 +53,14 @@ export async function readFilesFromDragEvent( * 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 + * @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; @@ -70,14 +75,3 @@ export function dragEventContainsFiles(e: React.DragEvent): boolean { return false; } - -/** - * Checks if a drag event contains only internal application data (e.g., items - * being moved within the workspace tree). - * - * @param e - The React drag event - * @returns true if the drag event contains internal app data - */ -export function dragEventContainsInternalData(e: React.DragEvent): boolean { - return e.dataTransfer.types.includes("application/json"); -} diff --git a/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx b/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx index 040dbdd9..8fc0ba04 100644 --- a/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx +++ b/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx @@ -55,7 +55,6 @@ type State = { draggingColumn: string | null; initialX: number | null; hoveredOver: boolean; - readingDroppedFiles: boolean; }; export default class TreeBrowser extends React.Component, State> { @@ -68,7 +67,6 @@ export default class TreeBrowser extends React.Component, State> { draggingColumn: null, initialX: null, hoveredOver: false, - readingDroppedFiles: false, }; } @@ -187,14 +185,17 @@ export default class TreeBrowser extends React.Component, State> { // Prevent React from reusing the event since readFilesFromDragEvent is asynchronous e.persist(); - this.setState({ readingDroppedFiles: true, hoveredOver: false }); + this.setState({ hoveredOver: false }); - readFilesFromDragEvent(e).then((files) => { - if (files.size > 0 && this.props.onDropFiles) { - this.props.onDropFiles(files, idOfLocationToMoveTo); - } - this.setState({ readingDroppedFiles: 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); + }); return; } diff --git a/frontend/src/js/components/workspace/Workspaces.tsx b/frontend/src/js/components/workspace/Workspaces.tsx index 2547aa56..a226d249 100644 --- a/frontend/src/js/components/workspace/Workspaces.tsx +++ b/frontend/src/js/components/workspace/Workspaces.tsx @@ -83,6 +83,21 @@ 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 & RouteComponentProps<{ id: string; workspaceLocation: string }>; @@ -767,22 +782,22 @@ class WorkspacesUnconnected extends React.Component { * Sets the droppedFiles state which triggers the upload modal to open with the files pre-populated. */ onDropFiles = (files: Map, targetFolderId: string) => { - // Find the target folder node from expandedNodes or check if it's the root const workspace = this.props.currentWorkspace; if (!workspace) return; let targetFolder: TreeNode | null = null; if (targetFolderId !== workspace.rootNode.id) { - // Look for the folder in expanded nodes - const foundNode = this.props.expandedNodes.find( - (node) => node.id === targetFolderId, - ); + // 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 in expanded nodes, targetFolder stays null (uploads to root) + // If targetFolderId is root or not found, targetFolder stays null (uploads to root) this.setState({ droppedFiles: { From 89aa2281887dbcdc97432605d357d404b2e42499 Mon Sep 17 00:00:00 2001 From: hoyla Date: Mon, 2 Mar 2026 16:39:33 +0000 Subject: [PATCH 7/9] Fix drag-and-drop losing items when dropping multiple folders or mixed selections Collect all FileSystemEntry references synchronously from the DataTransfer before any async work. The browser invalidates the DataTransfer after the synchronous event handler returns, so awaiting inside the iteration loop caused items beyond the first to be silently lost. Also removes redundant double-call to webkitGetAsEntry() per item. --- .../js/components/Uploads/dropZoneUtils.ts | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/frontend/src/js/components/Uploads/dropZoneUtils.ts b/frontend/src/js/components/Uploads/dropZoneUtils.ts index af56f331..909d9cb2 100644 --- a/frontend/src/js/components/Uploads/dropZoneUtils.ts +++ b/frontend/src/js/components/Uploads/dropZoneUtils.ts @@ -17,34 +17,49 @@ import { export async function readFilesFromDragEvent( e: React.DragEvent, ): Promise> { - const files = new Map(); + // 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) { - 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, - ); + const entry = item.webkitGetAsEntry(); - for (const [path, file] of directoryFiles) { - files.set(path, file as File); - } - } + if (entry) { + entries.push(entry); } else { // Fallback for browsers that don't support webkitGetAsEntry const file = item.getAsFile(); if (file) { - files.set(file.name, file); + fallbackFiles.push(file); + } + } + } + + // Now process collected entries asynchronously + const files = new Map(); + + for (const entry of entries) { + if (entry.isFile) { + const file = await readFileEntry(entry as FileSystemFileEntry); + files.set(file.name, file as File); + } else if (entry.isDirectory) { + const directoryFiles = await readDirectoryEntry( + entry as FileSystemDirectoryEntry, + ); + + for (const [path, file] of directoryFiles) { + files.set(path, file as File); } } } + for (const file of fallbackFiles) { + files.set(file.name, file); + } + return files; } From a5e32a465d6f1a8f97825481738d8ba9b43a047c Mon Sep 17 00:00:00 2001 From: hoyla Date: Tue, 3 Mar 2026 22:49:46 +0000 Subject: [PATCH 8/9] fix: Restrict drag-and-drop to single folder or flat file selection Reject mixed selections (folders + files, or multiple folders) which caused duplicate entries due to how the OS includes both a folder and its visible children as separate DataTransfer items. Now only two shapes are accepted: 1. A single directory (full hierarchy preserved) 2. One or more files with no directories Show an alert to the user when an unsupported selection is dropped. --- .../src/js/components/Uploads/FilePicker.tsx | 15 +++-- .../js/components/Uploads/dropZoneUtils.ts | 55 ++++++++++++++----- .../UtilComponents/TreeBrowser/index.tsx | 1 + 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/frontend/src/js/components/Uploads/FilePicker.tsx b/frontend/src/js/components/Uploads/FilePicker.tsx index 5d045004..f480bc54 100644 --- a/frontend/src/js/components/Uploads/FilePicker.tsx +++ b/frontend/src/js/components/Uploads/FilePicker.tsx @@ -96,10 +96,17 @@ export default class FilePicker extends React.Component { e.persist(); this.setState({ readingFiles: true }); - readFilesFromDragEvent(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/dropZoneUtils.ts b/frontend/src/js/components/Uploads/dropZoneUtils.ts index 909d9cb2..d7867f56 100644 --- a/frontend/src/js/components/Uploads/dropZoneUtils.ts +++ b/frontend/src/js/components/Uploads/dropZoneUtils.ts @@ -11,8 +11,18 @@ import { * 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, @@ -38,26 +48,45 @@ export async function readFilesFromDragEvent( } } + 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(); - for (const entry of entries) { - if (entry.isFile) { + 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); - } else if (entry.isDirectory) { - const directoryFiles = await readDirectoryEntry( - entry as FileSystemDirectoryEntry, - ); - - for (const [path, file] of directoryFiles) { - files.set(path, file as File); - } } - } - for (const file of fallbackFiles) { - files.set(file.name, file); + for (const file of fallbackFiles) { + files.set(file.name, file); + } } return files; diff --git a/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx b/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx index 8fc0ba04..2ca7a4b7 100644 --- a/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx +++ b/frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx @@ -195,6 +195,7 @@ export default class TreeBrowser extends React.Component, State> { }) .catch((error) => { console.error("Failed to read dropped files:", error); + window.alert(error instanceof Error ? error.message : String(error)); }); return; } From 6968ddf733e61d9b91757d0f5776143794625b44 Mon Sep 17 00:00:00 2001 From: hoyla Date: Tue, 3 Mar 2026 22:53:54 +0000 Subject: [PATCH 9/9] style: Apply prettier formatting --- frontend/src/js/components/Uploads/dropZoneUtils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/js/components/Uploads/dropZoneUtils.ts b/frontend/src/js/components/Uploads/dropZoneUtils.ts index d7867f56..5c21b341 100644 --- a/frontend/src/js/components/Uploads/dropZoneUtils.ts +++ b/frontend/src/js/components/Uploads/dropZoneUtils.ts @@ -59,7 +59,10 @@ export async function readFilesFromDragEvent( ); } - if (directoryEntries.length === 1 && (fileEntries.length > 0 || fallbackFiles.length > 0)) { + 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.", );