feat(json): add interactive JSON tree viewer with collapse/expand and rainbow colors (#786)

- Add JsonTreeViewer component with collapse/expand for nested objects and arrays
- Add rainbow colors by depth level using CSS color-mix() with syntax theme tokens
- Add virtualization support (@tanstack/react-virtual) for large JSON files (>200 nodes)
- Add JsonTreeView wrapper with Expand All / Collapse All toolbar
- Integrate into FilesView: tree/text toggle button, JSON file detection
- Integrate into ToolPart: auto-detect JSON tool outputs, render as tree
- Integrate into ToolOutputDialog: JSON tree view in expanded dialog
- Add jsonTreeUtils.ts: parse, flatten, path utilities

No new dependencies - uses existing @tanstack/react-virtual.
Colors derived from existing syntax.* theme tokens - works across all themes.

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Nguyễn Ngô Thượng
2026-04-01 17:30:28 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d9fdd39f99
commit 6180f388e8
7 changed files with 755 additions and 1 deletions
@@ -20,10 +20,12 @@ import {
renderWebSearchOutput,
formatInputForDisplay,
parseReadToolOutput,
tryParseJsonOutput,
} from './toolRenderers';
import type { ToolPopupContent, DiffViewMode } from './types';
import { DiffViewToggle } from './DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -1184,6 +1186,18 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />;
}
// JSON tree viewer for generic JSON outputs
const jsonResult = popup.content ? tryParseJsonOutput(popup.content) : { data: null, isJson: false };
if (jsonResult.isJson) {
return (
<JsonTreeView
jsonString={popup.content}
initiallyExpandedDepth={3}
maxHeight="70vh"
/>
);
}
return (
<SyntaxHighlighter
style={syntaxTheme}
@@ -30,7 +30,9 @@ import {
formatEditOutput,
detectLanguageFromOutput,
formatInputForDisplay,
tryParseJsonOutput,
} from '../toolRenderers';
import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer';
import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
import { MinDurationShineText } from './MinDurationShineText';
import { ToolRevealOnMount } from './ToolRevealOnMount';
@@ -534,6 +536,19 @@ const ToolScrollableTextOutput: React.FC<{
}> = ({ output, part, metadata, input, syntaxTheme }) => {
const renderedOutput = getToolOutputText(output, part, metadata);
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]);
if (jsonResult.isJson) {
return (
<div className="tool-output-surface p-2 rounded-xl w-full min-w-0">
<JsonTreeViewer
data={jsonResult.data}
initiallyExpandedDepth={1}
maxHeight="400px"
/>
</div>
);
}
return (
<div className={part.tool === 'bash' ? 'typography-code text-muted-foreground/90' : undefined}>
@@ -28,6 +28,36 @@ const formatInputForDisplay = (input: Record<string, unknown>, toolName?: string
return formatToolInput(input, toolName || '');
};
export const tryParseJsonOutput = (output: string): { data: unknown; isJson: boolean } => {
if (!output || typeof output !== 'string') {
return { data: null, isJson: false };
}
const trimmed = output.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return { data: null, isJson: false };
}
if (!trimmed.endsWith('}') && !trimmed.endsWith(']')) {
return { data: null, isJson: false };
}
if (trimmed.length < 2) {
return { data: null, isJson: false };
}
try {
const parsed = JSON.parse(trimmed);
if (parsed !== null && typeof parsed === 'object') {
return { data: parsed, isJson: true };
}
return { data: null, isJson: false };
} catch {
return { data: null, isJson: false };
}
};
export const formatEditOutput = (output: string, toolName: string, metadata?: Record<string, unknown>): string => {
let cleaned = cleanOutput(output);
@@ -0,0 +1,100 @@
import React from 'react';
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { JsonTreeViewer } from './JsonTreeViewer';
interface JsonTreeViewProps {
jsonString: string;
className?: string;
maxHeight?: string;
initiallyExpandedDepth?: number;
}
const JsonTreeView = React.memo(function JsonTreeView({
jsonString,
className,
maxHeight = '100%',
initiallyExpandedDepth = 2,
}: JsonTreeViewProps) {
const viewerRef = React.useRef<{ expandAll: () => void; collapseAll: () => void }>(null);
const [parseError, setParseError] = React.useState<string | null>(null);
const parsedData = React.useMemo(() => {
try {
const trimmed = jsonString.trim();
if (!trimmed) {
setParseError('Empty JSON content');
return null;
}
const parsed = JSON.parse(trimmed);
setParseError(null);
return parsed;
} catch (err) {
setParseError(err instanceof Error ? err.message : 'Invalid JSON');
return null;
}
}, [jsonString]);
const handleExpandAll = React.useCallback(() => {
viewerRef.current?.expandAll();
}, []);
const handleCollapseAll = React.useCallback(() => {
viewerRef.current?.collapseAll();
}, []);
if (parseError) {
return (
<div className={className}>
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--syntax-base-background)] p-4">
<div className="mb-1 font-medium text-[var(--surface-foreground)]">Invalid JSON</div>
<div className="font-mono text-xs text-[var(--surface-mutedForeground)]">{parseError}</div>
</div>
</div>
);
}
if (parsedData === null) {
return null;
}
return (
<div className={className}>
<div className="flex items-center gap-1 border-b border-[var(--interactive-border)] px-2 py-1">
<Button
variant="ghost"
size="xs"
onClick={handleExpandAll}
className="gap-1 text-xs text-muted-foreground"
>
<RiArrowDownSLine className="h-3 w-3" />
Expand All
</Button>
<Button
variant="ghost"
size="xs"
onClick={handleCollapseAll}
className="gap-1 text-xs text-muted-foreground"
>
<RiArrowUpSLine className="h-3 w-3" />
Collapse All
</Button>
</div>
<div
className="bg-[var(--syntax-base-background)] py-1"
style={{ maxHeight, overflow: 'auto' }}
>
<JsonTreeViewer
ref={viewerRef}
data={parsedData}
maxHeight={maxHeight}
initiallyExpandedDepth={initiallyExpandedDepth}
/>
</div>
</div>
);
});
export { JsonTreeView };
export type { JsonTreeViewProps };
@@ -0,0 +1,304 @@
import React from 'react';
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
parseJsonToTree,
flattenTree,
getAllExpandableIds,
getExpandableIdsAboveDepth,
type JsonTreeNode,
type FlatJsonNode,
} from '@/lib/jsonTreeUtils';
interface JsonTreeViewerProps {
data: unknown;
className?: string;
maxHeight?: string;
initiallyExpandedDepth?: number;
onCopyPath?: (path: string) => void;
}
const RAINBOW_COLORS = [
'var(--syntax-key)',
'color-mix(in oklch, var(--syntax-key) 85%, var(--syntax-string))',
'color-mix(in oklch, var(--syntax-key) 70%, var(--syntax-number))',
'color-mix(in oklch, var(--syntax-key) 55%, var(--syntax-function))',
'color-mix(in oklch, var(--syntax-key) 40%, var(--syntax-type))',
'color-mix(in oklch, var(--syntax-key) 30%, var(--syntax-keyword))',
];
function getKeyColor(depth: number): string {
return RAINBOW_COLORS[depth % RAINBOW_COLORS.length];
}
function getValueColor(node: JsonTreeNode): string {
switch (node.type) {
case 'string':
return 'var(--syntax-string)';
case 'number':
return 'var(--syntax-number)';
case 'boolean':
return 'var(--syntax-keyword)';
case 'null':
return 'var(--syntax-comment)';
default:
return 'var(--surface-foreground)';
}
}
function formatValuePreview(node: JsonTreeNode): string {
switch (node.type) {
case 'string': {
const str = node.value as string;
if (str.length > 60) return `"${str.slice(0, 57)}..."`;
return `"${str}"`;
}
case 'number':
case 'boolean':
return String(node.value);
case 'null':
return 'null';
case 'object':
return `{${node.childCount ?? 0} ${node.childCount === 1 ? 'item' : 'items'}}`;
case 'array':
return `[${node.childCount ?? 0}]`;
default:
return String(node.value);
}
}
function getCollapsedPreview(node: JsonTreeNode): string {
if (node.type === 'object') {
const keys = node.children?.map((c) => c.key).slice(0, 3) ?? [];
const suffix = (node.childCount ?? 0) > 3 ? ', ...' : '';
return `{ ${keys.map((k) => `"${k}": ...`).join(', ')}${suffix} }`;
}
if (node.type === 'array') {
const count = node.childCount ?? 0;
if (count <= 3) {
const items = node.children?.map((c) => formatValuePreview(c)).join(', ') ?? '';
return `[${items}]`;
}
return `[${count} items]`;
}
return formatValuePreview(node);
}
const JsonRow = React.memo(
({
flatNode,
onToggle,
onCopyPath,
}: {
flatNode: FlatJsonNode;
onToggle: (id: string) => void;
onCopyPath?: (path: string) => void;
}) => {
const { node, isExpanded } = flatNode;
const indent = node.depth * 20;
const handleToggle = React.useCallback(() => {
onToggle(node.id);
}, [onToggle, node.id]);
const handleContextMenu = React.useCallback(
(e: React.MouseEvent) => {
if (onCopyPath) {
e.preventDefault();
onCopyPath(node.id);
}
},
[onCopyPath, node.id],
);
const keyColor = getKeyColor(node.depth);
const valueColor = getValueColor(node);
const isCollapsible = node.isExpandable && node.children && node.children.length > 0;
return (
<div
className="flex items-center py-0.5 px-2 hover:bg-[var(--surface-hover)] rounded-sm cursor-default font-mono text-xs leading-5 whitespace-nowrap"
style={{ paddingLeft: `${indent + 8}px` }}
onContextMenu={handleContextMenu}
>
{isCollapsible ? (
<button
type="button"
onClick={handleToggle}
className="mr-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm hover:bg-[var(--interactive-hover)] text-muted-foreground"
>
{isExpanded ? (
<RiArrowDownSLine className="h-3 w-3" />
) : (
<RiArrowRightSLine className="h-3 w-3" />
)}
</button>
) : (
<span className="mr-1 w-4" />
)}
{node.key !== 'root' && (
<>
<span
className="mr-1 font-semibold"
style={{ color: keyColor }}
>
{/^\d+$/.test(node.key) ? node.key : `"${node.key}"`}
</span>
<span className="mr-1 text-[var(--surface-foreground)]">:</span>
</>
)}
{node.isExpandable ? (
isExpanded ? (
<span className="text-[var(--surface-foreground)]">
{node.type === 'object' ? '{' : '['}
</span>
) : (
<span style={{ color: 'var(--surface-mutedForeground)' }}>
{getCollapsedPreview(node)}
</span>
)
) : (
<span style={{ color: valueColor }}>{formatValuePreview(node)}</span>
)}
</div>
);
},
);
JsonRow.displayName = 'JsonRow';
const VIRTUALIZE_THRESHOLD = 200;
const ROW_HEIGHT = 22;
const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () => void }, JsonTreeViewerProps>(
function JsonTreeViewer(
{ data, className, maxHeight = '100%', initiallyExpandedDepth = 2, onCopyPath },
ref,
) {
const jsonString = React.useMemo(() => {
try {
return JSON.stringify(data);
} catch {
return null;
}
}, [data]);
const treeRoot = React.useMemo(() => {
if (!jsonString) return null;
return parseJsonToTree(jsonString);
}, [jsonString]);
const [collapsedPaths, setCollapsedPaths] = React.useState<Set<string>>(() => {
if (!treeRoot) return new Set();
return new Set(getExpandableIdsAboveDepth(treeRoot, initiallyExpandedDepth));
});
const flatNodes = React.useMemo(
() => flattenTree(treeRoot, collapsedPaths),
[treeRoot, collapsedPaths],
);
const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD;
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: flatNodes.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 20,
enabled: shouldVirtualize,
});
const handleToggle = React.useCallback((id: string) => {
setCollapsedPaths((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const expandAll = React.useCallback(() => {
setCollapsedPaths(new Set());
}, []);
const collapseAll = React.useCallback(() => {
if (!treeRoot) return;
setCollapsedPaths(new Set(getAllExpandableIds(treeRoot)));
}, [treeRoot]);
React.useImperativeHandle(ref, () => ({ expandAll, collapseAll }), [expandAll, collapseAll]);
if (!treeRoot) {
return null;
}
if (shouldVirtualize) {
return (
<div
ref={parentRef}
className={className}
style={{ maxHeight, overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const flatNode = flatNodes[virtualRow.index];
if (!flatNode) return null;
return (
<div
key={flatNode.node.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<JsonRow
flatNode={flatNode}
onToggle={handleToggle}
onCopyPath={onCopyPath}
/>
</div>
);
})}
</div>
</div>
);
}
return (
<div
ref={parentRef}
className={className}
style={{ maxHeight, overflow: 'auto' }}
>
{flatNodes.map((flatNode) => (
<JsonRow
key={flatNode.node.id}
flatNode={flatNode}
onToggle={handleToggle}
onCopyPath={onCopyPath}
/>
))}
</div>
);
},
);
export { JsonTreeViewer };
export type { JsonTreeViewerProps };
+70 -1
View File
@@ -24,6 +24,8 @@ import {
RiEditLine,
RiFileCopyLine,
RiFileTransferLine,
RiCodeSSlashLine,
RiNodeTree,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
@@ -40,6 +42,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { PreviewToggleButton } from './PreviewToggleButton';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/languageByExtension';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
@@ -263,6 +266,12 @@ const isMarkdownFile = (path: string): boolean => {
return ext === 'md' || ext === 'markdown';
};
const isJsonFile = (path: string): boolean => {
if (!path) return false;
const ext = path.toLowerCase().split('.').pop();
return ext === 'json' || ext === 'jsonc' || ext === 'json5' || ext === 'geojson';
};
const isHtmlFile = (path: string): boolean => {
if (!path) return false;
const ext = path.toLowerCase().split('.').pop();
@@ -472,6 +481,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
const [jsonViewMode, setJsonViewMode] = React.useState<'tree' | 'text'>('tree');
const [htmlViewMode, setHtmlViewMode] = React.useState<'preview' | 'edit'>('edit');
const lightTheme = React.useMemo(
@@ -1639,6 +1649,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
const isTextFile = Boolean(selectedFile && !isSelectedImage);
const canUseShikiFileView = isTextFile && !isMarkdown && !(isHtml && htmlViewMode === 'preview');
@@ -1708,6 +1719,21 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return mdViewMode;
}, [mdViewMode]);
const JSON_VIEWER_MODE_KEY = 'openchamber:files:json-viewer-mode';
React.useEffect(() => {
try {
const stored = localStorage.getItem(JSON_VIEWER_MODE_KEY);
if (stored === 'tree') {
setJsonViewMode('tree');
} else if (stored === 'text') {
setJsonViewMode('text');
}
} catch {
// Ignore localStorage errors
}
}, []);
const HTML_VIEWER_MODE_KEY = 'openchamber:files:html-viewer-mode';
React.useEffect(() => {
@@ -1723,6 +1749,15 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
}, []);
const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => {
setJsonViewMode(mode);
try {
localStorage.setItem(JSON_VIEWER_MODE_KEY, mode);
} catch {
// Ignore localStorage errors
}
}, []);
const saveHtmlViewMode = React.useCallback((mode: 'preview' | 'edit') => {
setHtmlViewMode(mode);
try {
@@ -1735,7 +1770,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const getHtmlViewMode = React.useCallback((): 'preview' | 'edit' => {
return htmlViewMode;
}, [htmlViewMode]);
React.useEffect(() => {
if (!pendingFileNavigation || !root) {
return;
@@ -2285,6 +2319,22 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
)}
{isJson && (
<Button
variant="ghost"
size="sm"
onClick={() => saveJsonViewMode(jsonViewMode === 'tree' ? 'text' : 'tree')}
className="h-6 w-6 p-0 text-muted-foreground opacity-65 hover:opacity-100"
title={jsonViewMode === 'tree' ? 'Switch to Text View' : 'Switch to Tree View'}
>
{jsonViewMode === 'tree' ? (
<RiCodeSSlashLine className="size-4" />
) : (
<RiNodeTree className="size-4" />
)}
</Button>
)}
{canCopy && (
<Button
variant="ghost"
@@ -2580,6 +2630,25 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
/>
</div>
) : selectedFile && isJson && jsonViewMode === 'tree' ? (
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">JSON viewer unavailable</div>
<div className="text-sm text-muted-foreground">
Switch to text mode to view raw content.
</div>
</div>
}
>
<div className="h-full overflow-auto">
<JsonTreeView
jsonString={fileContent}
maxHeight="100%"
initiallyExpandedDepth={2}
/>
</div>
</ErrorBoundary>
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
<div className="h-full overflow-auto p-3">
{fileContent.length > 500 * 1024 && (
+222
View File
@@ -0,0 +1,222 @@
/**
* JSON Tree utilities for interactive JSON viewing.
* Provides parsing, tree building, flattening, and path utilities.
*/
export type JsonTreeNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null';
export interface JsonTreeNode {
id: string;
key: string;
value: unknown;
type: JsonTreeNodeType;
depth: number;
children?: JsonTreeNode[];
path: string[];
isExpandable: boolean;
childCount?: number;
}
export interface FlatJsonNode {
node: JsonTreeNode;
isExpanded: boolean;
}
export interface JsonTreeOptions {
maxDepth?: number;
maxNodes?: number;
initiallyExpandedDepth?: number;
}
const DEFAULT_OPTIONS: Required<JsonTreeOptions> = {
maxDepth: 50,
maxNodes: 100_000,
initiallyExpandedDepth: 2,
};
function getType(value: unknown): JsonTreeNodeType {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
if (typeof value === 'object') return 'object';
if (typeof value === 'string') return 'string';
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
return 'null';
}
export function getNodePath(pathSegments: string[]): string {
if (pathSegments.length === 0) return 'root';
let result = 'root';
for (const segment of pathSegments) {
if (/^\d+$/.test(segment)) {
result += `[${segment}]`;
} else {
result += `.${segment}`;
}
}
return result;
}
export function parseNodePath(pathKey: string): string[] {
if (pathKey === 'root' || pathKey === '') return [];
const withoutRoot = pathKey.startsWith('root.') ? pathKey.slice(5) : pathKey.startsWith('root[') ? pathKey.slice(4) : pathKey;
const segments: string[] = [];
let current = '';
let i = 0;
while (i < withoutRoot.length) {
const ch = withoutRoot[i];
if (ch === '.') {
if (current) segments.push(current);
current = '';
i++;
} else if (ch === '[') {
if (current) segments.push(current);
current = '';
i++;
let bracket = '';
while (i < withoutRoot.length && withoutRoot[i] !== ']') {
bracket += withoutRoot[i];
i++;
}
segments.push(bracket);
i++;
} else {
current += ch;
i++;
}
}
if (current) segments.push(current);
return segments;
}
let nodeCount = 0;
function buildTreeNode(
value: unknown,
key: string,
path: string[],
depth: number,
options: Required<JsonTreeOptions>,
): JsonTreeNode | null {
if (nodeCount >= options.maxNodes) return null;
nodeCount++;
const type = getType(value);
const id = getNodePath(path);
const isExpandable = type === 'object' || type === 'array';
const node: JsonTreeNode = {
id,
key,
value,
type,
depth,
path,
isExpandable,
};
if (isExpandable && depth < options.maxDepth) {
const entries = type === 'array'
? (value as unknown[]).map((v, i) => [String(i), v] as const)
: Object.entries(value as Record<string, unknown>);
node.childCount = entries.length;
node.children = [];
for (const [childKey, childValue] of entries) {
const childPath = [...path, childKey];
const child = buildTreeNode(childValue, childKey, childPath, depth + 1, options);
if (child) node.children.push(child);
}
} else if (isExpandable) {
node.childCount = type === 'array'
? (value as unknown[]).length
: Object.keys(value as Record<string, unknown>).length;
}
return node;
}
export function parseJsonToTree(text: string, options?: JsonTreeOptions): JsonTreeNode | null {
const opts = { ...DEFAULT_OPTIONS, ...options };
nodeCount = 0;
try {
const trimmed = text.trim();
if (!trimmed) return null;
const parsed = JSON.parse(trimmed);
return buildTreeNode(parsed, 'root', [], 0, opts);
} catch {
return null;
}
}
export function flattenTree(root: JsonTreeNode | null, collapsedPaths: Set<string>): FlatJsonNode[] {
if (!root) return [];
const result: FlatJsonNode[] = [];
function walk(node: JsonTreeNode) {
const isExpanded = !collapsedPaths.has(node.id);
result.push({ node, isExpanded });
if (node.isExpandable && isExpanded && node.children) {
for (const child of node.children) {
walk(child);
}
}
}
walk(root);
return result;
}
export function getAllExpandableIds(root: JsonTreeNode | null): string[] {
if (!root) return [];
const ids: string[] = [];
function walk(node: JsonTreeNode) {
if (node.isExpandable) {
ids.push(node.id);
if (node.children) {
for (const child of node.children) {
walk(child);
}
}
}
}
walk(root);
return ids;
}
export function getExpandableIdsAboveDepth(root: JsonTreeNode | null, maxDepth: number): string[] {
if (!root) return [];
const ids: string[] = [];
function walk(node: JsonTreeNode) {
if (node.isExpandable && node.depth >= maxDepth) {
ids.push(node.id);
}
if (node.children) {
for (const child of node.children) {
walk(child);
}
}
}
walk(root);
return ids;
}
export function isJsonParseable(text: string): boolean {
if (!text || typeof text !== 'string') return false;
const trimmed = text.trim();
if (trimmed.length < 2) return false;
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false;
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}