diff --git a/src/tui/approve/Approve.tsx b/src/tui/approve/Approve.tsx new file mode 100644 index 0000000..3c74182 --- /dev/null +++ b/src/tui/approve/Approve.tsx @@ -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 ( + + + ─ Approve change? {fileName} ─── + {adds} lines, − {dels} lines ─ + + + {hunks.map((h, i) => ( + + ))} + + + y accept n reject q reject & quit ESC reject + + + ); +} + +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 ( + + {sigil} {lineNoStr} {hunk.text} + + ); +} diff --git a/tests/tui/Approve.test.tsx b/tests/tui/Approve.test.tsx new file mode 100644 index 0000000..129ebb5 --- /dev/null +++ b/tests/tui/Approve.test.tsx @@ -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('', () => { + it('renders both deletions and additions in a unified diff', () => { + const { lastFrame } = render( + {}} /> + ); + 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( + {}} /> + ); + const out = lastFrame()!; + expect(out).toMatch(/\+ 2 lines.*− 1 lines/); + }); + + const flush = () => new Promise((r) => setImmediate(r)); + + it('calls onDecision("accept") when y is pressed', async () => { + const decision = vi.fn(); + const { stdin } = render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + await flush(); + stdin.write(String.fromCharCode(27)); + await flush(); + expect(decision).toHaveBeenCalledWith('reject'); + }); +});