/* 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} />
);
}