0
0
Fork 0

tui: add Tree component (collapsible device/POU listing)

<Tree project cursorPath expanded/> renders the project as a
two-level collapsible list: device headers with POU count, and (when
expanded) POU rows with name + kind + LOC count. Cursor row marked
with a leading triangle.

devicePath()/pouPath() are exported keypath helpers used by the tree
state machine to address rows uniformly.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-28 22:21:05 +02:00
parent 357c1d11c5
commit 9902cd6dcf
2 changed files with 102 additions and 0 deletions

44
src/tui/browser/Tree.tsx Normal file
View file

@ -0,0 +1,44 @@
import React from 'react';
import { Box, Text } from 'ink';
import { Project, POU } from '../shared/types.js';
export interface TreeProps {
project: Project;
cursorPath: string;
expanded: Set<string>;
}
export function devicePath(deviceName: string): string {
return `device:${deviceName}`;
}
export function pouPath(deviceName: string, relPath: string): string {
return `pou:${deviceName}:${relPath}`;
}
export function Tree({ project, cursorPath, expanded }: TreeProps): React.ReactElement {
const rows: React.ReactElement[] = [];
for (const dev of project.devices) {
const dPath = devicePath(dev.name);
const isExpanded = expanded.has(dPath);
const isCursor = cursorPath === dPath;
rows.push(
<Text key={dPath}>
{isCursor ? '▶ ' : ' '}
{isExpanded ? '▾ ' : '▸ '}
{dev.name} {dev.pous.length} POUs
</Text>
);
if (!isExpanded) continue;
for (const p of dev.pous) {
const pPath = pouPath(dev.name, p.relPath);
const isPCursor = cursorPath === pPath;
rows.push(
<Text key={pPath}>
{isPCursor ? '▶ ' : ' '}
{p.name.padEnd(18)} {p.kind.padEnd(6)} {String(p.loc).padStart(4)} L
</Text>
);
}
}
return <Box flexDirection="column">{rows}</Box>;
}

58
tests/tui/Tree.test.tsx Normal file
View file

@ -0,0 +1,58 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render } from 'ink-testing-library';
import { Tree } from '../../src/tui/browser/Tree.tsx';
import { Project } from '../../src/tui/shared/types.ts';
const project: Project = {
rootDir: '/p',
mirrorMtimeMs: 0,
devices: [
{
name: 'CodesysRpi',
pous: [
{ name: 'PLC_PRG', kind: 'PRG', relPath: 'a/PLC_PRG.st', absPath: '/abs/a/PLC_PRG.st', loc: 5, mtimeMs: 0 },
{ name: 'FB_Test', kind: 'FB', relPath: 'a/FB_Test.st', absPath: '/abs/a/FB_Test.st', loc: 87, mtimeMs: 0 },
],
},
],
};
describe('<Tree>', () => {
it('renders devices and POU rows when expanded', () => {
const { lastFrame } = render(
<Tree
project={project}
cursorPath="device:CodesysRpi"
expanded={new Set(['device:CodesysRpi'])}
/>
);
const out = lastFrame()!;
expect(out).toContain('CodesysRpi');
expect(out).toContain('PLC_PRG');
expect(out).toContain('FB_Test');
expect(out).toContain('PRG');
expect(out).toContain('FB');
});
it('does not show POU rows when device is collapsed', () => {
const { lastFrame } = render(
<Tree project={project} cursorPath="device:CodesysRpi" expanded={new Set()} />
);
const out = lastFrame()!;
expect(out).toContain('CodesysRpi');
expect(out).not.toContain('PLC_PRG');
});
it('marks the cursor row', () => {
const { lastFrame } = render(
<Tree
project={project}
cursorPath="pou:CodesysRpi:a/FB_Test.st"
expanded={new Set(['device:CodesysRpi'])}
/>
);
const out = lastFrame()!;
expect(out).toMatch(/▶ FB_Test/);
});
});