'use client'; /** * JsonViewer — collapsible, syntax-coloured JSON tree. * * Ported from jalco/ui's JsonViewer (MIT, Justin Levine — * https://ui.justinlevine.me). Modifications: * - Removed Shiki theme module + the 65-theme palette table; we only * use Tailwind tokens so the viewer follows the host theme via * normal CSS-var cascade. No `colorTheme` prop. * - Replaced jalco's ` ); } const entries = Array.isArray(value) ? value.map((v, i) => [i, v] as [number, JsonValue]) : (Object.entries(value as Record) as [string, JsonValue][]); const displayEntries = searchQuery ? entries.filter(([k, v]) => hasSearchMatch(v, k, searchQuery)) : entries; return (
{renderKey()} : {openBracket} {!isExpanded && ( <> {count} {count === 1 ? 'item' : 'items'} {closeBracket} {comma} )}
{isExpanded && ( <> {displayEntries.map(([k, v], i) => ( ))}
{closeBracket} {comma}
)}
); } // ─── path collectors for expand/collapse-all ──────────────────────────── function collectInitialCollapsed( value: JsonValue, path: string, maxDepth: number | true, depth: number, result: Set, ): void { if (value === null || typeof value !== 'object') return; if (maxDepth !== true && depth >= maxDepth) result.add(path); const entries = Array.isArray(value) ? value.map((v, i) => [i, v] as const) : Object.entries(value); for (const [k, v] of entries) { collectInitialCollapsed(v, buildPath(path, k), maxDepth, depth + 1, result); } } function collectAllExpandable(value: JsonValue, path: string, result: Set): void { if (value === null || typeof value !== 'object') return; result.add(path); const entries = Array.isArray(value) ? value.map((v, i) => [i, v] as const) : Object.entries(value); for (const [k, v] of entries) { collectAllExpandable(v, buildPath(path, k), result); } } // ─── toolbar ──────────────────────────────────────────────────────────── interface ToolbarProps { title?: string; badge?: string; actions: readonly JsonAction[]; searchOpen: boolean; copiedAll: boolean; compact?: boolean; onToggleSearch: () => void; onExpandAll: () => void; onCollapseAll: () => void; onCopy: () => void; onDownload: () => void; } function Toolbar({ title, badge, actions, searchOpen, copiedAll, compact = false, onToggleSearch, onExpandAll, onCollapseAll, onCopy, onDownload, }: ToolbarProps) { // Compact density: smaller hit area, smaller icons, no key/items badge. // Used by embedded contexts (e.g. RequestViewer body for 2-key error // responses) where the default toolbar visually outweighs the JSON. const btnSize = compact ? 'size-6' : 'size-7'; const iconSize = compact ? 'size-3' : 'size-3.5'; const rowPadding = compact ? 'px-2 py-1' : 'px-3 py-1.5'; // Each action button is wrapped in a Tooltip for the keyboard / mouse // discoverability win. `asChild` keeps the underlying {label} ); const iconCls = iconSize; return (
{title ? (

{title}

) : null} {badge && !compact ? ( {badge} ) : null}
{actions.includes('search') ? renderBtn( 'search', searchOpen ? 'Close search' : 'Search', , onToggleSearch, searchOpen, ) : null} {actions.includes('expand') ? ( <> {renderBtn( 'expand', 'Expand all', , onExpandAll, )} {renderBtn( 'collapse', 'Collapse all', , onCollapseAll, )} ) : null} {actions.includes('copy') ? renderBtn( 'copy', copiedAll ? 'Copied!' : 'Copy JSON', copiedAll ? ( ) : ( ), onCopy, ) : null} {actions.includes('download') ? renderBtn( 'download', 'Download JSON', , onDownload, ) : null}
); } // ─── component ────────────────────────────────────────────────────────── const DEFAULT_ACTIONS: readonly JsonAction[] = ['search', 'expand', 'copy']; /** * JsonTree — interactive viewer for arbitrary JSON. * * Default view: card with a hover-revealed toolbar (search / expand / * copy) and the tree expanded two levels deep. Toolbar fades in when * the container is hovered or keyboard-focused. * * For debug surfaces that benefit from the toolbar being always * visible, pass `toolbar="always"`. For host panes that own the * surrounding chrome (cmdop file preview, OpenAPI response panel), * pass `bordered={false}`. */ /** Map `size` → Tailwind text-size class for the tree body. */ const SIZE_CLASS: Record<'sm' | 'md' | 'lg', string> = { sm: 'text-xs', md: 'text-sm', lg: 'text-base', }; export const JsonTree = memo(function JsonTree({ data, title, rootName = 'root', toolbar = 'auto', actions = DEFAULT_ACTIONS, defaultExpandedDepth = 2, bordered = true, size = 'sm', className, downloadFilename = 'data.json', compactHeader = false, }: JsonTreeProps) { // Treat the data uniformly as `JsonValue` for the recursion. We only // need this cast at the boundary; everything inside is typed. const root = data as JsonValue; const [collapsedPaths, setCollapsedPaths] = useState>(() => { if (defaultExpandedDepth === true) return new Set(); const collapsed = new Set(); collectInitialCollapsed(root, rootName, defaultExpandedDepth, 0, collapsed); return collapsed; }); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [copiedAll, setCopiedAll] = useState(false); const searchRef = useRef(null); const togglePath = useCallback((path: string) => { setCollapsedPaths((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }, []); const expandAll = useCallback(() => setCollapsedPaths(new Set()), []); const collapseAll = useCallback(() => { const all = new Set(); collectAllExpandable(root, rootName, all); setCollapsedPaths(all); }, [root, rootName]); const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]); const copyJson = useCallback(() => { void navigator.clipboard?.writeText(jsonString).then(() => { setCopiedAll(true); setTimeout(() => setCopiedAll(false), 1500); }); }, [jsonString]); const downloadJson = useCallback(() => { const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = downloadFilename; a.click(); URL.revokeObjectURL(url); }, [jsonString, downloadFilename]); const toggleSearch = useCallback(() => { setSearchOpen((prev) => { const next = !prev; if (next) { requestAnimationFrame(() => searchRef.current?.focus()); } else { setSearchQuery(''); } return next; }); }, []); // Any non-empty query expands the whole tree so matches inside // collapsed nodes become visible — restoring the user's previous // collapse state would be friendlier; for now match jalco's behaviour. useEffect(() => { if (searchQuery) setCollapsedPaths(new Set()); }, [searchQuery]); const isExpandable = root !== null && typeof root === 'object'; const type = typeOf(root); const badge = isExpandable ? `${countEntries(root)} ${type === 'array' ? 'items' : 'keys'}` : undefined; const showToolbar = toolbar !== 'never'; const toolbarHover = toolbar === 'auto'; return (
{showToolbar ? (
) : null} {showToolbar && searchOpen ? (
setSearchQuery(e.target.value)} placeholder="Filter keys and values…" className="h-7 border-0 bg-transparent px-0 font-mono shadow-none focus-visible:ring-0" /> {searchQuery ? ( ) : null}
) : null}
{isExpandable ? ( ) : (
{rootName} : {typeof root === 'string' ? ( "{root}" ) : typeof root === 'number' ? ( {String(root)} ) : typeof root === 'boolean' ? ( {String(root)} ) : ( null )}
)}
); });