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:
parent
f0b7852dbb
commit
2a743c29fa
2 changed files with 109 additions and 6 deletions
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue