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:
parent
99a9519138
commit
884de61cd7
2 changed files with 125 additions and 0 deletions
51
src/tui/approve/Approve.tsx
Normal file
51
src/tui/approve/Approve.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
tests/tui/Approve.test.tsx
Normal file
74
tests/tui/Approve.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue