diff --git a/packages/core/src/catalog/index.test.ts b/packages/core/src/catalog/index.test.ts index 2165a7e..22e913f 100644 --- a/packages/core/src/catalog/index.test.ts +++ b/packages/core/src/catalog/index.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { assembleLensSpec, + buildForest, lensComponents, type QueryResult, } from "./index.js"; @@ -17,7 +18,7 @@ const fakeResult: QueryResult = { }; describe("lensComponents", () => { - it("registers all fourteen components (9 lenses + 5 controls)", () => { + it("registers all fifteen components (10 lenses + 5 controls)", () => { expect(Object.keys(lensComponents).sort()).toEqual([ "ActionList", "Button", @@ -33,6 +34,7 @@ describe("lensComponents", () => { "TextInput", "Timeline", "Toggle", + "Tree", ]); }); @@ -294,3 +296,79 @@ describe("Form picker field schema", () => { ).toThrow(/require kind: picker/); }); }); + +describe("Tree lens", () => { + it("builds a Tree spec with rows merged in", () => { + const spec = assembleLensSpec( + "t1", + "Tree", + { + levels: [ + { key: "d.slug", label: "d.name" }, + { key: "c.slug", label: "c.name" }, + ], + select_state: "/selected", + }, + fakeResult, + ); + expect(spec.elements["t1"]?.type).toBe("Tree"); + expect((spec.elements["t1"]?.props as { rows: unknown[] }).rows).toEqual( + fakeResult.rows, + ); + }); + + it("rejects fewer than two levels", () => { + expect(() => + assembleLensSpec("t1", "Tree", { levels: [{ key: "d.slug" }] }, fakeResult), + ).toThrow(); + }); +}); + +describe("buildForest", () => { + const LEVELS = [ + { key: "d", label: "dn" }, + { key: "c", label: "cn" }, + { key: "r", label: "rn" }, + ]; + + it("groups shared prefixes, dedupes, keeps first-seen order and labels", () => { + const rows = [ + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" }, + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "emer", rn: "Emergence" }, + { d: "sys", dn: "Systems", c: "chunk", cn: "Chunking", r: "attr", rn: "Attractors" }, + { d: "cog", dn: "Cognitive", c: "bias", cn: "Bias", r: "loop", rn: "Feedback loops" }, + // duplicate full path — must not duplicate nodes + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" }, + ]; + const forest = buildForest(rows, LEVELS); + expect(forest.map((n) => n.label)).toEqual(["Systems", "Cognitive"]); + const sys = forest[0]!; + expect(sys.children.map((n) => n.label)).toEqual(["Feedback loops", "Chunking"]); + expect(sys.children[0]!.children.map((n) => n.label)).toEqual([ + "Attractors", + "Emergence", + ]); + // same key under different parents = distinct nodes with distinct paths + const cogLoop = forest[1]!.children[0]!.children[0]!; + expect(cogLoop.key).toBe("loop"); + expect(cogLoop.path).not.toBe(sys.children[0]!.path); + expect(cogLoop.depth).toBe(2); + }); + + it("truncates sparse paths at the first empty level and falls back to key as label", () => { + const rows = [ + { d: "sys", dn: "Systems", c: "solo", cn: null, r: null, rn: null }, + { d: "sys", dn: "Systems", c: "", cn: "ignored" }, + { d: "lonely" }, // level-1-only row, no label column value + ]; + const forest = buildForest(rows, LEVELS); + expect(forest.map((n) => n.label)).toEqual(["Systems", "lonely"]); + const solo = forest[0]!.children[0]!; + expect(solo.label).toBe("solo"); // null label → key fallback + expect(solo.children).toEqual([]); + }); + + it("returns an empty forest for no rows", () => { + expect(buildForest([], LEVELS)).toEqual([]); + }); +}); diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts index c786c24..76636ed 100644 --- a/packages/core/src/catalog/index.ts +++ b/packages/core/src/catalog/index.ts @@ -8,6 +8,13 @@ import { type TableAuthorProps, type TableRuntimeProps, } from "./lenses/table.js"; +import { + TreeAuthorPropsSchema, + TreeRuntimePropsSchema, + TreeDescription, + type TreeAuthorProps, + type TreeRuntimeProps, +} from "./lenses/tree.js"; import { PathAuthorPropsSchema, PathRuntimePropsSchema, @@ -88,6 +95,7 @@ import { } from "./lenses/number_input.js"; export * from "./lenses/table.js"; +export * from "./lenses/tree.js"; export * from "./lenses/path.js"; export * from "./lenses/subgraph.js"; export * from "./lenses/action_list.js"; @@ -129,6 +137,7 @@ export interface QueryResult { */ export const lensComponents = { Table: { props: TableRuntimePropsSchema, description: TableDescription }, + Tree: { props: TreeRuntimePropsSchema, description: TreeDescription }, Path: { props: PathRuntimePropsSchema, description: PathDescription }, Subgraph: { props: SubgraphRuntimePropsSchema, description: SubgraphDescription }, ActionList: { props: ActionListRuntimePropsSchema, description: ActionListDescription }, @@ -263,6 +272,11 @@ function buildRuntimeProps( const runtime: TableRuntimeProps = { ...author, rows: result.rows }; return runtime as unknown as Record; } + case "Tree": { + const author: TreeAuthorProps = TreeAuthorPropsSchema.parse(authorProps); + const runtime: TreeRuntimeProps = { ...author, rows: result.rows }; + return runtime as unknown as Record; + } case "Path": { const author: PathAuthorProps = PathAuthorPropsSchema.parse(authorProps); const runtime: PathRuntimeProps = { ...author, rows: result.rows }; diff --git a/packages/core/src/catalog/lenses/tree.ts b/packages/core/src/catalog/lenses/tree.ts new file mode 100644 index 0000000..a897c3f Binary files /dev/null and b/packages/core/src/catalog/lenses/tree.ts differ diff --git a/packages/core/src/spec/index.ts b/packages/core/src/spec/index.ts index 83e061b..7b78a47 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -4,6 +4,7 @@ import { parse as parseYaml } from "yaml"; /** Data-bearing lenses (cells with a query; Form's query is optional — prefill). */ export const LensKind = z.enum([ "Table", + "Tree", "Subgraph", "Path", "ActionList", @@ -28,6 +29,7 @@ export type ControlKind = z.infer; /** Any cell type. */ export const ComponentKind = z.enum([ "Table", + "Tree", "Subgraph", "Path", "ActionList", diff --git a/packages/tui/src/components/Tree.tsx b/packages/tui/src/components/Tree.tsx new file mode 100644 index 0000000..de433f1 --- /dev/null +++ b/packages/tui/src/components/Tree.tsx @@ -0,0 +1,138 @@ +import React, { useMemo, useState } from "react"; +import { Box, Text, useFocus, useInput } from "ink"; +import { useActions, useStateValue } from "@json-render/ink"; +import { + buildForest, + type TreeNode, + type TreeRuntimeProps, +} from "@modernrelay/notebook-core"; + +interface ComponentCtx

{ + props: P; +} + +const VISIBLE_LINES = 14; + +interface VisibleLine { + node: TreeNode; + prefix: string; +} + +/** Flatten the expanded forest into display lines with box-drawing prefixes. */ +function visibleLines( + nodes: TreeNode[], + isOpen: (node: TreeNode) => boolean, + ancestors = "", +): VisibleLine[] { + const out: VisibleLine[] = []; + nodes.forEach((node, index) => { + const last = index === nodes.length - 1; + const branch = node.depth === 0 ? "" : last ? "└─ " : "├─ "; + out.push({ node, prefix: ancestors + branch }); + if (node.children.length > 0 && isOpen(node)) { + const carry = node.depth === 0 ? "" : last ? " " : "│ "; + out.push(...visibleLines(node.children, isOpen, ancestors + carry)); + } + }); + return out; +} + +/** + * Tree lens (ink) — the expanded forest as box-drawing lines. ↑/↓ move the + * cursor, ←/→ collapse/expand the focused node, Enter/space writes the + * node's key value to `select_state` (same dispatch as Table). + */ +export function Tree({ + props: p, +}: ComponentCtx): React.ReactElement { + const actions = useActions(); + const selectable = Boolean(p.select_state); + const { isFocused } = useFocus({ autoFocus: selectable }); + const selected = useStateValue(p.select_state ?? "/__never__"); + const forest = useMemo(() => buildForest(p.rows, p.levels), [p.rows, p.levels]); + // Default policy + explicit user toggles (survives query re-reads; new + // paths from refreshed rows get the expand_depth default). + const defaultOpen = (node: TreeNode): boolean => + node.children.length > 0 && + (p.expand_depth === undefined || node.depth < p.expand_depth); + const [userToggled, setUserToggled] = useState>( + () => new Map(), + ); + const isOpen = (node: TreeNode): boolean => + userToggled.get(node.path) ?? defaultOpen(node); + const [cursor, setCursor] = useState(0); + + const lines = visibleLines(forest, isOpen); + const total = lines.length; + + useInput( + (input, key) => { + if (total === 0) return; + const line = lines[Math.min(cursor, total - 1)]; + if (key.upArrow) setCursor((c) => (c - 1 + total) % total); + else if (key.downArrow) setCursor((c) => (c + 1) % total); + else if (key.leftArrow && line) { + setUserToggled((prev) => new Map(prev).set(line.node.path, false)); + } else if (key.rightArrow && line && line.node.children.length > 0) { + setUserToggled((prev) => new Map(prev).set(line.node.path, true)); + } else if ((key.return || input === " ") && line && selectable) { + actions.execute({ + action: "setState", + params: { statePath: p.select_state, value: line.node.key }, + }); + } + }, + { isActive: isFocused }, + ); + + if (total === 0) { + return {p.empty_text ?? "(no rows)"}; + } + + const cur = Math.min(cursor, total - 1); + const start = Math.max( + 0, + Math.min(cur - Math.floor(VISIBLE_LINES / 2), total - VISIBLE_LINES), + ); + const window = lines.slice(start, start + VISIBLE_LINES); + const showCounts = p.counts !== false; + + return ( + + {p.title && {p.title}} + {window.map((line, i) => { + const index = start + i; + const isCursor = isFocused && index === cur; + const isSelected = selectable && line.node.key === selected; + const hasChildren = line.node.children.length > 0; + const disclosure = hasChildren + ? isOpen(line.node) + ? "▾ " + : "▸ " + : ""; + return ( + + + {isCursor ? "▶ " : isSelected ? "● " : " "} + {line.prefix} + {disclosure} + {line.node.label} + {showCounts && hasChildren && ( + ({line.node.children.length}) + )} + + + ); + })} + {total > VISIBLE_LINES && ( + + {" "} + {cur + 1}/{total} + + )} + {isFocused && ( + ↑/↓ move · ←/→ fold{selectable ? " · Enter select" : ""} + )} + + ); +} diff --git a/packages/tui/src/registry.ts b/packages/tui/src/registry.ts index a0ed818..d039e37 100644 --- a/packages/tui/src/registry.ts +++ b/packages/tui/src/registry.ts @@ -2,6 +2,7 @@ import { defineCatalog } from "@json-render/core"; import { schema, defineRegistry } from "@json-render/ink"; import { lensActions, lensComponents } from "@modernrelay/notebook-core"; import { Table } from "./components/Table.js"; +import { Tree } from "./components/Tree.js"; import { Path } from "./components/Path.js"; import { Subgraph } from "./components/Subgraph.js"; import { ActionList } from "./components/ActionList.js"; @@ -22,7 +23,8 @@ const catalog = defineCatalog(schema, { }); const { registry: inkRegistry } = defineRegistry(catalog, { - components: { Table, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput }, + components: { Table, + Tree, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput }, actions: { setState: async (params, setState) => { const { statePath, value } = params as { statePath: string; value: unknown }; diff --git a/packages/web/src/components/Tree.test.tsx b/packages/web/src/components/Tree.test.tsx new file mode 100644 index 0000000..3f4c406 --- /dev/null +++ b/packages/web/src/components/Tree.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom +// +// Tree lens through the REAL registry: forest grouping renders nested, +// disclosure collapses, node click writes select_state. +import { describe, it, expect, afterEach } from "vitest"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSONUIProvider, Renderer } from "@json-render/react"; +import { assembleLensSpec, type QueryResult } from "@modernrelay/notebook-core"; +import { webRegistry } from "../registry.js"; + +const ROWS = [ + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "attr", rn: "Attractors" }, + { d: "sys", dn: "Systems", c: "loop", cn: "Feedback loops", r: "emer", rn: "Emergence" }, + { d: "sys", dn: "Systems", c: "chunk", cn: "Chunking", r: "attr", rn: "Attractors" }, + { d: "cog", dn: "Cognitive", c: "bias", cn: "Bias", r: "loop", rn: "Feedback loops" }, +]; + +const result = (rows: Record[]): QueryResult => ({ + query_name: "q", + target: "main", + row_count: rows.length, + columns: Object.keys(rows[0] ?? {}), + rows, +}); + +function treeSpec(extra: Record = {}) { + return assembleLensSpec( + "tree", + "Tree", + { + levels: [ + { key: "d", label: "dn" }, + { key: "c", label: "cn" }, + { key: "r", label: "rn" }, + ], + select_state: "/selected", + ...extra, + }, + result(ROWS), + ); +} + +function mount(spec: ReturnType): { + host: HTMLDivElement; + root: Root; + rerender: (s: ReturnType) => void; +} { + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + const render = (s: ReturnType): void => { + act(() => { + root.render( + + + , + ); + }); + }; + render(spec); + return { host, root, rerender: render }; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("Tree lens (web)", () => { + it("renders the grouped forest with counts", () => { + const { host, root } = mount(treeSpec()); + const text = host.textContent ?? ""; + expect(text).toContain("Systems"); + expect(text).toContain("Cognitive"); + expect(text).toContain("Feedback loops"); + expect(text).toContain("Attractors"); + // Systems has 2 concepts; Feedback loops has 2 related + const badges = Array.from(host.querySelectorAll("li > div > [class*='badge'], li > div > span[class*='tabular']")).map((b) => b.textContent); + expect(badges).toContain("2"); + act(() => root.unmount()); + }); + + it("disclosure collapses a branch", () => { + const { host, root } = mount(treeSpec()); + const collapseSystems = Array.from(host.querySelectorAll("button")).find( + (b) => b.getAttribute("aria-label") === "Collapse Systems", + )!; + expect(host.textContent).toContain("Feedback loops"); + act(() => { + collapseSystems.click(); + }); + // Systems' subtree hidden; Cognitive's still visible + expect(host.textContent).not.toContain("Chunking"); + expect(host.textContent).toContain("Bias"); + act(() => root.unmount()); + }); + + it("expand_depth: 0 starts fully collapsed", () => { + const { host, root } = mount(treeSpec({ expand_depth: 0 })); + expect(host.textContent).toContain("Systems"); + expect(host.textContent).not.toContain("Feedback loops"); + act(() => root.unmount()); + }); + + it("node click writes its key value to select_state (highlight follows)", () => { + const { host, root } = mount(treeSpec()); + const chunking = Array.from(host.querySelectorAll("span")).find( + (s) => s.textContent === "Chunking", + )!; + act(() => { + chunking.click(); + }); + // selection is the KEY value; the highlight class lands on the node + expect(chunking.className).toContain("bg-accent"); + // a different node with the same label elsewhere isn't highlighted + const systems = Array.from(host.querySelectorAll("span")).find( + (s) => s.textContent === "Systems", + )!; + expect(systems.className).not.toContain("bg-accent"); + act(() => root.unmount()); + }); + + it("re-read rows keep the default-open policy for NEW branches; user toggles survive", () => { + const { host, root, rerender } = mount(treeSpec()); + // user collapses Systems + act(() => { + Array.from(host.querySelectorAll("button")) + .find((b) => b.getAttribute("aria-label") === "Collapse Systems")! + .click(); + }); + expect(host.textContent).not.toContain("Chunking"); + // a background re-read adds a brand-new domain + const grown = assembleLensSpec( + "tree", + "Tree", + { + levels: [ + { key: "d", label: "dn" }, + { key: "c", label: "cn" }, + { key: "r", label: "rn" }, + ], + select_state: "/selected", + }, + result([ + ...ROWS, + { d: "phys", dn: "Physics", c: "entropy", cn: "Entropy", r: "attr", rn: "Attractors" }, + ]), + ); + rerender(grown); + // the NEW domain follows the default policy (open — no expand_depth set) + expect(host.textContent).toContain("Physics"); + expect(host.textContent).toContain("Entropy"); + // the user's explicit collapse of Systems SURVIVES the re-read + expect(host.textContent).not.toContain("Chunking"); + act(() => root.unmount()); + }); + + it("shows empty_text for zero rows", () => { + const spec = assembleLensSpec( + "tree", + "Tree", + { levels: [{ key: "d" }, { key: "c" }], empty_text: "No paths." }, + result([]), + ); + const { host, root } = mount(spec); + expect(host.textContent).toContain("No paths."); + act(() => root.unmount()); + }); +}); diff --git a/packages/web/src/components/Tree.tsx b/packages/web/src/components/Tree.tsx new file mode 100644 index 0000000..f6480e3 --- /dev/null +++ b/packages/web/src/components/Tree.tsx @@ -0,0 +1,119 @@ +import React, { useMemo, useState } from "react"; +import { useActions, useStateValue } from "@json-render/react"; +import { + buildForest, + type TreeNode, + type TreeRuntimeProps, +} from "@modernrelay/notebook-core"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +interface ComponentCtx

{ + props: P; +} + +/** + * Tree lens — a collapsible forest grouped from path-shaped rows (one row per + * root→leaf chain; `levels` names the column pairs). Clicking a node label + * writes THAT node's key value to `select_state` (Table's mechanism), so any + * level drives dependent cells. Disclosure state is per-node local UI state. + */ +export function Tree({ + props: p, +}: ComponentCtx): React.ReactElement { + const actions = useActions(); + const selected = useStateValue(p.select_state ?? "/__never__"); + const forest = useMemo( + () => buildForest(p.rows, p.levels), + [p.rows, p.levels], + ); + // Disclosure = default policy + explicit user toggles. Deriving the default + // per node (instead of materializing a set once) keeps re-read forests + // correct: new paths get the expand_depth default; the user's explicit + // opens/closes survive by path identity. + const defaultOpen = (node: TreeNode): boolean => + node.children.length > 0 && + (p.expand_depth === undefined || node.depth < p.expand_depth); + const [userToggled, setUserToggled] = useState>( + () => new Map(), + ); + const isOpen = (node: TreeNode): boolean => + userToggled.get(node.path) ?? defaultOpen(node); + const toggle = (node: TreeNode): void => { + setUserToggled((prev) => new Map(prev).set(node.path, !isOpen(node))); + }; + + if (forest.length === 0) { + return ( +

+ {p.empty_text ?? "(no rows)"} +

+ ); + } + + const selectable = Boolean(p.select_state); + const showCounts = p.counts !== false; + + const renderNode = (node: TreeNode): React.ReactElement => { + const hasChildren = node.children.length > 0; + const open = isOpen(node); + const isSelected = selectable && node.key === selected; + return ( +
  • +
    + {hasChildren ? ( + + ) : ( + + )} + + actions.execute({ + action: "setState", + params: { statePath: p.select_state, value: node.key }, + }), + } + : {})} + > + {node.label} + + {showCounts && hasChildren && ( + + {node.children.length} + + )} +
    + {hasChildren && open && ( +
      + {node.children.map(renderNode)} +
    + )} +
  • + ); + }; + + return ( +
    + {p.title && ( +

    {p.title}

    + )} +
      {forest.map(renderNode)}
    +
    + ); +} diff --git a/packages/web/src/registry.ts b/packages/web/src/registry.ts index 6886340..c69cbd1 100644 --- a/packages/web/src/registry.ts +++ b/packages/web/src/registry.ts @@ -3,6 +3,7 @@ import { schema, defineRegistry } from "@json-render/react"; import { lensActions, lensComponents } from "@modernrelay/notebook-core"; import { setAtPointer } from "@modernrelay/notebook-core"; import { Table } from "./components/Table.js"; +import { Tree } from "./components/Tree.js"; import { Path } from "./components/Path.js"; import { Subgraph } from "./components/Subgraph.js"; import { ActionList } from "./components/ActionList.js"; @@ -25,7 +26,8 @@ const catalog = defineCatalog(schema, { // React's SetState is `(updater: (prev) => next) => void`. We use the // executor's setAtPointer helper to write at JSON-pointer paths immutably. const { registry: webRegistry } = defineRegistry(catalog, { - components: { Table, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput }, + components: { Table, + Tree, Path, Subgraph, ActionList, Form, Timeline, Card, Quote, Text, Button, Toggle, Select, TextInput, NumberInput }, actions: { setState: async (params, setState) => { const { statePath, value } = params as {