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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d9fdd39f99
commit
6180f388e8
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user