Files
dbx/apps/desktop/src/lib/common/jsonTree.ts
T

133 lines
4.4 KiB
TypeScript

import { isLosslessJsonNumber } from "./safeJsonFormat";
export type JsonTreeContainerKind = "array" | "object";
export type JsonTreeParentKind = JsonTreeContainerKind | "root";
export interface JsonTreeNode {
key: string;
label: string;
value: unknown;
/** RFC 6901 JSON Pointer. The root value is represented by an empty string. */
path: string;
/** Zero-based structural depth; the root value is at depth zero. */
depth: number;
parentKind: JsonTreeParentKind;
/** True only for the synthetic closing-bracket row of an expanded container. */
closing?: boolean;
}
export type JsonTreeContainer = Record<string, unknown> | unknown[];
export function createJsonTreeRoot(value: unknown): JsonTreeNode {
return {
key: "$",
label: "$",
value,
path: "",
depth: 0,
parentKind: "root",
};
}
export function isJsonTreeContainer(value: unknown): value is JsonTreeContainer {
// LosslessJsonNumber is an object wrapper, but represents a scalar JSON number.
return value !== null && typeof value === "object" && !isLosslessJsonNumber(value);
}
export function jsonTreeContainerKind(value: JsonTreeContainer): JsonTreeContainerKind {
return Array.isArray(value) ? "array" : "object";
}
/** Compact summary shown between the brackets of a collapsed container. */
export function jsonTreeContainerSummary(value: JsonTreeContainer): string {
if (Array.isArray(value)) return `Array(${value.length})`;
return `Object(${Object.keys(value).length})`;
}
export function isJsonTreeClosingNode(node: JsonTreeNode): boolean {
return node.closing === true;
}
/** Escape a reference token according to RFC 6901. */
export function escapeJsonPointerSegment(segment: string): string {
return segment.replaceAll("~", "~0").replaceAll("/", "~1");
}
export function appendJsonPointer(path: string, segment: string | number): string {
return `${path}/${escapeJsonPointerSegment(String(segment))}`;
}
/**
* Create child nodes only for an already-expanded parent. Callers should not
* invoke this while a container is collapsed so large JSON payloads stay lazy.
*/
export function getJsonTreeChildren(node: JsonTreeNode): JsonTreeNode[] {
if (!isJsonTreeContainer(node.value)) return [];
if (Array.isArray(node.value)) {
return node.value.map((value, index) => ({
key: String(index),
label: String(index),
value,
path: appendJsonPointer(node.path, index),
depth: node.depth + 1,
parentKind: "array",
}));
}
return Object.entries(node.value).map(([key, value]) => ({
key,
label: key,
value,
path: appendJsonPointer(node.path, key),
depth: node.depth + 1,
parentKind: "object",
}));
}
/**
* Flatten the currently expanded tree with an iterative traversal. This lets
* virtual renderers keep every node logically expanded without recursive DOM
* creation or deep-call-stack failures. When `includeClosingRows` is set, each
* expanded container is followed by a synthetic closing-bracket row.
*/
export function getVisibleJsonTreeNodes(root: JsonTreeNode, isExpanded: (node: JsonTreeNode) => boolean, includeClosingRows = false): JsonTreeNode[] {
const nodes: JsonTreeNode[] = [];
const pending = [root];
while (pending.length > 0) {
const node = pending.pop();
if (!node) continue;
nodes.push(node);
if (isJsonTreeClosingNode(node) || !isJsonTreeContainer(node.value) || !isExpanded(node)) continue;
if (includeClosingRows) {
pending.push({
// NUL suffix keeps synthetic closing rows from colliding with a real
// `$close` key that a document may legitimately contain.
key: `${node.key}$close\0`,
label: "",
value: node.value,
path: `${node.path}/$close\0`,
depth: node.depth,
parentKind: node.parentKind,
closing: true,
});
}
const children = getJsonTreeChildren(node);
for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]);
}
return nodes;
}
/**
* `initialExpandedDepth` counts container levels from the root. For example,
* a value of 2 expands the root and its direct container children.
*/
export function isJsonTreeInitiallyExpanded(depth: number, initialExpandedDepth: number): boolean {
if (initialExpandedDepth === Number.POSITIVE_INFINITY) return true;
if (!Number.isFinite(initialExpandedDepth)) return false;
return depth < Math.max(0, Math.floor(initialExpandedDepth));
}