Files
openchamber/packages/ui/src/lib/jsonTreeUtils.ts
T
Nguyễn Ngô ThượngandBohdan Triapitsyn 6180f388e8 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>
2026-04-01 17:30:28 +03:00

223 lines
5.3 KiB
TypeScript

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