diff --git a/appview/pulls/diff_hunks_test.go b/appview/pulls/diff_hunks_test.go new file mode 100644 index 00000000..22207c84 --- /dev/null +++ b/appview/pulls/diff_hunks_test.go @@ -0,0 +1,135 @@ +package pulls + +import ( + "testing" + + gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" +) + +func u32(v uint32) *uint32 { return &v } + +// lines returns n lines named l0..l{n-1}, optionally overriding some by index. +func lines(n int, override map[int]string) []string { + out := make([]string, n) + for i := range out { + if v, ok := override[i]; ok { + out[i] = v + } else { + out[i] = "l" + string(rune('0'+i%10)) + } + } + return out +} + +func hunk(pairs ...*gitmirrorv1.LinePair) *gitmirrorv1.Hunk { + return &gitmirrorv1.Hunk{Lines: pairs} +} + +func opCounts(h displayHunk) (ctx, del, add int) { + for _, r := range h.rows { + switch r.op { + case opContext: + ctx++ + case opDel: + del++ + case opAdd: + add++ + } + } + return +} + +func TestBuildHunks_SingleModification(t *testing.T) { + base := lines(10, map[int]string{5: "old"}) + head := lines(10, map[int]string{5: "new"}) + hunks := []*gitmirrorv1.Hunk{hunk(&gitmirrorv1.LinePair{Lhs: u32(5), Rhs: u32(5)})} + + got := buildHunks(base, head, hunks) + if len(got) != 1 { + t.Fatalf("want 1 hunk, got %d", len(got)) + } + // 3 leading + del + add + 3 trailing = 8 rows. + if len(got[0].rows) != 8 { + t.Fatalf("want 8 rows, got %d: %+v", len(got[0].rows), got[0].rows) + } + ctx, del, add := opCounts(got[0]) + if ctx != 6 || del != 1 || add != 1 { + t.Fatalf("want ctx=6 del=1 add=1, got ctx=%d del=%d add=%d", ctx, del, add) + } + // Leading context starts at line index 2 (5-3), paired on both sides. + r0 := got[0].rows[0] + if r0.op != opContext || r0.lhs != 2 || r0.rhs != 2 { + t.Fatalf("bad first context row: %+v", r0) + } + if got[0].rows[3].op != opDel || got[0].rows[3].lhs != 5 { + t.Fatalf("bad del row: %+v", got[0].rows[3]) + } +} + +func TestBuildHunks_MergeClose(t *testing.T) { + // Changes at index 3 and 8: gap of 4 unchanged lines <= 6, so merged into one hunk. + base := lines(12, map[int]string{3: "a", 8: "b"}) + head := lines(12, map[int]string{3: "A", 8: "B"}) + hunks := []*gitmirrorv1.Hunk{ + hunk(&gitmirrorv1.LinePair{Lhs: u32(3), Rhs: u32(3)}), + hunk(&gitmirrorv1.LinePair{Lhs: u32(8), Rhs: u32(8)}), + } + got := buildHunks(base, head, hunks) + if len(got) != 1 { + t.Fatalf("want 1 merged hunk, got %d", len(got)) + } + ctx, del, add := opCounts(got[0]) + // 3 leading + 4 between + 3 trailing = 10 context; 2 del; 2 add. + if ctx != 10 || del != 2 || add != 2 { + t.Fatalf("want ctx=10 del=2 add=2, got ctx=%d del=%d add=%d", ctx, del, add) + } +} + +func TestBuildHunks_SplitFar(t *testing.T) { + // Changes at index 3 and 20: gap 16 > 6, so two separate hunks. + base := lines(25, map[int]string{3: "a", 20: "b"}) + head := lines(25, map[int]string{3: "A", 20: "B"}) + hunks := []*gitmirrorv1.Hunk{ + hunk(&gitmirrorv1.LinePair{Lhs: u32(3), Rhs: u32(3)}), + hunk(&gitmirrorv1.LinePair{Lhs: u32(20), Rhs: u32(20)}), + } + got := buildHunks(base, head, hunks) + if len(got) != 2 { + t.Fatalf("want 2 hunks, got %d", len(got)) + } + for i, h := range got { + ctx, del, add := opCounts(h) + if ctx != 6 || del != 1 || add != 1 { + t.Fatalf("hunk %d: want ctx=6 del=1 add=1, got ctx=%d del=%d add=%d", i, ctx, del, add) + } + } +} + +func TestBuildHunks_PureInsertionAtTop(t *testing.T) { + base := lines(5, nil) + // Two lines inserted at the very top; head = 2 new + 5 base. + head := append([]string{"x", "y"}, base...) + hunks := []*gitmirrorv1.Hunk{ + hunk( + &gitmirrorv1.LinePair{Rhs: u32(0)}, + &gitmirrorv1.LinePair{Rhs: u32(1)}, + ), + } + got := buildHunks(base, head, hunks) + if len(got) != 1 { + t.Fatalf("want 1 hunk, got %d", len(got)) + } + // No leading context (clamped to start); 2 adds; up to 3 trailing context. + if got[0].rows[0].op != opAdd || got[0].rows[1].op != opAdd { + t.Fatalf("want first two rows to be adds, got %+v", got[0].rows[:2]) + } + ctx, del, add := opCounts(got[0]) + if del != 0 || add != 2 || ctx != 3 { + t.Fatalf("want ctx=3 del=0 add=2, got ctx=%d del=%d add=%d", ctx, del, add) + } + // Trailing context pairs base line 0 with head line 2 (the insertion offset). + first := got[0].rows[2] + if first.op != opContext || first.lhs != 0 || first.rhs != 2 { + t.Fatalf("bad trailing context row: %+v", first) + } +} diff --git a/appview/pulls/pull2.go b/appview/pulls/pull2.go index d970eb0c..78aec2fc 100644 --- a/appview/pulls/pull2.go +++ b/appview/pulls/pull2.go @@ -285,23 +285,133 @@ func (s *Pulls) PullDiffFragment(w http.ResponseWriter, r *http.Request) { // TODO: implement split view _ = unified - // TODO: implement context lines for _, f := range files { - params.Diff += f.diff.RhsSrc.Path + "\n\n" - for _, hunk := range f.diff.Hunks { + params.Diff += f.diff.RhsSrc.Path + "\n" + for _, h := range buildHunks(f.baseLines, f.headLines, f.diff.Hunks) { params.Diff += "@@@\n" - for _, line := range hunk.Lines { - if line.Lhs != nil { - params.Diff += fmt.Sprintf("%d\t\t - %s\n", *line.Lhs+1, f.baseLines[*line.Lhs]) + for _, row := range h.rows { + switch row.op { + case opContext: + params.Diff += fmt.Sprintf("%d\t%d\t %s\n", row.lhs+1, row.rhs+1, row.content) + case opDelete: + params.Diff += fmt.Sprintf("%d\t\t - %s\n", row.lhs+1, row.content) + case opAdd: + params.Diff += fmt.Sprintf("\t%d\t + %s\n", row.rhs+1, row.content) } } - for _, line := range hunk.Lines { - if line.Rhs != nil { - params.Diff += fmt.Sprintf("\t%d\t + %s\n", *line.Rhs+1, f.headLines[*line.Rhs]) - } + } + } +} + +type diffOp int + +const ( + opContext diffOp = iota + opDelete + opAdd +) + +type diffRow struct { + op diffOp + lhs int + rhs int + content string +} + +type displayHunk struct { + rows []diffRow +} + +const contextLines = 3 + +// buildHunks turns gitmirror's changed-lines-only hunks into display hunks that include +// up to contextLines of unchanged context around each change, merging hunks whose +// context windows overlap (gap <= 2*contextLines unchanged lines) into one display hunk. +// +// gitmirror hunks carry only changed lines (deletions + additions); context lines are +// shared content, so at any unchanged line baseLines[lhs] == headLines[rhs] and the two +// cursors advance together. We track li/ri (next unconsumed line per side) and derive a +// hunk's start on a side that has no lines from the other side via that invariant. +func buildHunks(baseLines, headLines []string, hunks []*gitmirrorv1.Hunk) []displayHunk { + var out []displayHunk + var cur *displayHunk + li, ri := 0, 0 + + // emitContext appends n unchanged rows starting at base line lx (paired head line rx). + emitContext := func(lx, rx, n int) { + for j := range n { + cur.rows = append(cur.rows, diffRow{ + op: opContext, + lhs: lx + j, + rhs: rx + j, + content: baseLines[lx+j], + }) + } + } + + for _, h := range hunks { + var lhsNums, rhsNums []int + for _, lp := range h.Lines { + if lp.Lhs != nil { + lhsNums = append(lhsNums, int(*lp.Lhs)) } + if lp.Rhs != nil { + rhsNums = append(rhsNums, int(*lp.Rhs)) + } + } + if len(lhsNums) == 0 && len(rhsNums) == 0 { + continue + } + + // Start of the changed block on each side; derive the empty side from the other. + var lhsStart, rhsStart int + switch { + case len(lhsNums) > 0 && len(rhsNums) > 0: + lhsStart, rhsStart = lhsNums[0], rhsNums[0] + case len(lhsNums) == 0: // pure insertion + rhsStart = rhsNums[0] + lhsStart = li + (rhsStart - ri) + default: // pure deletion + lhsStart = lhsNums[0] + rhsStart = ri + (lhsStart - li) + } + + gap := lhsStart - li // unchanged lines before this change (== rhsStart - ri) + + switch { + case cur == nil: + // First change (or just after a split): leading context only. + cur = &displayHunk{} + lead := min(gap, contextLines) + emitContext(lhsStart-lead, rhsStart-lead, lead) + case gap <= 2*contextLines: + // Close enough to merge: keep all the intervening lines as context. + emitContext(li, ri, gap) + default: + // Too far apart: close this hunk with trailing context, open a new one. + emitContext(li, ri, contextLines) + out = append(out, *cur) + cur = &displayHunk{} + emitContext(lhsStart-contextLines, rhsStart-contextLines, contextLines) + } + + // Changed lines: deletions then additions (matches the unified render order). + for _, ln := range lhsNums { + cur.rows = append(cur.rows, diffRow{op: opDelete, lhs: ln, content: baseLines[ln]}) + } + for _, ln := range rhsNums { + cur.rows = append(cur.rows, diffRow{op: opAdd, rhs: ln, content: headLines[ln]}) } + + li = lhsStart + len(lhsNums) + ri = rhsStart + len(rhsNums) + } + + if cur != nil { + emitContext(li, ri, min(len(baseLines)-li, contextLines)) + out = append(out, *cur) } + return out } // getBlob streams a blob's bytes from gitmirror by OID and concatenates them.