Files
OpenQode/bin/ui/components/DiffView.mjs

264 lines
9.8 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import * as Diff from 'diff';
const h = React.createElement;
const normalizeNewlines = (text) => String(text ?? '').replace(/\r\n/g, '\n');
const applySelectedHunks = (originalText, patch, enabledHunkIds) => {
const original = normalizeNewlines(originalText);
const hadTrailingNewline = original.endsWith('\n');
const originalLines = original.split('\n');
if (hadTrailingNewline) originalLines.pop();
const out = [];
let i = 0; // index into originalLines
const hunks = Array.isArray(patch?.hunks) ? patch.hunks : [];
for (let h = 0; h < hunks.length; h++) {
const hunk = hunks[h];
const id = `${hunk.oldStart}:${hunk.oldLines}->${hunk.newStart}:${hunk.newLines}`;
const enabled = enabledHunkIds.has(id);
const oldStartIdx = Math.max(0, (hunk.oldStart || 1) - 1);
const oldEndIdx = oldStartIdx + (hunk.oldLines || 0);
// Add unchanged segment before hunk
while (i < oldStartIdx && i < originalLines.length) {
out.push(originalLines[i]);
i++;
}
if (!enabled) {
// Keep original segment for this hunk range
while (i < oldEndIdx && i < originalLines.length) {
out.push(originalLines[i]);
i++;
}
continue;
}
// Apply hunk lines
const lines = Array.isArray(hunk.lines) ? hunk.lines : [];
for (const line of lines) {
if (!line) continue;
const prefix = line[0];
const content = line.slice(1);
if (prefix === ' ') {
// context line: consume from original and emit original content (safer than trusting patch line)
if (i < originalLines.length) {
out.push(originalLines[i]);
i++;
} else {
out.push(content);
}
} else if (prefix === '-') {
// deletion: consume original line, emit nothing
if (i < originalLines.length) i++;
} else if (prefix === '+') {
// addition: emit new content
out.push(content);
} else if (prefix === '\\') {
// "\ No newline at end of file" marker: ignore
} else {
// Unknown prefix: best-effort emit
out.push(line);
}
}
// After applying enabled hunk, ensure we've consumed the expected old range
i = Math.max(i, oldEndIdx);
}
// Append remaining original
while (i < originalLines.length) {
out.push(originalLines[i]);
i++;
}
const joined = out.join('\n') + (hadTrailingNewline ? '\n' : '');
return joined;
};
const DiffView = ({
original = '',
modified = '',
file = 'unknown.js',
onApply,
onApplyAndOpen,
onApplyAndTest,
onSkip,
width = 80,
height = 20
}) => {
const normalizedOriginal = normalizeNewlines(original);
const normalizedModified = normalizeNewlines(modified);
const patch = Diff.structuredPatch(file, file, normalizedOriginal, normalizedModified, '', '', { context: 3 });
const hunks = Array.isArray(patch?.hunks) ? patch.hunks : [];
const hunkIds = hunks.map(h => `${h.oldStart}:${h.oldLines}->${h.newStart}:${h.newLines}`);
const [enabledHunks, setEnabledHunks] = useState(() => new Set(hunkIds));
const [mode, setMode] = useState('diff'); // 'diff' | 'hunks'
const [activeHunkIndex, setActiveHunkIndex] = useState(0);
// Scroll state
const [scrollTop, setScrollTop] = useState(0);
// Calculate total lines for scrolling
const diffForRender = Diff.diffLines(normalizedOriginal, normalizedModified);
const totalLines = diffForRender.reduce((acc, part) => acc + part.value.split('\n').length - 1, 0);
const visibleLines = height - 4; // Header + Footer space
useInput((input, key) => {
const maxScroll = Math.max(0, totalLines - visibleLines);
if (key.tab) {
setMode(m => (m === 'diff' ? 'hunks' : 'diff'));
return;
}
if (mode === 'hunks') {
if (key.upArrow) setActiveHunkIndex(v => Math.max(0, v - 1));
if (key.downArrow) setActiveHunkIndex(v => Math.min(Math.max(0, hunks.length - 1), v + 1));
if (input.toLowerCase() === 't') {
const id = hunkIds[activeHunkIndex];
if (!id) return;
setEnabledHunks(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
if (input.toLowerCase() === 'a') {
setEnabledHunks(new Set(hunkIds));
}
if (input.toLowerCase() === 'x') {
setEnabledHunks(new Set());
}
if (key.escape) onSkip?.();
if (key.return) {
const nextContent = applySelectedHunks(normalizedOriginal, patch, enabledHunks);
onApply?.(nextContent);
}
return;
}
// diff scroll mode
if (key.upArrow) setScrollTop(prev => Math.max(0, prev - 1));
if (key.downArrow) setScrollTop(prev => Math.min(maxScroll, prev + 1));
if (key.pageUp) setScrollTop(prev => Math.max(0, prev - visibleLines));
if (key.pageDown) setScrollTop(prev => Math.min(maxScroll, prev + visibleLines));
const nextContent = applySelectedHunks(normalizedOriginal, patch, enabledHunks);
if (input === 'y' || input === 'Y' || key.return) onApply?.(nextContent);
if (input === 'r' || input === 'R') onApplyAndOpen?.(nextContent);
if (input === 'v' || input === 'V') onApplyAndTest?.(nextContent);
if (input === 'n' || input === 'N' || key.escape) onSkip?.();
});
// Render Logic
let currentLine = 0;
const renderedLines = [];
diffForRender.forEach((part) => {
const lines = part.value.split('\n');
// last element of split is often empty if value ends with newline
if (lines[lines.length - 1] === '') lines.pop();
lines.forEach((line) => {
currentLine++;
// Check visibility
if (currentLine <= scrollTop || currentLine > scrollTop + visibleLines) {
return;
}
let color = 'gray'; // Unchanged
let prefix = ' ';
let bg = undefined;
if (part.added) {
color = 'green';
prefix = '+ ';
} else if (part.removed) {
color = 'red';
prefix = '- ';
}
renderedLines.push(
h(Box, { key: currentLine, width: '100%' },
h(Text, { color: 'gray', dimColor: true }, `${currentLine.toString().padEnd(4)} `),
h(Text, { color: color, backgroundColor: bg, wrap: 'truncate-end' }, prefix + line)
)
);
});
});
return h(Box, {
flexDirection: 'column',
width: width,
height: height,
borderStyle: 'double',
borderColor: 'yellow'
},
// Header
h(Box, { flexDirection: 'column', paddingX: 1, borderStyle: 'single', borderBottom: true, borderTop: false, borderLeft: false, borderRight: false },
h(Text, { bold: true, color: 'yellow' }, `Reviewing: ${file}`),
h(Box, { justifyContent: 'space-between' },
h(Text, { dimColor: true }, `Hunks: ${hunks.length} | Selected: ${enabledHunks.size} | Lines: ${totalLines}`),
h(Text, { color: 'blue' }, mode === 'hunks' ? 'TAB diff | T toggle | A all | X none' : 'UP/DOWN scroll | TAB hunks | R reopen | V test')
)
),
mode === 'hunks'
? h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 1, paddingTop: 1 },
hunks.length === 0
? h(Text, { color: 'gray' }, 'No hunks (files are identical).')
: hunks.slice(0, Math.max(1, height - 6)).map((hunk, idx) => {
const id = hunkIds[idx];
const enabled = enabledHunks.has(id);
const isActive = idx === activeHunkIndex;
const label = `${enabled ? '[x]' : '[ ]'} @@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
return h(Text, {
key: id,
color: isActive ? 'cyan' : (enabled ? 'green' : 'gray'),
backgroundColor: isActive ? 'black' : undefined,
bold: isActive,
wrap: 'truncate-end'
}, label);
})
)
:
// Diff Content
h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 1 },
renderedLines.length > 0
? renderedLines
: h(Text, { color: 'gray' }, 'No changes detected (Files are identical)')
),
// Footer Actions
h(Box, {
borderStyle: 'single',
borderTop: true,
borderBottom: false,
borderLeft: false,
borderRight: false,
paddingX: 1,
justifyContent: 'center',
gap: 4
},
h(Text, { color: 'green', bold: true }, '[Y/Enter] Apply Selected'),
h(Text, { color: 'cyan', bold: true }, '[R] Apply + Reopen'),
h(Text, { color: 'magenta', bold: true }, '[V] Apply + Run Tests'),
h(Text, { color: 'red', bold: true }, '[N/Esc] Skip')
)
);
};
export default DiffView;