0
0
Fork 0

tui: add Approve component with y/n keybind

<Approve fileName oldText newText onDecision/> renders a unified diff
(+/-/space sigils, 4-col line numbers, add/del totals header) and
binds y -> accept, n/q/ESC -> reject via ink useInput.

Tests use ink-testing-library; each keystroke case awaits a microtask
flush before stdin.write so that ink's useEffect-installed 'readable'
listener has actually been attached at the moment the byte arrives.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-28 22:18:00 +02:00
parent 99a9519138
commit 884de61cd7
2 changed files with 125 additions and 0 deletions

View file

@ -0,0 +1,51 @@
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { computeHunks } from '../shared/diff.js';
import { Hunk } from '../shared/types.js';
export type Decision = 'accept' | 'reject';
export interface ApproveProps {
fileName: string;
oldText: string;
newText: string;
onDecision: (d: Decision) => void;
}
export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps): React.ReactElement {
const hunks = React.useMemo(() => computeHunks(oldText, newText), [oldText, newText]);
const adds = hunks.filter((h) => h.kind === 'add').length;
const dels = hunks.filter((h) => h.kind === 'del').length;
useInput((input, key) => {
if (input === 'y') return onDecision('accept');
if (input === 'n' || input === 'q' || key.escape) return onDecision('reject');
});
return (
<Box flexDirection="column">
<Text>
Approve change? {fileName} + {adds} lines, {dels} lines
</Text>
<Box flexDirection="column">
{hunks.map((h, i) => (
<HunkLine key={i} hunk={h} />
))}
</Box>
<Text>
y accept n reject q reject & quit ESC reject
</Text>
</Box>
);
}
function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement {
const sigil = hunk.kind === 'add' ? '+' : hunk.kind === 'del' ? '-' : ' ';
const color = hunk.kind === 'add' ? 'green' : hunk.kind === 'del' ? 'red' : undefined;
const lineNoStr = String(hunk.lineNo).padStart(4, ' ');
return (
<Text color={color}>
{sigil} {lineNoStr} {hunk.text}
</Text>
);
}

View file

@ -0,0 +1,74 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { Approve } from '../../src/tui/approve/Approve.tsx';
const OLD = 'PROGRAM PLC_PRG\nVAR\n counter : INT := 0;\nEND_VAR';
const NEW = 'PROGRAM PLC_PRG\nVAR\n counter : DINT := 0;\n overflow : BOOL;\nEND_VAR';
describe('<Approve>', () => {
it('renders both deletions and additions in a unified diff', () => {
const { lastFrame } = render(
<Approve fileName="PLC_PRG.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
);
const out = lastFrame()!;
expect(out).toContain('counter : INT := 0;');
expect(out).toContain('counter : DINT := 0;');
expect(out).toContain('overflow : BOOL;');
expect(out).toMatch(/Approve change\? PLC_PRG\.st/);
});
it('reports add/del totals in the header', () => {
const { lastFrame } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
);
const out = lastFrame()!;
expect(out).toMatch(/\+ 2 lines.* 1 lines/);
});
const flush = () => new Promise<void>((r) => setImmediate(r));
it('calls onDecision("accept") when y is pressed', async () => {
const decision = vi.fn();
const { stdin } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
);
await flush();
stdin.write('y');
await flush();
expect(decision).toHaveBeenCalledWith('accept');
});
it('calls onDecision("reject") when n is pressed', async () => {
const decision = vi.fn();
const { stdin } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
);
await flush();
stdin.write('n');
await flush();
expect(decision).toHaveBeenCalledWith('reject');
});
it('calls onDecision("reject") when q is pressed', async () => {
const decision = vi.fn();
const { stdin } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
);
await flush();
stdin.write('q');
await flush();
expect(decision).toHaveBeenCalledWith('reject');
});
it('calls onDecision("reject") on escape', async () => {
const decision = vi.fn();
const { stdin } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={decision} />
);
await flush();
stdin.write(String.fromCharCode(27));
await flush();
expect(decision).toHaveBeenCalledWith('reject');
});
});