Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
689a8bd
Add interactive TUI mode with file tree side panel
armanarutiunov Feb 11, 2026
4d11f97
Add per-file addition/deletion line counts
armanarutiunov Feb 11, 2026
c434705
Add dir, border-focused, additions, deletions theme colors
armanarutiunov Feb 11, 2026
0575e48
Rewrite file tree as nested expandable tree with file icons and +N -M…
armanarutiunov Feb 11, 2026
92ae324
Add focus border indicator, ctrl+d/u half-page scroll, update sync fo…
armanarutiunov Feb 11, 2026
53d1685
Match folder color to file name header, darken tree background
armanarutiunov Feb 11, 2026
e5aed4e
Add git staging status detection with green/orange file coloring
armanarutiunov Feb 11, 2026
2cc4e2a
Add staged/partial-staged/file-selected theme colors, fix span ordering
armanarutiunov Feb 11, 2026
4096f5d
Use background-only selection for files, keep inverse for dirs
armanarutiunov Feb 11, 2026
c06368d
Fix dead ternary in FileTreePanel, use execFileSync in gitStatus
armanarutiunov Feb 12, 2026
9a533ca
Deduplicate side-by-side diff parser into single iterSideBySideDiffEv…
armanarutiunov Feb 12, 2026
6eb9fa7
Add Windows fallback for interactive mode, deduplicate TREE_WIDTH/BOR…
armanarutiunov Feb 12, 2026
df4fa57
Fix stale rendering artifacts with ERASE_TO_EOL and screen clear
armanarutiunov Feb 12, 2026
4086e02
Add 'e' key tree toggle and dynamic diff re-render on terminal resize
armanarutiunov Feb 12, 2026
a608033
Fix ttyFd leak, remove dead params, restore comments, export helpers
armanarutiunov Feb 12, 2026
211ffe4
Add unit tests for TUI helpers
armanarutiunov Feb 12, 2026
0be5f85
Make tree width configurable via split-diffs.tree-width
armanarutiunov Feb 12, 2026
6f29eee
Add 'f' key to toggle flat/folder file tree mode
armanarutiunov Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Deduplicate side-by-side diff parser into single iterSideBySideDiffEv…
…ents

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
  • Loading branch information
armanarutiunov and claude committed Feb 12, 2026
commit 9a533caf71883e3dfcc0230db8fdbec83aabdbd0
294 changes: 8 additions & 286 deletions src/iterSideBySideDiffs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,300 +34,22 @@ type State =
| 'combined-diff-hunk-header'
| 'combined-diff-hunk-body';

async function* iterSideBySideDiffsFormatted(
context: Context,
lines: AsyncIterable<string>
): AsyncIterable<FormattedString> {
const { HORIZONTAL_SEPARATOR } = context;

let state: State = 'unknown';

// Commit metadata
let isFirstCommitBodyLine = false;

// File metadata
let fileNameA: string = '';
let fileNameB: string = '';
function* yieldFileName() {
yield* iterFormatFileName(context, fileNameA, fileNameB);
}

// Hunk metadata
let hunkParts: HunkPart[] = [];
let hunkHeaderLine: string = '';
async function* yieldHunk(diffType: 'unified-diff' | 'combined-diff') {
yield* iterFormatHunk(context, diffType, hunkHeaderLine, hunkParts);
for (const hunkPart of hunkParts) {
hunkPart.startLineNo = -1;
hunkPart.lines = [];
}
}

async function* flushPending() {
if (state === 'unified-diff' || state === 'combined-diff') {
yield* yieldFileName();
} else if (state === 'unified-diff-hunk-body') {
yield* yieldHunk('unified-diff');
} else if (state === 'combined-diff-hunk-body') {
yield* yieldHunk('combined-diff');
}
}

for await (const rawLine of lines) {
const line = rawLine.replace(ANSI_COLOR_CODE_REGEX, '');

// Update state
let nextState: State | null = null;
if (line.startsWith('commit ')) {
nextState = 'commit-header';
} else if (state === 'commit-header' && line.startsWith(' ')) {
nextState = 'commit-body';
} else if (line.startsWith('diff --git')) {
nextState = 'unified-diff';
} else if (line.startsWith('@@ ')) {
nextState = 'unified-diff-hunk-header';
} else if (state === 'unified-diff-hunk-header') {
nextState = 'unified-diff-hunk-body';
} else if (
line.startsWith('diff --cc') ||
line.startsWith('diff --combined')
) {
nextState = 'combined-diff';
} else if (COMBINED_HUNK_HEADER_START_REGEX.test(line)) {
nextState = 'combined-diff-hunk-header';
} else if (state === 'combined-diff-hunk-header') {
nextState = 'combined-diff-hunk-body';
} else if (
state === 'commit-body' &&
line.length > 0 &&
!line.startsWith(' ')
) {
nextState = 'unknown';
}

// Handle state starts
if (nextState) {
yield* flushPending();

switch (nextState) {
case 'commit-header':
if (
state === 'unified-diff-hunk-header' ||
state === 'unified-diff-hunk-body'
) {
yield HORIZONTAL_SEPARATOR;
}
break;
case 'unified-diff':
fileNameA = '';
fileNameB = '';
break;
case 'unified-diff-hunk-header':
hunkParts = [
{ fileName: fileNameA, startLineNo: -1, lines: [] },
{ fileName: fileNameB, startLineNo: -1, lines: [] },
];
break;
case 'commit-body':
isFirstCommitBodyLine = true;
break;
}

state = nextState;
}

// Handle state
switch (state) {
case 'unknown': {
yield T().appendString(rawLine);
break;
}
case 'commit-header': {
yield* iterFormatCommitHeaderLine(context, line);
break;
}
case 'commit-body': {
yield* iterFormatCommitBodyLine(
context,
line,
isFirstCommitBodyLine
);
isFirstCommitBodyLine = false;
break;
}
case 'unified-diff':
case 'combined-diff': {
if (line.startsWith('--- a/')) {
fileNameA = line.slice('--- a/'.length);
} else if (line.startsWith('+++ b/')) {
fileNameB = line.slice('+++ b/'.length);
} else if (line.startsWith('--- ')) {
fileNameA = line.slice('--- '.length);
// https://git-scm.com/docs/diff-format says that
// `/dev/null` is used to indicate creations and deletions,
// so we can special case it.
if (fileNameA === '/dev/null') {
fileNameA = '';
}
} else if (line.startsWith('+++ ')) {
fileNameB = line.slice('+++ '.length);
if (fileNameB === '/dev/null') {
fileNameB = '';
}
} else if (line.startsWith('rename from ')) {
fileNameA = line.slice('rename from '.length);
} else if (line.startsWith('rename to ')) {
fileNameB = line.slice('rename to '.length);
} else if (line.startsWith('Binary files')) {
const match = line.match(BINARY_FILES_DIFF_REGEX);
if (match) {
[, fileNameA, fileNameB] = match;
}
}
break;
}
case 'unified-diff-hunk-header': {
const hunkHeaderStart = line.indexOf('@@ ');
const hunkHeaderEnd = line.indexOf(' @@', hunkHeaderStart + 1);
assert.ok(hunkHeaderStart >= 0);
assert.ok(hunkHeaderEnd > hunkHeaderStart);
const hunkHeader = line.slice(
hunkHeaderStart + 3,
hunkHeaderEnd
);
hunkHeaderLine = line;

const [aHeader, bHeader] = hunkHeader.split(' ');
const [startAString] = aHeader.split(',');
const [startBString] = bHeader.split(',');

assert.ok(startAString.startsWith('-'));
hunkParts[0].startLineNo = parseInt(startAString.slice(1), 10);

assert.ok(startBString.startsWith('+'));
hunkParts[1].startLineNo = parseInt(startBString.slice(1), 10);
break;
}
case 'unified-diff-hunk-body': {
const [{ lines: hunkLinesA }, { lines: hunkLinesB }] =
hunkParts;
if (line.startsWith('-')) {
hunkLinesA.push(line);
} else if (line.startsWith('+')) {
hunkLinesB.push(line);
} else {
while (hunkLinesA.length < hunkLinesB.length) {
hunkLinesA.push(null);
}
while (hunkLinesB.length < hunkLinesA.length) {
hunkLinesB.push(null);
}
hunkLinesA.push(line);
hunkLinesB.push(line);
}
break;
}
case 'combined-diff-hunk-header': {
const match = COMBINED_HUNK_HEADER_START_REGEX.exec(line);
assert.ok(match);
const hunkHeaderStart = match.index + match[0].length; // End of the opening "@@@ "
const hunkHeaderEnd = line.lastIndexOf(' ' + match[1]); // Start of the closing " @@@"
assert.ok(hunkHeaderStart >= 0);
assert.ok(hunkHeaderEnd > hunkHeaderStart);
const hunkHeader = line.slice(hunkHeaderStart, hunkHeaderEnd);
hunkHeaderLine = line;

const fileRanges = hunkHeader.split(' ');
hunkParts = [];
for (let i = 0; i < fileRanges.length; i++) {
const fileRange = fileRanges[i];
const [fileRangeStart] = fileRange.slice(1).split(',');
hunkParts.push({
fileName:
i === fileRanges.length - 1 ? fileNameB : fileNameA,
startLineNo: parseInt(fileRangeStart, 10),
lines: [],
});
}
break;
}
case 'combined-diff-hunk-body': {
// A combined diff works differently from a unified diff. See
// https://git-scm.com/docs/git-diff#_combined_diff_format for
// details, but essentially we get a row of prefixes in each
// line indicating whether the line is present on the parent,
// the current commit, or both. We convert this into N+1 parts
// (for N parents) where the first part shows the current state
// and the rest show changes made in the corresponding parent.
const linePrefix = line.slice(0, hunkParts.length - 1);
const lineSuffix = line.slice(hunkParts.length - 1);
const isLineAdded = linePrefix.includes('+');
const isLineRemoved = linePrefix.includes('-');

// First N parts show changes made in the corresponding parent
// Either the line is going to be:
// 1. In the current commit and missing in some parents, which
// will have + prefixes, or
// 2. Missing in the current commit and present in some parents,
// which will have - prefixes.
// 3. Present in all commits, which will all have a space
// prefix.
let i = 0;
while (i < hunkParts.length - 1) {
const hunkPart = hunkParts[i];
const partPrefix = linePrefix[i];
if (isLineAdded) {
if (partPrefix === '+') {
hunkPart.lines.push(null);
} else {
hunkPart.lines.push('+' + lineSuffix);
}
} else if (isLineRemoved) {
if (partPrefix === '-') {
hunkPart.lines.push('-' + lineSuffix);
} else {
hunkPart.lines.push(null);
}
} else {
hunkPart.lines.push(' ' + lineSuffix);
}
i++;
}
// Final part shows the current state, so we just display the
// lines that exist in it without any highlighting.
if (isLineRemoved) {
hunkParts[i].lines.push('-' + lineSuffix);
} else if (isLineAdded) {
hunkParts[i].lines.push('+' + lineSuffix);
} else {
hunkParts[i].lines.push(' ' + lineSuffix);
}

break;
}
}
}

yield* flushPending();
}
export type DiffEvent =
| { type: 'line'; content: FormattedString }
| { type: 'file-start'; fileNameA: string; fileNameB: string; additions: number; deletions: number };

export async function* iterSideBySideDiffs(
context: Context,
lines: AsyncIterable<string>
) {
for await (const formattedString of iterSideBySideDiffsFormatted(
context,
lines
)) {
yield applyFormatting(context, formattedString);
for await (const event of iterSideBySideDiffEvents(context, lines)) {
if (event.type === 'line') {
yield applyFormatting(context, event.content);
}
}
}

export type DiffEvent =
| { type: 'line'; content: FormattedString }
| { type: 'file-start'; fileNameA: string; fileNameB: string; additions: number; deletions: number };

export async function* iterSideBySideDiffsWithEvents(
export async function* iterSideBySideDiffEvents(
context: Context,
lines: AsyncIterable<string>
): AsyncIterable<DiffEvent> {
Expand Down
45 changes: 41 additions & 4 deletions src/tui/collectDiffData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { iterlinesFromReadable } from '../iterLinesFromReadable';
import { iterReplaceTabsWithSpaces } from '../iterReplaceTabsWithSpaces';
import {
DiffEvent,
iterSideBySideDiffsWithEvents,
iterSideBySideDiffEvents,
} from '../iterSideBySideDiffs';

import { StagingStatus } from './gitStatus';
Expand All @@ -24,6 +24,7 @@ export interface DiffData {
files: DiffFile[];
allRenderedLines: string[];
fileBoundaries: number[];
rawLines: string[];
}

function getDisplayName(fileNameA: string, fileNameB: string): string {
Expand All @@ -33,22 +34,58 @@ function getDisplayName(fileNameA: string, fileNameB: string): string {
return `${fileNameA} → ${fileNameB}`;
}

/**
* Re-render diff lines from raw input with a given context.
* Used for dynamic width changes (terminal resize, tree toggle).
*/
export async function rerenderDiffLines(
context: Context,
rawLines: string[]
): Promise<{ allRenderedLines: string[]; fileBoundaries: number[] }> {
const allRenderedLines: string[] = [];
const fileBoundaries: number[] = [];

async function* iterLines() {
for (const line of rawLines) yield line;
}

const events = iterSideBySideDiffEvents(context, iterLines());
for await (const event of events) {
if (event.type === 'file-start') {
fileBoundaries.push(allRenderedLines.length);
} else {
allRenderedLines.push(applyFormatting(context, event.content));
}
}

return { allRenderedLines, fileBoundaries };
}

export async function collectDiffData(
context: Context,
input: Readable
): Promise<DiffData> {
const files: DiffFile[] = [];
const allRenderedLines: string[] = [];
const fileBoundaries: number[] = [];
const rawLines: string[] = [];

const lines = iterReplaceTabsWithSpaces(
context,
iterlinesFromReadable(input)
);

const events: AsyncIterable<DiffEvent> = iterSideBySideDiffsWithEvents(
// Buffer raw lines for later re-rendering
async function* captureLines() {
for await (const line of lines) {
rawLines.push(line);
yield line;
}
}

const events: AsyncIterable<DiffEvent> = iterSideBySideDiffEvents(
context,
lines
captureLines()
);

for await (const event of events) {
Expand All @@ -68,5 +105,5 @@ export async function collectDiffData(
}
}

return { files, allRenderedLines, fileBoundaries };
return { files, allRenderedLines, fileBoundaries, rawLines };
}