Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions frontend/src/js/components/Uploads/FileApiHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
56 changes: 13 additions & 43 deletions frontend/src/js/components/Uploads/FilePicker.tsx
Original file line number Diff line number Diff line change
@@ -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<Map<string, File>> {
const files = new Map<string, 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,
);

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;
Expand Down Expand Up @@ -129,14 +92,21 @@ export default class FilePicker extends React.Component<Props, State> {
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() {
Expand Down
35 changes: 34 additions & 1 deletion frontend/src/js/components/Uploads/UploadFiles.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, File>;
/** The target folder node where files should be uploaded, or null for root */
targetFolder: TreeNode<WorkspaceEntry> | null;
};

type Props = {
username: string;
workspace: Workspace;
Expand All @@ -62,6 +69,10 @@ type Props = {
focusedWorkspaceEntry: TreeEntry<WorkspaceEntry> | null;
expandedNodes?: TreeNode<WorkspaceEntry>[];
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 = {
Expand Down Expand Up @@ -325,6 +336,28 @@ export default function UploadFiles(props: Props) {
const [focusedWorkspaceFolder, setFocusedWorkspaceFolder] =
useState<TreeNode<WorkspaceEntry> | 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) {
Expand Down
124 changes: 124 additions & 0 deletions frontend/src/js/components/Uploads/dropZoneUtils.ts
Original file line number Diff line number Diff line change
@@ -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<Map<string, File>> {
// 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<string, File>();

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;
}
41 changes: 35 additions & 6 deletions frontend/src/js/components/UtilComponents/TreeBrowser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
onSelectLeaf: (leaf: TreeLeaf<T>) => void;
Expand All @@ -42,6 +46,8 @@ type Props<T> = {
onExpandNode: (entry: TreeNode<T>) => void;
onCollapseNode: (entry: TreeNode<T>) => void;
onContextMenu: (e: React.MouseEvent, entry: TreeEntry<T>) => void;
/** Optional callback for handling file drops from the file system */
onDropFiles?: (files: Map<string, File>, targetFolderId: string) => void;
};

type State = {
Expand Down Expand Up @@ -174,13 +180,36 @@ export default class TreeBrowser<T> extends React.Component<Props<T>, 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,
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/js/components/workspace/WorkspaceSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -43,6 +43,10 @@ type Props = {
expandedNodes: TreeNode<WorkspaceEntry>[];
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({
Expand All @@ -61,6 +65,8 @@ export default function WorkspaceSummary({
expandedNodes,
isAdmin,
clearFocus,
droppedFiles,
onClearDroppedFiles,
}: Props) {
const [isShowingMoreOptions, setIsShowingMoreOptions] = useState(false);

Expand Down Expand Up @@ -126,6 +132,8 @@ export default function WorkspaceSummary({
focusedWorkspaceEntry={focusedEntry}
expandedNodes={expandedNodes}
isAdmin={isAdmin}
droppedFiles={droppedFiles}
onClearDroppedFiles={onClearDroppedFiles}
/>
<CaptureFromUrl maybePreSelectedWorkspace={workspace} withButton />
<div>
Expand Down
Loading