reviewit
Version:
A lightweight command-line tool that spins up a local web server to display Git commit diffs in a GitHub-like Files changed view
238 lines (237 loc) • 11.5 kB
JavaScript
import React, { useState } from 'react';
import { Box, Text, useInput, useApp } from 'ink';
import { parseDiff } from '../utils/parseDiff.js';
const SideBySideDiffViewer = ({ files, initialFileIndex, onBack, }) => {
const [currentFileIndex, setCurrentFileIndex] = useState(initialFileIndex);
const [scrollOffset, setScrollOffset] = useState(0);
const currentFile = files[currentFileIndex];
if (!currentFile || files.length === 0) {
return (React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
React.createElement(Text, { color: "yellow" }, "No files to display"),
React.createElement(Box, { marginTop: 1 },
React.createElement(Text, { dimColor: true }, "Press ESC or 'b' to go back"))));
}
const parsedDiff = parseDiff(currentFile.diff);
// Calculate total lines for current file
const allLines = [];
parsedDiff.chunks.forEach((chunk) => {
// Add chunk header
allLines.push({
old: chunk.header,
new: chunk.header,
type: 'header',
});
let oldIdx = 0;
let newIdx = 0;
while (oldIdx < chunk.lines.length || newIdx < chunk.lines.length) {
const oldLine = chunk.lines[oldIdx];
const newLine = chunk.lines[newIdx];
if (oldLine?.type === 'remove' && newLine?.type === 'add') {
// Same line modified - show side by side
allLines.push({
old: oldLine.content,
new: newLine.content,
oldNum: oldLine.oldLineNumber,
newNum: newLine.newLineNumber,
type: 'modified',
});
oldIdx++;
newIdx++;
}
else if (oldLine?.type === 'remove') {
// Line removed
allLines.push({
old: oldLine.content,
oldNum: oldLine.oldLineNumber,
type: 'remove',
});
oldIdx++;
}
else if (newLine?.type === 'add') {
// Line added
allLines.push({
new: newLine.content,
newNum: newLine.newLineNumber,
type: 'add',
});
newIdx++;
}
else if (oldLine?.type === 'context') {
// Unchanged line
allLines.push({
old: oldLine.content,
new: oldLine.content,
oldNum: oldLine.oldLineNumber,
newNum: oldLine.newLineNumber,
type: 'context',
});
oldIdx++;
newIdx++;
}
else {
oldIdx++;
newIdx++;
}
}
});
const viewportHeight = Math.max(10, (process.stdout.rows || 24) - 10); // StatusBar(3) + file nav(3) + footer(3) + margin(1)
const maxScroll = Math.max(0, allLines.length - viewportHeight);
const { exit } = useApp();
useInput((input, key) => {
if (input === 'q' || (key.ctrl && input === 'c')) {
exit();
return;
}
if (key.escape || input === 'b') {
onBack();
return;
}
// Scroll within file
if (key.upArrow || input === 'k') {
setScrollOffset((prev) => Math.max(0, prev - 1));
}
if (key.downArrow || input === 'j') {
setScrollOffset((prev) => Math.min(maxScroll, prev + 1));
}
if (key.pageUp) {
setScrollOffset((prev) => Math.max(0, prev - viewportHeight));
}
if (key.pageDown) {
setScrollOffset((prev) => Math.min(maxScroll, prev + viewportHeight));
}
// Navigate between files
if (key.tab && !key.shift) {
// Next file (loop to first when at end)
setCurrentFileIndex((currentFileIndex + 1) % files.length);
setScrollOffset(0);
}
if (key.tab && key.shift) {
// Previous file (loop to last when at start)
setCurrentFileIndex((currentFileIndex - 1 + files.length) % files.length);
setScrollOffset(0);
}
}, { isActive: true });
const visibleLines = allLines.slice(scrollOffset, scrollOffset + viewportHeight);
const terminalWidth = process.stdout.columns || 80;
const columnWidth = Math.floor((terminalWidth - 6) / 2); // 6 for borders and separators
const getLineColor = (type) => {
switch (type) {
case 'add':
return 'green';
case 'remove':
return 'red';
case 'modified':
return undefined; // Will be handled separately for each side
case 'header':
return 'cyan';
default:
return undefined;
}
};
const truncateLine = (line, width) => {
if (line.length <= width)
return line.padEnd(width);
return line.substring(0, width - 1) + '…';
};
return (React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
React.createElement(Box, { marginBottom: 1, flexDirection: "column" },
React.createElement(Box, null,
React.createElement(Text, { bold: true },
currentFile.path,
" (",
currentFileIndex + 1,
"/",
files.length,
")"),
React.createElement(Text, { dimColor: true },
' ',
"- ",
currentFile.additions,
" additions, ",
currentFile.deletions,
" deletions")),
React.createElement(Box, { height: 2, overflow: "hidden", flexDirection: "column" }, (() => {
const terminalWidth = process.stdout.columns || 80;
const maxWidth = terminalWidth - 4; // Leave some margin
// Generate file list with current file highlighted
const fileItems = [];
// Add files before current
for (let i = Math.max(0, currentFileIndex - 2); i < currentFileIndex; i++) {
fileItems.push({
text: files[i].path,
isActive: false,
});
}
// Add current file
fileItems.push({
text: `[${files[currentFileIndex].path}]`,
isActive: true,
});
// Add files after current
for (let i = currentFileIndex + 1; i < Math.min(files.length, currentFileIndex + 3); i++) {
fileItems.push({
text: files[i].path,
isActive: false,
});
}
// Build lines (max 2 lines)
const lines = [[]];
let currentLineWidth = 0;
for (const item of fileItems) {
const itemWidth = item.text.length + 3; // Include separator
if (currentLineWidth + itemWidth > maxWidth && lines.length < 2) {
lines.push([]);
currentLineWidth = 0;
}
if (lines.length <= 2) {
lines[lines.length - 1].push(item);
currentLineWidth += itemWidth;
}
}
return lines.map((line, lineIndex) => (React.createElement(Box, { key: lineIndex }, line.map((item, itemIndex) => (React.createElement(React.Fragment, { key: itemIndex },
itemIndex > 0 && React.createElement(Text, { dimColor: true }, " | "),
React.createElement(Text, { color: item.isActive ? 'cyan' : undefined, dimColor: !item.isActive }, item.text)))))));
})())),
React.createElement(Box, { borderStyle: "single", flexDirection: "column", flexGrow: 1 },
React.createElement(Box, { borderStyle: "single", borderTop: false, borderLeft: false, borderRight: false },
React.createElement(Box, { width: columnWidth },
React.createElement(Text, { dimColor: true }, " \u2502 "),
React.createElement(Text, { bold: true }, "Old")),
React.createElement(Text, { dimColor: true }, " \u2503 "),
React.createElement(Box, { width: columnWidth },
React.createElement(Text, { dimColor: true }, " \u2502 "),
React.createElement(Text, { bold: true }, "New"))),
React.createElement(Box, { flexDirection: "column", flexGrow: 1 }, visibleLines.map((line, index) => (React.createElement(Box, { key: `line-${scrollOffset + index}` },
React.createElement(Box, { width: columnWidth },
React.createElement(Text, { dimColor: true }, line.oldNum ? String(line.oldNum).padStart(4) : ' '),
React.createElement(Text, { dimColor: true }, " \u2502 "),
React.createElement(Text, { color: line.type === 'remove' || line.type === 'modified' ? 'red' : undefined, dimColor: line.type === 'header' }, line.type === 'remove' || line.type === 'modified' ? '- ' : ' '),
React.createElement(Text, { color: line.type === 'remove' || line.type === 'modified'
? 'red'
: getLineColor(line.type) }, line.old
? truncateLine(line.old, columnWidth - 10)
: ' '.repeat(columnWidth - 10))),
React.createElement(Text, { dimColor: true }, " \u2503 "),
React.createElement(Box, { width: columnWidth },
React.createElement(Text, { dimColor: true }, line.newNum ? String(line.newNum).padStart(4) : ' '),
React.createElement(Text, { dimColor: true }, " \u2502 "),
React.createElement(Text, { color: line.type === 'add' || line.type === 'modified' ? 'green' : undefined, dimColor: line.type === 'header' }, line.type === 'add' || line.type === 'modified' ? '+ ' : ' '),
React.createElement(Text, { color: line.type === 'add' || line.type === 'modified'
? 'green'
: getLineColor(line.type) }, line.new
? truncateLine(line.new, columnWidth - 10)
: ' '.repeat(columnWidth - 10)))))))),
React.createElement(Box, { marginTop: 1, justifyContent: "space-between" },
React.createElement(Text, { dimColor: true },
"Lines ",
scrollOffset + 1,
"-",
Math.min(scrollOffset + viewportHeight, allLines.length),
" of",
' ',
allLines.length,
scrollOffset + viewportHeight < allLines.length &&
` (${allLines.length - scrollOffset - viewportHeight} more)`),
React.createElement(Text, { dimColor: true }, "Tab: next file | Shift+Tab: prev file | \u2191\u2193/jk: scroll | ESC/b: back"))));
};
export default SideBySideDiffViewer;