/* Copyright 2026 Marimo. All rights reserved. */ import { useAtomValue } from "jotai"; import { AlertCircleIcon, BracesIcon, BrickWallIcon, ChevronRightIcon, ClipboardCopyIcon, DownloadIcon, FileTextIcon, InfoIcon, MapIcon, TableIcon, } from "lucide-react"; import React from "react"; import { downloadSizeLimitAtom } from "./download-policy/atoms"; import type { DownloadAsArgs, DownloadAsOptions, DownloadFormat, ExportMetadata, GetExportMetadata, } 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", icon: TableIcon, canDownload: true, canCopy: true, }, { label: "TSV", format: "tsv", description: "Best for Excel and Google Sheets", icon: TableIcon, canDownload: true, canCopy: true, }, { label: "JSON", format: "json", description: "Raw JSON data", icon: BracesIcon, canDownload: true, canCopy: true, }, { label: "Parquet", format: "parquet", description: "Columnar binary format", icon: BrickWallIcon, canDownload: true, canCopy: false, }, { label: "GeoJSON", format: "geojson", description: "Geometry in longitude and latitude", icon: MapIcon, canDownload: true, canCopy: true, }, { label: "Markdown", format: "markdown", description: "Preserves hyperlinks and formatting", icon: FileTextIcon, canDownload: false, canCopy: true, }, ] as const; type ExportFormat = (typeof EXPORT_OPTIONS)[number]["format"]; type CopyFormat = Exclude; type GeographicFormat = "parquet" | "geojson"; const isGeographic = (format: ExportFormat): format is GeographicFormat => format === "parquet" || format === "geojson"; type ExportAction = | { destination: "download"; format: DownloadFormat } | { destination: "copy"; format: CopyFormat }; type ExportFailure = | { kind: "error"; title: string; description: string; action: ExportAction; } | { kind: "missing-packages"; description?: string | null; packages: 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", geojson: "geojson", 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 keyof ExportSettings => 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": case "geojson": 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 "geojson": case "markdown": return null; default: logNever(format); return null; } } export interface ExportActionProps { downloadAs: DownloadAsArgs; getExportMetadata?: GetExportMetadata; metadataSource?: unknown; // 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 | DownloadFormat, hasGeometry = false, ): string => format === "parquet" && hasGeometry ? "GeoParquet" : (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 { getExportMetadata, metadataSource } = props; const [exportDialogOpen, setExportDialogOpen] = React.useState(false); const [failure, setFailure] = React.useState(null); const [settings, setSettings] = React.useState(DEFAULT_SETTINGS); const [metadata, setMetadata] = React.useState(null); const [metadataLoading, setMetadataLoading] = React.useState(false); const [metadataError, setMetadataError] = React.useState(null); const [metadataRefresh, setMetadataRefresh] = React.useState(0); const [geometryColumn, setGeometryColumn] = React.useState( null, ); const hasGeometry = !!metadata?.geometry_columns.length; const [expandedFormat, setExpandedFormat] = React.useState< ConfigurableFormat | GeographicFormat | null >(null); const triggerRef = React.useRef(null); const dialogRef = React.useRef(null); const latestActionId = React.useRef(0); React.useEffect(() => { latestActionId.current += 1; }, [metadataSource]); React.useEffect(() => { if (!exportDialogOpen || !getExportMetadata) { return; } let active = true; setMetadata(null); setMetadataLoading(true); setMetadataError(null); void getExportMetadata({}).then( (result) => { if (!active) { return; } setMetadata(result); setGeometryColumn((current) => current !== null && result.geometry_columns.some((column) => column.name === current) ? current : result.default_geometry_column, ); setMetadataLoading(false); }, (error: unknown) => { if (!active) { return; } setMetadataError(failureDescription(error)); setMetadataLoading(false); }, ); return () => { active = false; }; }, [exportDialogOpen, getExportMetadata, metadataSource, metadataRefresh]); 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); setMetadata(null); setMetadataError(null); setGeometryColumn(null); } }; 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 request = { format, ...(options ? { options } : {}), ...(isGeographic(format) && geometryColumn !== null ? { geometry_column: geometryColumn } : {}), }; const response = await props.downloadAs(request); if (actionId !== latestActionId.current) { return null; } if (response.missing_packages && response.missing_packages.length > 0) { setActionFailure(actionId, { kind: "missing-packages", packages: response.missing_packages, description: response.error, action, }); return null; } if (response.error || response.code || !response.url) { setActionFailure(actionId, { kind: "error", title: failureTitleForAction(action), description: response.error ?? "The export did not produce a file.", 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, hasGeometry); try { const ok = await withLoadingToast( `Preparing ${label} export...`, async () => { const result = await resolveDownloadUrl(format, action, actionId); if (!result) { return false; } if (actionId !== latestActionId.current) { 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, hasGeometry)} 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": case "geojson": 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; } if (actionId !== latestActionId.current) { 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 retryAfterInstall = async (action: ExportAction) => { if (isGeographic(action.format) && getExportMetadata) { const actionId = latestActionId.current; let result: ExportMetadata; try { result = await getExportMetadata({}); } catch (error) { if (actionId === latestActionId.current) { setMetadataError(failureDescription(error)); } return; } if (actionId !== latestActionId.current) { return; } setMetadata(result); setMetadataError(null); if (result.formats[action.format]?.available === false) { setFailure(null); return; } if ( geometryColumn !== null && !result.geometry_columns.some( (column) => column.name === geometryColumn, ) ) { setGeometryColumn(result.default_geometry_column); setActionFailure(actionId, { kind: "error", title: failureTitleForAction(action), description: "The selected geometry is no longer available. Choose another geometry to export.", action, }); return; } if (result.geometry_columns.length > 0 && geometryColumn === null) { return; } if ( action.format === "geojson" && !result.geometry_columns.find( (column) => column.name === geometryColumn, )?.crs ) { setFailure(null); return; } } retryAction(action); }; const toggleExpanded = (format: ExportFormat) => { if (!isConfigurable(format) && !(isGeographic(format) && hasGeometry)) { 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.filter( (option) => option.format !== "geojson" || hasGeometry, ).map((option) => { const optionLabel = labelForFormat(option.format, hasGeometry); const configurableFormat = isConfigurable(option.format) || (isGeographic(option.format) && hasGeometry) ? 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 = isGeographic(option.format) && hasGeometry ? geometryColumn === "" ? "(unnamed geometry)" : (geometryColumn ?? "Choose geometry") : settingsSummary(settings, option.format); const selectedGeometryIndex = metadata?.geometry_columns.findIndex( (column) => column.name === geometryColumn, ) ?? -1; const eligibility = isGeographic(option.format) ? metadata?.formats[option.format] : undefined; const selectedGeometry = metadata?.geometry_columns[selectedGeometryIndex]; const missingCrs = option.format === "geojson" && selectedGeometry !== undefined && !selectedGeometry.crs; const geometryReason = metadataError ? `Could not load geometry options: ${metadataError}` : (eligibility?.reason ?? (hasGeometry && geometryColumn === null ? `Choose a primary geometry column to export ${optionLabel}.` : missingCrs ? `Geometry column ${geometryColumn === "" ? "(unnamed geometry)" : geometryColumn} needs a CRS for GeoJSON export. Declare its source CRS in Python before exporting.` : null)); const missingPackageFailure = rowFailure?.kind === "missing-packages" ? rowFailure : null; const missingPackages = missingPackageFailure?.packages ?? (eligibility?.available === false ? eligibility.missing_packages : []); const geographicUnavailable = isGeographic(option.format) && ((!!getExportMetadata && !metadata) || metadataLoading || !!metadataError || eligibility?.available === false || missingCrs || (!!metadata?.geometry_columns.length && geometryColumn === null)); const copyUnavailable = isGeographic(option.format) && (geographicUnavailable || missingPackages.length > 0); return (
  • {configurableFormat ? ( ) : ( )}
    {optionLabel} {summary && ( {summary} )} {option.format === "parquet" && hasGeometry ? "Parquet with geometry and CRS metadata" : option.description}
    {expanded && configurableFormat && (
    {optionLabel} options {isGeographic(configurableFormat) && (

    {configurableFormat === "geojson" ? "Reproject the selected geometry to longitude and latitude (WGS 84). Keep other geometry columns as complete WKT in their source coordinates." : "Use the selected column as the primary geometry. Preserve all geometry columns and their coordinate reference systems (CRS)."}

    )} {isGeographic(configurableFormat) ? ( { const index = Number(event.target.value); setGeometryColumn( metadata?.geometry_columns[index]?.name ?? null, ); }} > {metadata?.geometry_columns.map((column, index) => ( ))} ) : ( )}
    )} {isGeographic(option.format) && missingPackages.length === 0 && geometryReason && (
    {geometryReason} {metadataError && ( )}
    )} {missingPackages.length > 0 && (
    { void retryAfterInstall( missingPackageFailure?.action ?? { destination: "download", format: option.format === "geojson" ? "geojson" : "parquet", }, ); }} className="items-start" />
    )} {rowFailure?.kind === "error" && missingPackages.length === 0 && (
    {rowFailure.title} {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(); }); }