/* Copyright 2026 Marimo. All rights reserved. */ import JsonView from "@uiw/react-json-view"; import { type ComponentProps, memo, useCallback, useMemo, useState, } from "react"; import { useEvent } from "@/hooks/useEvent"; import { useTheme } from "@/theme/useTheme"; import { cn } from "@/utils/cn"; import { CopyClipboardIcon } from "../../icons/copy-icon"; import { getCopyValue } from "./json-output/formatting"; import { determineMaxDisplayLength, PAGE_SIZE, paginateJson, shouldExpandNode, } from "./json-output/pagination"; import { COLLAPSED_TEXT_LENGTH, JSON_NULL_RENDER, PYTHON_FALSE_RENDER, PYTHON_NULL_RENDER, PYTHON_TRUE_RENDER, renderArrow, renderKeyName, renderKeyQuote, renderStringValue, } from "./json-output/renderers"; import { marimoDarkTheme, marimoLightTheme } from "./json-output/themes"; interface Props { data: unknown; format?: "auto" | "tree" | "raw"; /** If false, omit the root label. */ name?: string | false; className?: string; /** Python mode decodes MIME leaves and uses True/False/None. */ valueTypes?: "json" | "python"; } export const JsonOutput = memo(function JsonOutput({ data, format = "auto", ...props }: Props) { if (format === "raw" || data === null || typeof data !== "object") { return (
{JSON.stringify(data, null, 2)}
); } return ; }); type TreeProps = Omit & { data: object }; type RowRenderer = NonNullable< ComponentProps>["render"] >; type CountRenderer = NonNullable< ComponentProps>["render"] >; function JsonTree({ data, name = false, valueTypes = "python", className, }: TreeProps) { const { theme } = useTheme(); const isPython = valueTypes === "python"; const pageSize = determineMaxDisplayLength(data) ?? PAGE_SIZE; const [pagination, setPagination] = useState<{ data: object; limits: Record; }>({ data, limits: {} }); if (pagination.data !== data) { setPagination({ data, limits: {} }); } const { limits } = pagination; const tree = useMemo( () => paginateJson(data, { limits, pageSize }), [data, limits, pageSize], ); const showMore = useEvent((path: string) => { setPagination((prev) => ({ ...prev, limits: { ...prev.limits, [path]: (prev.limits[path] ?? pageSize) + pageSize, }, })); }); // Render callbacks need current render state; useEvent reads committed state. const renderCopyButton = useCallback( (value: unknown) => { if ( isPython && typeof value === "string" && /^(text\/html:|image\/|video\/)/.test(value) ) { return null; } return ( isPython ? getCopyValue(tree.getOriginal(value)) : JSON.stringify(tree.getOriginal(value), null, 2) } tooltip={false} buttonClassName="inline-flex ml-2 copy-button rounded w-6 h-3 justify-center items-center relative" className="w-5 h-5 absolute -top-0.5 p-1 hover:bg-muted rounded" /> ); }, [isPython, tree], ); const renderString = useCallback( ( props: Record, result: { type: string; value?: unknown }, ) => renderStringValue(props, result, valueTypes), [valueTypes], ); const renderRow = useCallback( (props, { value }) => { const page = tree.getPage(value); return (
{page ? ( ) : ( <> {props.children} {renderCopyButton(value)} )}
); }, [tree, showMore, renderCopyButton], ); const renderCount = useCallback( (props, { value }) => { const original = tree.getOriginal(value); const count = Array.isArray(original) ? original.length : original && typeof original === "object" ? Object.keys(original).length : 0; return ( {count} {count === 1 ? "Item" : "Items"} ); }, [tree], ); const renderContainerCopy = useCallback( (_props: Record, { value }: { value?: unknown }) => renderCopyButton(value), [renderCopyButton], ); return ( render={renderCount} /> render={renderRow} /> ); }