/* Copyright 2026 Marimo. All rights reserved. */ import { MousePointerSquareDashedIcon, Upload } from "lucide-react"; import type { JSX } from "react"; import { useDropzone } from "react-dropzone"; import { z } from "zod"; import { Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/use-toast"; import { cn } from "@/utils/cn"; import { Logger } from "@/utils/Logger"; import { buttonVariants } from "../../components/ui/button"; import { filesToBase64 } from "../../utils/fileToBase64"; import { renderHTML } from "../core/RenderHTML"; import type { IPlugin, IPluginProps, Setter } from "../types"; type FileUploadType = "button" | "area"; /** * Arguments for a file upload area/button * * @param filetypes - file types to accept (same as HTML input's accept attr) * @param multiple - whether to allow the user to upload multiple files * @param label - a label for the file upload area * @param max_size - the maximum size of the file to upload (in bytes) */ interface Data { filetypes: string[]; multiple: boolean; kind: FileUploadType; label: string | null; max_size: number; } type T = [string, string][]; export class FileUploadPlugin implements IPlugin { tagName = "marimo-file"; validator = z.object({ filetypes: z.array(z.string()), multiple: z.boolean(), kind: z.enum(["button", "area"]), label: z.string().nullable(), max_size: z.number(), }); render(props: IPluginProps): JSX.Element { return ( ); } } /** * @param value - array of (filename, filecontents) tuples; filecontents should * be b64 encoded. * @param setValue - communicate file upload */ interface FileUploadProps extends Data { value: T; setValue: Setter; } function groupFileTypesByMIMEType(extensions: string[]) { const filesByMIMEType: Record = {}; const appendExt = (mimetype: string, extension: string) => { if (Object.hasOwn(filesByMIMEType, mimetype)) { filesByMIMEType[mimetype].push(extension); } else { filesByMIMEType[mimetype] = [extension]; } }; extensions.forEach((extension) => { switch (extension) { case ".png": case ".jpg": case ".jpeg": case ".gif": case ".avif": case ".bmp": case ".ico": case ".svg": case ".tiff": case ".webp": appendExt("image/*", extension); break; case ".avi": case ".mp4": case ".mpeg": case ".ogg": case ".webm": appendExt("video/*", extension); break; case ".pdf": appendExt("application/pdf", extension); break; case ".csv": appendExt("text/csv", extension); break; default: appendExt("text/plain", extension); } }); return filesByMIMEType; } /* TODO(akshayka): Allow uploading files one-by-one and removing uploaded files * when multiple is `True`*/ export const FileUpload = (props: FileUploadProps): JSX.Element => { const acceptGroups = groupFileTypesByMIMEType(props.filetypes); const { setValue, kind, multiple, value, max_size } = props; const { getRootProps, getInputProps, isFocused, isDragAccept, isDragReject } = useDropzone({ accept: acceptGroups, multiple: multiple, maxSize: max_size, 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: (acceptedFiles) => { filesToBase64(acceptedFiles) .then((value) => { setValue(value); }) .catch((error) => { Logger.error(error); toast({ title: "File upload failed", description: "Failed to convert file to base64.", variant: "danger", }); }); }, }); const uploadedFiles = (
    {value.map(([fileName]) => (
  • {fileName}
  • ))}
); const uploaded = value.length > 0; if (kind === "button") { // TODO(akshayka): React to a change in `value` due to an update from another // instance of this element. Browsers do not allow scripts to set the `value` // on a file input element. // One way to do this: // - hide the input element with a hidden attribute // - create a button and some text that reflects what has been uploaded; // link button to the hidden input element const label = props.label ?? "Upload"; return (
{uploaded ? ( <> Uploaded{" "} {value.length} {value.length === 1 ? "file" : "files"}. ) : null}
); } const label = props.label ?? `Drag and drop ${multiple ? "files" : "a file"} here, or click to open file browser`; return (
{uploaded ? ( To re-upload: {renderHTML({ html: label })} ) : ( {renderHTML({ html: label })} )}
{uploaded ? (
Uploaded{" "} {value.length} {value.length === 1 ? "file" : "files"}.
) : null}
); };