▚TreeViewcustom
Keyboard-navigable tree with expand/collapse
$ termino tree-view — focus, ↑↓←→
▾ src
▾ components
· badge.tsx
· tree-view.tsx
· modal.tsx
· main.ts
· renderer.ts
· package.json
· tsconfig.json
picked —
##API
Recursive flatten of nodes into a flat row list. Keyboard handled via useKeyboard; gate on the `focused` prop.
properties
| prop | type | default | description |
|---|---|---|---|
| nodes | TreeNode[] | - | Root nodes: { name, value?, children? } |
| focused | boolean | true | Gate for keyboard handling |
| onSelect | (node, path) => void | - | Fired with node + slash path on Enter |
| indent | number | 2 | Indent per depth level |
| selectedBackgroundColor | string | "#334455" | Selection row background |
| selectedTextColor | string | "#e0af68" | Selection row text color |
##Keymap
↑ / kMove selection up
↓ / jMove selection down
→ / lExpand node
← / hCollapse node, or jump to parent
EnterToggle expand + fire onSelect
##Notes
- ▸Expanded set is kept in a Set<string> keyed by slash path — cheap and stable across re-renders.
- ▸Glyphs: ▾ expanded, ▸ collapsed, · leaf.
##Source
Real implementation — this is the code that runs in your terminal.
1import { createElement as h, useMemo, useState } from "react";2import { useKeyboard } from "@opentui/react";34export interface TreeNode {5 name: string;6 value?: string;7 children?: TreeNode[];8}910export interface TreeViewProps {11 nodes: TreeNode[];12 focused?: boolean;13 onSelect?: (node: TreeNode, path: string) => void;14 indent?: number;15 selectedBackgroundColor?: string;16 selectedTextColor?: string;17}1819interface FlatNode {20 node: TreeNode;21 path: string;22 depth: number;23 expandable: boolean;24}2526/** Marker at the head of a row: a caret that points down when the branch is27 * open and right when it is closed, or a dot for a leaf. */28export function branchGlyph(expandable: boolean, isExpanded: boolean): string {29 if (!expandable) return "·";30 return isExpanded ? "▾" : "▸";31}3233/** Row text colour: the selection colour wins, branches read brighter than34 * leaves. */35function rowColor(isSelected: boolean, expandable: boolean, selectedColor: string): string {36 if (isSelected) return selectedColor;37 return expandable ? "#7dcfff" : "#a9b1d6";38}3940function flatten(41 nodes: TreeNode[],42 expanded: Set<string>,43 depth: number,44 base: string,45 out: FlatNode[],46) {47 for (const node of nodes) {48 const path = base ? `${base}/${node.name}` : node.name;49 const children = node.children ?? [];50 const expandable = children.length > 0;51 out.push({ node, path, depth, expandable });52 if (expandable && expanded.has(path)) {53 flatten(children, expanded, depth + 1, path, out);54 }55 }56}5758export function TreeView({59 nodes,60 focused = true,61 onSelect,62 indent = 2,63 selectedBackgroundColor = "#334455",64 selectedTextColor = "#e0af68",65}: Readonly<TreeViewProps>) {66 const [selected, setSelected] = useState(0);67 const [expanded, setExpanded] = useState<Set<string>>(68 () => new Set(collectExpanded(nodes)),69 );7071 const flat = useMemo(() => {72 const out: FlatNode[] = [];73 flatten(nodes, expanded, 0, "", out);74 return out;75 }, [nodes, expanded]);7677 const expand = (path: string) => setExpanded((prev) => new Set(prev).add(path));7879 const collapse = (path: string) =>80 setExpanded((prev) => {81 const next = new Set(prev);82 next.delete(path);83 return next;84 });8586 /** Left on an open branch closes it; anywhere else it walks to the parent. */87 const goLeft = (item: FlatNode | undefined) => {88 if (item?.expandable && expanded.has(item.path)) {89 collapse(item.path);90 return;91 }92 const parent = parentPath(item?.path);93 const idx = parent ? flat.findIndex((f) => f.path === parent) : -1;94 if (idx >= 0) setSelected(idx);95 };9697 const activate = (item: FlatNode) => {98 if (item.expandable) {99 if (expanded.has(item.path)) collapse(item.path);100 else expand(item.path);101 }102 onSelect?.(item.node, item.path);103 };104105 useKeyboard((key) => {106 if (!focused) return;107 const item = flat[selected];108 switch (key.name) {109 case "up":110 case "k":111 return setSelected((s) => Math.max(0, s - 1));112 case "down":113 case "j":114 return setSelected((s) => Math.min(flat.length - 1, s + 1));115 case "right":116 case "l":117 if (item?.expandable) expand(item.path);118 return;119 case "left":120 case "h":121 return goLeft(item);122 case "return":123 if (item) activate(item);124 return;125 default:126 return;127 }128 });129130 return h(131 "box",132 { flexDirection: "column" },133 flat.map((item, i) => {134 const isSel = focused && i === selected;135 const glyph = branchGlyph(item.expandable, expanded.has(item.path));136 return h(137 "box",138 {139 key: item.path,140 style: {141 paddingLeft: 1 + item.depth * indent,142 backgroundColor: isSel ? selectedBackgroundColor : undefined,143 },144 },145 h(146 "text",147 {148 fg: rowColor(isSel, item.expandable, selectedTextColor),149 },150 `${glyph} ${item.node.name}`,151 ),152 );153 }),154 );155}156157function collectExpanded(nodes: TreeNode[], base = "", out: string[] = []): string[] {158 for (const node of nodes) {159 if (node.children?.length) {160 const path = base ? `${base}/${node.name}` : node.name;161 out.push(path);162 collectExpanded(node.children, path, out);163 }164 }165 return out;166}167168function parentPath(path?: string): string | null {169 if (!path) return null;170 const idx = path.lastIndexOf("/");171 return idx > 0 ? path.slice(0, idx) : null;172}173