0
0
Fork 0

tui(approve): v toggles unified <-> side-by-side diff

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'.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-28 23:23:45 +02:00
parent f0b7852dbb
commit 2a743c29fa
2 changed files with 109 additions and 6 deletions

View file

@ -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
<Text>
Approve change? {fileName} + {adds} lines, {dels} lines
</Text>
<Box flexDirection="column">
{hunks.map((h, i) => (
<HunkLine key={i} hunk={h} />
))}
</Box>
{sideBySide ? <SideBySide hunks={hunks} /> : <UnifiedView hunks={hunks} />}
<Text>
y accept n reject q reject & quit ESC reject
y accept n reject v toggle side-by-side q reject &amp; quit ESC reject
</Text>
</Box>
);
}
function UnifiedView({ hunks }: { hunks: Hunk[] }): React.ReactElement {
return (
<Box flexDirection="column">
{hunks.map((h, i) => (
<HunkLine key={i} hunk={h} />
))}
</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;
@ -49,3 +60,68 @@ function HunkLine({ hunk }: { hunk: Hunk }): React.ReactElement {
</Text>
);
}
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 (
<Box flexDirection="column">
{rows.map((row, i) => (
<SideBySideRow key={i} left={row[0]} right={row[1]} />
))}
</Box>
);
}
function SideBySideRow({ left, right }: { left: Hunk | null; right: Hunk | null }): React.ReactElement {
return (
<Box flexDirection="row">
<Box width="50%">
<HalfLine hunk={left} side="left" />
</Box>
<Text> </Text>
<Box width="50%">
<HalfLine hunk={right} side="right" />
</Box>
</Box>
);
}
function HalfLine({ hunk, side }: { hunk: Hunk | null; side: 'left' | 'right' }): React.ReactElement {
if (!hunk) return <Text> </Text>;
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 (
<Text color={color}>
{sigil} {String(hunk.lineNo).padStart(4, ' ')} {hunk.text}
</Text>
);
}

View file

@ -61,6 +61,33 @@ describe('<Approve>', () => {
expect(decision).toHaveBeenCalledWith('reject');
});
it('toggles to side-by-side on v and shows both halves', async () => {
const { stdin, lastFrame } = render(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
);
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(
<Approve fileName="x.st" oldText={OLD} newText={NEW} onDecision={() => {}} />
);
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(