224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
import { useMemo } from "react";
|
|
import type { ExtractionNode } from "../regex/model/match";
|
|
import { handleTreeNavigation } from "./tree-navigation";
|
|
|
|
const MAXIMUM_RENDERED_EXTRACTION_NODES = 2_000;
|
|
const MAXIMUM_VALUE_PREVIEW_UTF16 = 100;
|
|
|
|
export interface ExtractionTreeProps {
|
|
readonly nodes: readonly ExtractionNode[];
|
|
readonly selectedId?: string;
|
|
readonly onSelect: (node: ExtractionNode) => void;
|
|
readonly repeatedCaptureNumbers: ReadonlySet<number>;
|
|
}
|
|
|
|
function printable(node: ExtractionNode): string {
|
|
const value = node.value;
|
|
if (value === undefined) return "—";
|
|
if (value === "") return "∅ empty";
|
|
const visible = value
|
|
.slice(0, MAXIMUM_VALUE_PREVIEW_UTF16)
|
|
.replaceAll("\n", "↵")
|
|
.replaceAll("\r", "␍")
|
|
.replaceAll("\t", "⇥");
|
|
const exactLength = node.range
|
|
? node.range.endUtf16 - node.range.startUtf16
|
|
: undefined;
|
|
const uiTruncated = value.length > MAXIMUM_VALUE_PREVIEW_UTF16;
|
|
if (node.status === "truncated") {
|
|
return `${visible}${uiTruncated ? "…" : ""} (engine value prefix: ${value.length.toLocaleString()} of ${exactLength?.toLocaleString() ?? "unknown"} UTF-16 units${uiTruncated ? "; UI shows the first 100" : ""})`;
|
|
}
|
|
return uiTruncated
|
|
? `${visible}… (${(exactLength ?? value.length).toLocaleString()} UTF-16 units total; UI preview)`
|
|
: visible;
|
|
}
|
|
|
|
function countNodes(nodes: readonly ExtractionNode[]): number {
|
|
let count = 0;
|
|
const pending = [...nodes];
|
|
while (pending.length > 0) {
|
|
const node = pending.pop();
|
|
if (!node) continue;
|
|
count += 1;
|
|
pending.push(...node.children);
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function boundedNodes(
|
|
nodes: readonly ExtractionNode[],
|
|
maximum: number,
|
|
): readonly ExtractionNode[] {
|
|
let remaining = maximum;
|
|
const takeNode = (node: ExtractionNode): ExtractionNode | undefined => {
|
|
if (remaining <= 0) return undefined;
|
|
remaining -= 1;
|
|
const children: ExtractionNode[] = [];
|
|
for (const child of node.children) {
|
|
const visible = takeNode(child);
|
|
if (!visible) break;
|
|
children.push(visible);
|
|
}
|
|
return { ...node, children };
|
|
};
|
|
const visible: ExtractionNode[] = [];
|
|
for (const node of nodes) {
|
|
const bounded = takeNode(node);
|
|
if (!bounded) break;
|
|
visible.push(bounded);
|
|
}
|
|
return visible;
|
|
}
|
|
|
|
function containsNode(nodes: readonly ExtractionNode[], id: string): boolean {
|
|
const pending = [...nodes];
|
|
while (pending.length > 0) {
|
|
const node = pending.pop();
|
|
if (!node) continue;
|
|
if (node.id === id) return true;
|
|
pending.push(...node.children);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function ExtractionTreeNode({
|
|
node,
|
|
selectedId,
|
|
onSelect,
|
|
repeatedCaptureNumbers,
|
|
depth,
|
|
focusableId,
|
|
}: {
|
|
readonly node: ExtractionNode;
|
|
readonly selectedId?: string;
|
|
readonly onSelect: (node: ExtractionNode) => void;
|
|
readonly repeatedCaptureNumbers: ReadonlySet<number>;
|
|
readonly depth: number;
|
|
readonly focusableId: string;
|
|
}) {
|
|
const repeatedFinal =
|
|
node.groupNumber !== undefined &&
|
|
repeatedCaptureNumbers.has(node.groupNumber);
|
|
return (
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
role="treeitem"
|
|
aria-level={depth}
|
|
aria-selected={node.id === selectedId}
|
|
tabIndex={node.id === focusableId ? 0 : -1}
|
|
className={`tree-row extraction-row ${
|
|
node.id === selectedId ? "is-selected" : ""
|
|
}`}
|
|
onClick={() => onSelect(node)}
|
|
onKeyDown={handleTreeNavigation}
|
|
>
|
|
<span className="tree-token">{node.label}</span>
|
|
<span className="tree-explanation">{printable(node)}</span>
|
|
<span className={`status status-${node.status}`}>
|
|
{node.status.replaceAll("-", " ")}
|
|
</span>
|
|
<span className="tree-details extraction-details">
|
|
<span>
|
|
UTF-16 range:{" "}
|
|
{node.range
|
|
? `${node.range.startUtf16}…${node.range.endUtf16}`
|
|
: "unavailable"}
|
|
</span>
|
|
<span>
|
|
native range:{" "}
|
|
{node.nativeRange
|
|
? `${node.nativeRange.start}…${node.nativeRange.end} ${node.nativeRange.unit}`
|
|
: "unavailable"}
|
|
</span>
|
|
<span>provenance: {node.provenance}</span>
|
|
</span>
|
|
</button>
|
|
{repeatedFinal ? (
|
|
<p className="tree-note">
|
|
This engine exposes only the final retained capture for this repeated
|
|
group.
|
|
</p>
|
|
) : null}
|
|
{node.children.length > 0 ? (
|
|
<ul role="group">
|
|
{node.children.map((child) => (
|
|
<ExtractionTreeNode
|
|
key={child.id}
|
|
node={child}
|
|
selectedId={selectedId}
|
|
onSelect={onSelect}
|
|
repeatedCaptureNumbers={repeatedCaptureNumbers}
|
|
depth={depth + 1}
|
|
focusableId={focusableId}
|
|
/>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export function ExtractionTree({
|
|
nodes,
|
|
selectedId,
|
|
onSelect,
|
|
repeatedCaptureNumbers,
|
|
}: ExtractionTreeProps) {
|
|
const totalNodes = useMemo(() => countNodes(nodes), [nodes]);
|
|
const visibleNodes = useMemo(
|
|
() => boundedNodes(nodes, MAXIMUM_RENDERED_EXTRACTION_NODES),
|
|
[nodes],
|
|
);
|
|
const renderedNodes = Math.min(totalNodes, MAXIMUM_RENDERED_EXTRACTION_NODES);
|
|
const selectedNodeVisible =
|
|
selectedId === undefined || containsNode(visibleNodes, selectedId);
|
|
const focusableId =
|
|
selectedId && selectedNodeVisible
|
|
? selectedId
|
|
: (visibleNodes[0]?.id ?? "");
|
|
return (
|
|
<section className="panel tree-panel" aria-labelledby="extraction-heading">
|
|
<header className="panel-heading">
|
|
<div>
|
|
<p className="eyebrow">Engine results</p>
|
|
<h2 id="extraction-heading">Extraction tree</h2>
|
|
</div>
|
|
<span className="provenance-badge is-engine">Actual engine result</span>
|
|
</header>
|
|
{totalNodes > renderedNodes ? (
|
|
<p
|
|
className="render-limit-banner"
|
|
role="status"
|
|
data-testid="extraction-render-limit"
|
|
>
|
|
<strong>Tree preview limited.</strong> Showing the first{" "}
|
|
{renderedNodes.toLocaleString()} of {totalNodes.toLocaleString()}{" "}
|
|
returned result nodes. All returned data remains available to the
|
|
other result views and exports.
|
|
{!selectedNodeVisible
|
|
? " The selected result is outside this bounded tree preview."
|
|
: ""}
|
|
</p>
|
|
) : null}
|
|
{nodes.length > 0 ? (
|
|
<ul className="tree" role="tree" aria-label="Match extraction">
|
|
{visibleNodes.map((node) => (
|
|
<ExtractionTreeNode
|
|
key={node.id}
|
|
node={node}
|
|
selectedId={selectedId}
|
|
onSelect={onSelect}
|
|
repeatedCaptureNumbers={repeatedCaptureNumbers}
|
|
depth={1}
|
|
focusableId={focusableId}
|
|
/>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p className="empty-state">No matches to extract.</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|