Release v1.01 Enhanced: Vi Control, TUI Gen5, Core Stability
This commit is contained in:
@@ -4,53 +4,169 @@ 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
|
||||
}) => {
|
||||
// Generate diff objects
|
||||
// [{ value: 'line', added: boolean, removed: boolean }]
|
||||
const diff = Diff.diffLines(original, modified);
|
||||
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 totalLines = diff.reduce((acc, part) => acc + part.value.split('\n').length - 1, 0);
|
||||
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) => {
|
||||
if (key.upArrow) {
|
||||
setScrollTop(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setScrollTop(prev => Math.min(totalLines - visibleLines, prev + 1));
|
||||
}
|
||||
if (key.pageUp) {
|
||||
setScrollTop(prev => Math.max(0, prev - visibleLines));
|
||||
}
|
||||
if (key.pageDown) {
|
||||
setScrollTop(prev => Math.min(totalLines - visibleLines, prev + visibleLines));
|
||||
const maxScroll = Math.max(0, totalLines - visibleLines);
|
||||
|
||||
if (key.tab) {
|
||||
setMode(m => (m === 'diff' ? 'hunks' : 'diff'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === 'y' || input === 'Y' || key.return) {
|
||||
onApply();
|
||||
}
|
||||
if (input === 'n' || input === 'N' || key.escape) {
|
||||
onSkip();
|
||||
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 = [];
|
||||
|
||||
diff.forEach((part) => {
|
||||
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();
|
||||
@@ -94,11 +210,30 @@ const DiffView = ({
|
||||
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 }, `Lines: ${totalLines} | Changes: ${diff.filter(p => p.added || p.removed).length} blocks`),
|
||||
h(Text, { color: 'blue' }, 'UP/DOWN to scroll')
|
||||
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
|
||||
@@ -117,8 +252,10 @@ const DiffView = ({
|
||||
justifyContent: 'center',
|
||||
gap: 4
|
||||
},
|
||||
h(Text, { color: 'green', bold: true }, '[Y] Apply Changes'),
|
||||
h(Text, { color: 'red', bold: true }, '[N] Discard/Skip')
|
||||
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')
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user