/* Copyright 2026 Marimo. All rights reserved. */ import { BracesIcon, BrickWallIcon, DownloadIcon, FileTextIcon, TableIcon, } from "lucide-react"; import React from "react"; import { useLocale } from "react-aria"; import { logNever } from "@/utils/assertNever"; import { cn } from "@/utils/cn"; import { copyToClipboard } from "@/utils/copy"; import { downloadByURL } from "@/utils/download"; import { prettyError } from "@/utils/errors"; import { Filenames } from "@/utils/filenames"; import { jsonParseWithSpecialChar, jsonToMarkdown, jsonToTSV, } from "@/utils/json/json-parser"; import { MissingPackagePrompt } from "../datasources/missing-package-prompt"; import { Button } from "../ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { Tooltip } from "../ui/tooltip"; import { toast } from "../ui/use-toast"; type DownloadFormat = "csv" | "json" | "parquet"; export interface ExportActionProps { downloadAs: (req: { format: DownloadFormat }) => Promise<{ url: string; filename: string; error?: string | null; missing_packages?: string[] | null; }>; } const FILE_TYPES = { CSV: { label: "CSV", format: "csv", description: "Comma-separated values", icon: TableIcon, }, JSON: { label: "JSON", format: "json", description: "Raw JSON data", icon: BracesIcon, }, PARQUET: { label: "Parquet", format: "parquet", description: "Columnar binary format", icon: BrickWallIcon, }, TSV: { label: "TSV", format: "tsv", description: "Best for Excel and Google Sheets", icon: TableIcon, }, MARKDOWN: { label: "Markdown", format: "markdown", description: "Preserves hyperlinks and formatting", icon: FileTextIcon, }, } as const; const downloadOptions = [FILE_TYPES.CSV, FILE_TYPES.JSON, FILE_TYPES.PARQUET]; const copyOptions = [ FILE_TYPES.TSV, FILE_TYPES.JSON, FILE_TYPES.CSV, FILE_TYPES.MARKDOWN, ]; const labelForDownloadFormat = (format: DownloadFormat): string => downloadOptions.find((opt) => opt.format === format)?.label ?? format; export const ExportMenu: React.FC = (props) => { const { locale } = useLocale(); const [open, setOpen] = React.useState(false); const button = ( ); const resolveDownloadUrl = async ( format: DownloadFormat, onRetry: () => void, ): Promise<{ url: string; filename: string } | null> => { let response: Awaited>; try { response = await props.downloadAs({ format }); } catch (error) { toast({ title: "Failed to download", description: error != null && typeof error === "object" && "message" in error ? String(error.message) : String(error), }); return null; } if (response.missing_packages && response.missing_packages.length > 0) { toast({ title: "Export failed", description: ( ), }); return null; } return { url: response.url, filename: response.filename }; }; const handleDownload = async (format: DownloadFormat) => { const result = await resolveDownloadUrl(format, () => { void handleDownload(format); }); if (!result) { return; } const rawName = (result.filename ?? "").trim(); const baseName = Filenames.withoutExtension(rawName) || "download"; downloadByURL(result.url, `${baseName}.${format}`); }; const handleClipboardCopy = async ( format: (typeof copyOptions)[number]["format"], ) => { const sourceFormat: DownloadFormat = format === "csv" ? "csv" : "json"; const result = await resolveDownloadUrl(sourceFormat, () => { void handleClipboardCopy(format); }); if (!result) { return; } let text: string; switch (format) { case "tsv": { const json = await fetchJson(result.url); text = jsonToTSV(json, locale); break; } case "json": { const json = await fetchJson(result.url); text = JSON.stringify(json, null, 2); break; } case "csv": text = await fetchText(result.url); break; case "markdown": { const json = await fetchJson(result.url); text = jsonToMarkdown(json); break; } default: logNever(format); return; } await copyToClipboard(text); toast({ title: "Copied to clipboard", }); }; return ( {button} Download {downloadOptions.map((option) => ( { void handleDownload(option.format); }} >
{option.label} {option.description}
))} Copy to clipboard {copyOptions.map((option) => ( { try { await handleClipboardCopy(option.format); } catch (error) { toast({ title: "Failed to copy to clipboard", description: prettyError(error), variant: "danger", }); } }} >
{option.label} {option.description}
))}
); }; function fetchJson(url: string): Promise[]> { return fetchText(url).then( jsonParseWithSpecialChar[]>, ); } function fetchText(url: string): Promise { return fetch(url).then((res) => { if (!res.ok) { throw new Error(res.statusText); } return res.text(); }); }