/* Copyright 2026 Marimo. All rights reserved. */ import { useAtomValue } from "jotai"; import { AlertCircleIcon, ChevronRightIcon, ClipboardCopyIcon, DownloadIcon, InfoIcon, } from "lucide-react"; import React from "react"; import { downloadSizeLimitAtom } from "./download-policy/atoms"; import type { DownloadAsArgs, DownloadAsOptions, DownloadFormat, } from "./schemas"; import { logNever } from "@/utils/assertNever"; import { cn } from "@/utils/cn"; import { copyToClipboard } from "@/utils/copy"; import { downloadByURL, withLoadingToast } from "@/utils/download"; import { prettyError } from "@/utils/errors"; import { Filenames } from "@/utils/filenames"; import { jsonParseWithSpecialChar, jsonToMarkdown, } from "@/utils/json/json-parser"; import { MissingPackagePrompt } from "../datasources/missing-package-prompt"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Button } from "../ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "../ui/dialog"; import { Label } from "../ui/label"; import { NativeSelect } from "../ui/native-select"; import { Switch } from "../ui/switch"; import { Tooltip } from "../ui/tooltip"; import { toast } from "../ui/use-toast"; const EXPORT_OPTIONS = [ { label: "CSV", format: "csv", description: "Comma-separated values", canDownload: true, canCopy: true, }, { label: "TSV", format: "tsv", description: "Best for Excel and Google Sheets", canDownload: true, canCopy: true, }, { label: "JSON", format: "json", description: "Raw JSON data", canDownload: true, canCopy: true, }, { label: "Parquet", format: "parquet", description: "Columnar binary format", canDownload: true, canCopy: false, }, { label: "Markdown", format: "markdown", description: "Preserves hyperlinks and formatting", canDownload: false, canCopy: true, }, ] as const; type ExportFormat = (typeof EXPORT_OPTIONS)[number]["format"]; type CopyFormat = Exclude; type ExportAction = | { destination: "download"; format: DownloadFormat } | { destination: "copy"; format: CopyFormat }; type ExportFailure = | { kind: "error"; title: string; description: string; action: ExportAction; } | { kind: "missing-packages"; title: string; description?: string | null; packages: string[]; featureName: string; action: ExportAction; }; // Each clipboard-copy format fetches from a backend download format, then // transforms the payload client-side as needed. const COPY_SOURCE_FORMAT: Record = { csv: "csv", tsv: "tsv", json: "json", markdown: "json", }; const SEPARATORS = [ { value: ",", label: "Comma" }, { value: ";", label: "Semicolon" }, { value: "|", label: "Pipe" }, ] as const; const ENCODINGS = [ { value: "utf-8", label: "UTF-8" }, { value: "utf-8-sig", label: "UTF-8 with BOM" }, { value: "latin-1", label: "Latin-1" }, ] as const; type Separator = (typeof SEPARATORS)[number]["value"]; type Encoding = (typeof ENCODINGS)[number]["value"]; interface ExportSettings { csv: { separator: Separator; encoding: Encoding }; tsv: { encoding: Encoding }; json: { ensureAscii: boolean }; } const DEFAULT_SETTINGS: ExportSettings = { csv: { separator: ",", encoding: "utf-8" }, tsv: { encoding: "utf-8" }, json: { ensureAscii: true }, }; type ConfigurableFormat = keyof ExportSettings; const isConfigurable = (format: ExportFormat): format is ConfigurableFormat => format in DEFAULT_SETTINGS; /** * Build the request options for one action. Only settings that differ from * the defaults are sent, so the backend keeps its own defaults otherwise. * * Clipboard copies never send an encoding. The browser decodes the fetched * payload as UTF-8, so any other encoding would corrupt the copied text. */ function requestOptions( settings: ExportSettings, format: DownloadFormat, destination: ExportAction["destination"], ): DownloadAsOptions | undefined { const options: DownloadAsOptions = {}; switch (format) { case "csv": if (settings.csv.separator !== DEFAULT_SETTINGS.csv.separator) { options.separator = settings.csv.separator; } if ( destination === "download" && settings.csv.encoding !== DEFAULT_SETTINGS.csv.encoding ) { options.encoding = settings.csv.encoding; } break; case "tsv": if ( destination === "download" && settings.tsv.encoding !== DEFAULT_SETTINGS.tsv.encoding ) { options.encoding = settings.tsv.encoding; } break; case "json": if (settings.json.ensureAscii !== DEFAULT_SETTINGS.json.ensureAscii) { options.ensure_ascii = settings.json.ensureAscii; } break; case "parquet": break; default: logNever(format); } return Object.keys(options).length > 0 ? options : undefined; } const labelForValue = ( items: ReadonlyArray<{ value: string; label: string }>, value: string, ): string => items.find((item) => item.value === value)?.label ?? value; /** Short summary of the current settings, shown next to the format label. */ function settingsSummary( settings: ExportSettings, format: ExportFormat, ): string | null { switch (format) { case "csv": return [ labelForValue(SEPARATORS, settings.csv.separator), labelForValue(ENCODINGS, settings.csv.encoding), ].join(" · "); case "tsv": return labelForValue(ENCODINGS, settings.tsv.encoding); case "json": return settings.json.ensureAscii ? "Escapes non-ASCII" : "Keeps non-ASCII"; case "parquet": case "markdown": return null; default: logNever(format); return null; } } export interface ExportActionProps { downloadAs: DownloadAsArgs; // JSON-serialized size of the currently-rendered data. Used together with // downloadSizeLimitAtom to disable the Export button when a host (e.g., // marimo-lsp inside VS Code) declares a download size cap. Null/undefined // means "no info" and the gate stays disabled (fail-open). sizeBytes?: number | null; sizeBytesIsLoading?: boolean; } const labelForFormat = (format: ExportFormat): string => EXPORT_OPTIONS.find((option) => option.format === format)?.label ?? format; const failureTitleForAction = (action: ExportAction): string => action.destination === "download" ? "Failed to download" : "Failed to copy to clipboard"; const failureDescription = (error: unknown): string => typeof error === "string" ? error : prettyError(error); export const ExportActions: React.FC = (props) => { const [exportDialogOpen, setExportDialogOpen] = React.useState(false); const [failure, setFailure] = React.useState(null); const [settings, setSettings] = React.useState(DEFAULT_SETTINGS); const [expandedFormat, setExpandedFormat] = React.useState(null); const triggerRef = React.useRef(null); const dialogRef = React.useRef(null); const latestActionId = React.useRef(0); const policy = useAtomValue(downloadSizeLimitAtom); const overLimit = !!( policy && props.sizeBytes != null && props.sizeBytes > policy.limitBytes ); const disabled = !!(policy && (props.sizeBytesIsLoading || overLimit)); const tooltipContent = !disabled ? "Export" : props.sizeBytesIsLoading ? "Checking download size…" : policy?.unavailableMessage; const button = ( ); const handleDialogOpenChange = (open: boolean) => { setExportDialogOpen(open); if (!open) { latestActionId.current += 1; setFailure(null); setExpandedFormat(null); setSettings(DEFAULT_SETTINGS); } }; const beginAction = () => { const actionId = latestActionId.current + 1; latestActionId.current = actionId; setFailure(null); return actionId; }; const setActionFailure = (actionId: number, nextFailure: ExportFailure) => { if (actionId === latestActionId.current) { setFailure(nextFailure); } }; const resolveDownloadUrl = async ( format: DownloadFormat, action: ExportAction, actionId: number, ): Promise<{ url: string; filename: string; } | null> => { const options = requestOptions(settings, format, action.destination); const response = await props.downloadAs( options ? { format, options } : { format }, ); if (response.missing_packages && response.missing_packages.length > 0) { setActionFailure(actionId, { kind: "missing-packages", title: "Export failed", packages: response.missing_packages, featureName: `${labelForFormat(action.format)} export`, description: response.error, action, }); return null; } if (response.error) { setActionFailure(actionId, { kind: "error", title: failureTitleForAction(action), description: response.error, action, }); return null; } return { url: response.url, filename: response.filename, }; }; const handleDownload = async (format: DownloadFormat) => { const action: ExportAction = { destination: "download", format }; const actionId = beginAction(); const label = labelForFormat(format); try { const ok = await withLoadingToast( `Preparing ${label} export...`, async () => { const result = await resolveDownloadUrl(format, action, actionId); if (!result) { return false; } const rawName = (result.filename ?? "").trim(); const baseName = Filenames.withoutExtension(rawName) || "download"; const downloadName = `${baseName}.${format}`; // Append ?download=1 so the server returns Content-Disposition: attachment. // This forces a save even when is ignored — e.g., inside // sandboxed iframes that lack `allow-downloads`. Skip for data: URLs // (used in pyodide/wasm) since query params would corrupt the payload. let downloadUrl = result.url; if (!downloadUrl.startsWith("data:")) { const separator = downloadUrl.includes("?") ? "&" : "?"; const params = new URLSearchParams({ download: "1", filename: downloadName, }); downloadUrl = `${downloadUrl}${separator}${params.toString()}`; } downloadByURL(downloadUrl, downloadName); return true; }, ); if (ok) { toast({ title: `${label} download started` }); } } catch (error) { setActionFailure(actionId, { kind: "error", title: failureTitleForAction(action), description: failureDescription(error), action, }); } }; const handleClipboardCopy = async ( format: CopyFormat, action: ExportAction, actionId: number, ) => { await withLoadingToast( `Preparing ${labelForFormat(format)} for clipboard...`, async () => { const sourceFormat = COPY_SOURCE_FORMAT[format]; const result = await resolveDownloadUrl(sourceFormat, action, actionId); if (!result) { return; } let text: string; switch (format) { case "tsv": case "csv": text = await fetchText(result.url); break; case "json": { const json = await fetchJson(result.url); text = JSON.stringify(json, null, 2); 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", }); handleDialogOpenChange(false); }, ); }; const handleCopyAction = async (format: CopyFormat) => { const action: ExportAction = { destination: "copy", format }; const actionId = beginAction(); try { await handleClipboardCopy(format, action, actionId); } catch (error) { setActionFailure(actionId, { kind: "error", title: failureTitleForAction(action), description: failureDescription(error), action, }); } }; const retryAction = (action: ExportAction) => { if (action.destination === "download") { void handleDownload(action.format); } else { void handleCopyAction(action.format); } }; const toggleExpanded = (format: ExportFormat) => { if (!isConfigurable(format)) { return; } setExpandedFormat((current) => (current === format ? null : format)); }; return ( {disabled ? ( // Keep the host-limit reason reachable when the nested button is disabled. // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex {button} ) : ( {button} )} { event.preventDefault(); dialogRef.current?.focus(); }} onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }} onKeyDown={(event) => { if (event.key === "Escape") { handleDialogOpenChange(false); } }} > Export table Download a file or copy to the clipboard.
    {EXPORT_OPTIONS.map((option) => { const configurableFormat = isConfigurable(option.format) ? option.format : null; const expanded = configurableFormat !== null && expandedFormat === configurableFormat; const panelId = `export-options-${option.format}`; const rowFailure = failure?.action.format === option.format ? failure : null; const summary = settingsSummary(settings, option.format); return (
  • {configurableFormat ? ( ) : ( )}
    {option.label} {summary && ( {summary} )} {option.description}
    {expanded && configurableFormat && (
    {option.label} options
    )} {rowFailure && (
    {rowFailure.title} {rowFailure.kind === "missing-packages" ? ( retryAction(rowFailure.action)} className="items-start" /> ) : ( rowFailure.description )}
    )}
  • ); })}
); }; interface ExportSettingsFieldsProps { format: ConfigurableFormat; settings: ExportSettings; onChange: React.Dispatch>; } const ExportSettingsFields: React.FC = ({ format, settings, onChange, }) => { const id = React.useId(); const encodingField = (encoding: Encoding, key: "csv" | "tsv") => ( { const next = event.target.value as Encoding; onChange((current) => ({ ...current, [key]: { ...current[key], encoding: next }, })); }} > {ENCODINGS.map((item) => ( ))} ); switch (format) { case "csv": return ( <> { const next = event.target.value as Separator; onChange((current) => ({ ...current, csv: { ...current.csv, separator: next }, })); }} > {SEPARATORS.map((item) => ( ))} {encodingField(settings.csv.encoding, "csv")} ); case "tsv": return encodingField(settings.tsv.encoding, "tsv"); case "json": return ( { onChange((current) => ({ ...current, json: { ensureAscii: checked }, })); }} /> ); default: logNever(format); return null; } }; interface SettingFieldProps { id: string; label: string; help: string; children: React.ReactNode; } const SettingField: React.FC = ({ id, label, help, children, }) => (
{children}
); 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(); }); }