From 2a743c29fa212e841f3e778eee92c9ccd891bf21 Mon Sep 17 00:00:00 2001 From: Karstein Phobic Nyvold Kvistad Date: Tue, 28 Apr 2026 23:23:45 +0200 Subject: [PATCH] tui(approve): v toggles unified <-> side-by-side diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Side-by-side renders old | new in two 50% columns separated by '│'. Consecutive del/add hunks are paired row-by-row; a longer side gets blank rows on the shorter side. Context lines mirror on both sides. Footer now lists 'v toggle side-by-side'. --- src/tui/approve/Approve.tsx | 88 ++++++++++++++++++++++++++++++++++--- tests/tui/Approve.test.tsx | 27 ++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/tui/approve/Approve.tsx b/src/tui/approve/Approve.tsx index 3c74182..744ede7 100644 --- a/src/tui/approve/Approve.tsx +++ b/src/tui/approve/Approve.tsx @@ -16,8 +16,13 @@ export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps 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; + const [sideBySide, setSideBySide] = React.useState(false); useInput((input, key) => { + if (input === 'v') { + setSideBySide((v) => !v); + return; + } if (input === 'y') return onDecision('accept'); if (input === 'n' || input === 'q' || key.escape) return onDecision('reject'); }); @@ -27,18 +32,24 @@ export function Approve({ fileName, oldText, newText, onDecision }: ApproveProps ─ Approve change? {fileName} ─── + {adds} lines, − {dels} lines ─ - - {hunks.map((h, i) => ( - - ))} - + {sideBySide ? : } - y accept n reject q reject & quit ESC reject + y accept n reject v toggle side-by-side q reject & quit ESC reject ); } +function UnifiedView({ hunks }: { hunks: Hunk[] }): React.ReactElement { + return ( + + {hunks.map((h, i) => ( + + ))} + + ); +} + 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; @@ -49,3 +60,68 @@ function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement { ); } + +function pairForSideBySide(hunks: Hunk[]): Array<[Hunk | null, Hunk | null]> { + const out: Array<[Hunk | null, Hunk | null]> = []; + let i = 0; + while (i < hunks.length) { + if (hunks[i].kind === 'ctx') { + out.push([hunks[i], hunks[i]]); + i++; + continue; + } + const dels: Hunk[] = []; + const adds: Hunk[] = []; + while (i < hunks.length && hunks[i].kind !== 'ctx') { + if (hunks[i].kind === 'del') dels.push(hunks[i]); + else adds.push(hunks[i]); + i++; + } + const max = Math.max(dels.length, adds.length); + for (let j = 0; j < max; j++) { + out.push([dels[j] ?? null, adds[j] ?? null]); + } + } + return out; +} + +function SideBySide({ hunks }: { hunks: Hunk[] }): React.ReactElement { + const rows = React.useMemo(() => pairForSideBySide(hunks), [hunks]); + return ( + + {rows.map((row, i) => ( + + ))} + + ); +} + +function SideBySideRow({ left, right }: { left: Hunk | null; right: Hunk | null }): React.ReactElement { + return ( + + + + + + + + + + ); +} + +function HalfLine({ hunk, side }: { hunk: Hunk | null; side: 'left' | 'right' }): React.ReactElement { + if (!hunk) return ; + const color = + hunk.kind === 'add' ? 'green' : hunk.kind === 'del' ? 'red' : undefined; + const sigil = + hunk.kind === 'add' ? '+' : hunk.kind === 'del' ? '-' : ' '; + // For ctx, show ' '; for del on left, show '-'; for add on right, show '+'. + // (Cross-cell pollution like 'add' on the left side shouldn't happen given pairForSideBySide.) + void side; + return ( + + {sigil} {String(hunk.lineNo).padStart(4, ' ')} {hunk.text} + + ); +} diff --git a/tests/tui/Approve.test.tsx b/tests/tui/Approve.test.tsx index 129ebb5..e51c58d 100644 --- a/tests/tui/Approve.test.tsx +++ b/tests/tui/Approve.test.tsx @@ -61,6 +61,33 @@ describe('', () => { expect(decision).toHaveBeenCalledWith('reject'); }); + it('toggles to side-by-side on v and shows both halves', async () => { + const { stdin, lastFrame } = render( + {}} /> + ); + await flush(); + expect(lastFrame()).not.toContain('│'); + stdin.write('v'); + await flush(); + const out = lastFrame()!; + expect(out).toContain('│'); + expect(out).toContain('counter : INT := 0;'); + expect(out).toContain('counter : DINT := 0;'); + }); + + it('toggles back to unified on a second v', async () => { + const { stdin, lastFrame } = render( + {}} /> + ); + await flush(); + stdin.write('v'); + await flush(); + expect(lastFrame()).toContain('│'); + stdin.write('v'); + await flush(); + expect(lastFrame()).not.toContain('│'); + }); + it('calls onDecision("reject") on escape', async () => { const decision = vi.fn(); const { stdin } = render(