/* Copyright 2026 Marimo. All rights reserved. */ import { type DropEvent, type DropzoneOptions, useDropzone, } from "react-dropzone"; import { toast } from "@/components/ui/use-toast"; import { useRequestClient } from "@/core/network/requests"; import type { FileCreateInput, FileCreateResponse } from "@/core/network/types"; import { withLoadingToast } from "@/utils/download"; import { prettyError } from "@/utils/errors"; import { Logger } from "@/utils/Logger"; import { type FilePath, PathBuilder, Paths } from "@/utils/paths"; import { mapWithConcurrency } from "@/utils/semaphore"; const MAX_SIZE = 1024 * 1024 * 100; // 100MB const UPLOAD_CONCURRENCY = 5; export const FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE = "data-file-explorer-directory-path"; type DestinationPath = FilePath | ((event: DropEvent) => FilePath); interface FileExplorerUploadOptions extends Omit< DropzoneOptions, "onDrop" | "onDropRejected" | "onError" > { destinationPath: DestinationPath; getDestinationLabel?: (path: FilePath) => string; onUploadStart?: (destinationPath: FilePath, files: File[]) => void; refreshDestination: (destinationPath: FilePath) => Promise; } interface UploadFileResult { file: File; response: FileCreateResponse; } export interface UploadFilesResult { successful: UploadFileResult[]; failed: UploadFileResult[]; } export function useFileExplorerUpload(options: FileExplorerUploadOptions) { const { destinationPath, getDestinationLabel = (path) => path, onUploadStart, refreshDestination, ...dropzoneOptions } = options; const { sendCreateFileOrFolder } = useRequestClient(); return useDropzone({ multiple: true, maxSize: MAX_SIZE, ...dropzoneOptions, onError: (error) => { Logger.error(error); toast({ title: "File upload failed", description: error.message, variant: "danger", }); }, onDropRejected: (rejectedFiles) => { toast({ title: "File upload failed", description: (
{rejectedFiles.map((file) => (
{file.file.name} ({file.errors.map((e) => e.message).join(", ")} )
))}
), variant: "danger", }); }, onDrop: async (acceptedFiles, _rejectedFiles, event) => { if (acceptedFiles.length === 0) { return; } const resolvedDestinationPath = typeof destinationPath === "function" ? destinationPath(event) : destinationPath; const destinationLabel = getDestinationLabel(resolvedDestinationPath); const isSingle = acceptedFiles.length === 1; const loadingTitle = isSingle ? `Uploading file to ${destinationLabel}...` : `Uploading files to ${destinationLabel}...`; onUploadStart?.(resolvedDestinationPath, acceptedFiles); let result: UploadFilesResult; try { result = await withLoadingToast(loadingTitle, async (progress) => { progress.addTotal(acceptedFiles.length); return uploadFilesToDestination({ files: acceptedFiles, destinationPath: resolvedDestinationPath, createFile: sendCreateFileOrFolder, onFileProcessed: () => progress.increment(1), }); }); } catch { await refreshDestination(resolvedDestinationPath); return; } await refreshDestination(resolvedDestinationPath); showUploadResultToast(result, destinationLabel); }, }); } export async function uploadFilesToDestination({ files, destinationPath, createFile, onFileProcessed, }: { files: File[]; destinationPath: FilePath; createFile: (request: FileCreateInput) => Promise; onFileProcessed?: () => void; }): Promise { const results = await mapWithConcurrency( files, UPLOAD_CONCURRENCY, async (file): Promise => { try { const filePath = stripLeadingSlash(getPath(file)); const directoryPath = resolveUploadDirectoryPath({ destinationPath, filePath, }); const response = await createFile({ path: directoryPath, type: "file", name: file.name, file, }); return { file, response }; } catch (error) { return { file, response: { success: false, message: prettyError(error) }, }; } finally { onFileProcessed?.(); } }, ); return { successful: results.filter(({ response }) => response.success), failed: results.filter(({ response }) => !response.success), }; } export function resolveUploadDirectoryPath({ destinationPath, filePath, }: { destinationPath: FilePath; filePath: FilePath | undefined; }): FilePath { if (!filePath) { return destinationPath; } if (Paths.isAbsolute(filePath)) { throw new Error(`Upload path must be relative: ${filePath}`); } const pathParts = filePath.split(/[\\/]+/); const relativeDirectoryParts = pathParts.slice(0, -1).filter(Boolean); if (relativeDirectoryParts.includes("..")) { throw new Error(`Upload path cannot contain parent traversal: ${filePath}`); } const normalizedDirectoryParts = relativeDirectoryParts.filter( (part) => part !== ".", ); if (normalizedDirectoryParts.length === 0) { return destinationPath; } const destinationPathBuilder = PathBuilder.guessDeliminator(destinationPath); const normalizedRelativePath = normalizedDirectoryParts.join( destinationPathBuilder.deliminator, ); return destinationPathBuilder.join(destinationPath, normalizedRelativePath); } export function getUploadDestinationFromTarget( target: EventTarget | null, rootPath: FilePath, ): FilePath { if (!(target instanceof Element)) { return rootPath; } const directory = target.closest( `[${FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE}]`, ); const path = directory?.getAttribute(FILE_EXPLORER_DIRECTORY_PATH_ATTRIBUTE); return path ? (path as FilePath) : rootPath; } function showUploadResultToast( result: UploadFilesResult, destinationLabel: string, ) { const total = result.successful.length + result.failed.length; if (result.failed.length > 0) { let title: string; if (result.successful.length === 0) { title = total === 1 ? "File upload failed" : "Files failed to upload"; } else { title = `${result.successful.length} of ${total} files uploaded`; } toast({ title, description: (
Destination: {destinationLabel}.
{result.failed.map(({ file, response }, index) => (
{file.name}:{" "} {response.message || "The server rejected the upload."}
))}
), variant: "danger", }); return; } const renamedFiles = result.successful.filter( ({ file, response }) => response.info?.name && response.info.name !== file.name, ); toast({ title: total === 1 ? "File uploaded" : `${total} files uploaded`, description: renamedFiles.length === 0 ? ( `Uploaded to ${destinationLabel}.` ) : (
Uploaded to {destinationLabel}.
{renamedFiles.map(({ file, response }, index) => (
{file.name} was saved as {response.info?.name}.
))}
), }); } /** * Get the path of a file. * * Types only have `webkitRelativePath`, but File objects in the browser * can have `path` and `relativePath`. */ function getPath(file: File): FilePath | undefined { if (file.webkitRelativePath) { return file.webkitRelativePath as FilePath; } if ("path" in file && typeof file.path === "string") { return file.path as FilePath; } if ("relativePath" in file && typeof file.relativePath === "string") { return file.relativePath as FilePath; } return undefined; } /** * Strip leading slashes from a path. * * TODO: this may not support windows paths. */ function stripLeadingSlash(path: FilePath | undefined): FilePath | undefined { if (!path) { return undefined; } return path.replace(/^\/+/, "") as FilePath; }