Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,17 @@ them, run `rexdiff uninstall`.
- Update: when rexdiff shows a new version at the bottom, press `U`. Your
comments stay.

A long run of unchanged lines folds into one row. Three lines show beside each
change.

When the agent edits a file, the comments move with their lines. If the lines
are gone, rexdiff marks the comment as stale.

| Key | Action |
| --- | --- |
| `u` `b` `t` | uncommitted, branch, or last-turn changes |
| `]` `[` | next or previous change |
| `→` or click | show the unchanged lines of a fold (`⋯ 12 unmodified lines`) |
| `f` `F` | next or previous file |
| `c` | comment |
| `s` | send the comments to the agent |
Expand Down
18 changes: 18 additions & 0 deletions diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,21 @@ const (
// line numbers that start at 1. A value of 0 means that the row has no line
// on that side. Continued is true if the line continues the source lines of
// the line above, because [Options.Wrap] wrapped them.
//
// A long run of equal lines folds into the hunk header below it. For a
// hunk header, Fold is that run.
type Row struct {
Kind Kind
Old, New int
Continued bool
Fold Fold
}

// Fold is a run of equal lines that hides. Old and New are the line
// numbers of its first line. Lines is the number of lines, 0 if no line
// hides.
type Fold struct {
Old, New, Lines int
}

// Line returns the line number of the row on side s.
Expand All @@ -64,6 +75,9 @@ type Options struct {
// Wrap continues a line that is wider than Width on the next lines,
// instead of cutting it. Then Offset does not apply.
Wrap bool
// Show tells if the lines of a fold show. If Show is nil, no fold
// shows. RenderFile does not use it.
Show func(Fold) bool
// Signs marks a changed line with + or -. Without Signs, a bar in the
// colour of the line marks it.
Signs bool
Expand Down Expand Up @@ -96,6 +110,9 @@ func Render(path, old, new string, o Options) *Diff {
Width(o.Width).
TabWidth(tabWidth).
XOffset(o.Offset)
if o.Show != nil {
dv = dv.Show(func(f diffview.Fold) bool { return o.Show(Fold{f.Before, f.After, f.Lines}) })
}
if o.Split {
dv = dv.Split()
}
Expand All @@ -115,6 +132,7 @@ func Render(path, old, new string, o Options) *Diff {
Old: r.Before,
New: r.After,
Continued: r.Continued,
Fold: Fold{r.Fold.Before, r.Fold.After, r.Fold.Lines},
})
}
if len(d.Rows) > 0 {
Expand Down
164 changes: 164 additions & 0 deletions diff/fold_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package diff_test

import (
"fmt"
"slices"
"strings"
"testing"

"github.com/charmbracelet/x/ansi"

"github.com/rednafi/rexdiff/diff"
)

// numbered returns lines "line 1" to "line n", with the lines in upper
// case changed to "LINE n".
func numbered(n int, upper ...int) string {
var b strings.Builder
for i := 1; i <= n; i++ {
word := "line"
for _, u := range upper {
if u == i {
word = "LINE"
}
}
fmt.Fprintf(&b, "%s %d\n", word, i)
}
return b.String()
}

// layout writes the rows of d as one entry each: "@" and its fold for a
// hunk header, else the old and the new line number.
func layout(d *diff.Diff) []string {
var out []string
for _, r := range d.Rows {
if r.Kind == diff.KindHunk {
out = append(out, fmt.Sprintf("@%d/%d+%d", r.Fold.Old, r.Fold.New, r.Fold.Lines))
continue
}
out = append(out, fmt.Sprintf("%d:%d", r.Old, r.New))
}
return out
}

// TestFolds checks that a long run of equal lines folds into the hunk
// header below it. Three lines show beside each change. A fold that Show
// accepts shows all its lines.
func TestFolds(t *testing.T) {
old, new := numbered(40), numbered(40, 10, 30)
fold := func(old, new, n int) string { return fmt.Sprintf("@%d/%d+%d", old, new, n) }
lines := func(lo, hi int) []string {
var out []string
for i := lo; i <= hi; i++ {
out = append(out, fmt.Sprintf("%d:%d", i, i))
}
return out
}
change := func(n int) []string { return []string{fmt.Sprintf("%d:0", n), fmt.Sprintf("0:%d", n)} }
join := func(parts ...[]string) []string {
var out []string
for _, p := range parts {
out = append(out, p...)
}
return out
}
top, middle, tail := fold(1, 1, 6), fold(14, 14, 13), fold(34, 34, 7)
tests := []struct {
name string
show func(diff.Fold) bool
want []string
}{
{"all folded", nil, join(
[]string{top}, lines(7, 9), change(10), lines(11, 13),
[]string{middle}, lines(27, 29), change(30), lines(31, 33),
[]string{tail},
)},
{"middle shows", func(f diff.Fold) bool { return f.New == 14 }, join(
[]string{top}, lines(7, 9), change(10), lines(11, 29), change(30), lines(31, 33),
[]string{tail},
)},
{"top and tail show", func(f diff.Fold) bool { return f.New != 14 }, join(
[]string{fold(0, 0, 0)}, lines(1, 9), change(10), lines(11, 13),
[]string{middle}, lines(27, 29), change(30), lines(31, 40),
)},
{"all show", func(diff.Fold) bool { return true }, join(
[]string{fold(0, 0, 0)}, lines(1, 9), change(10), lines(11, 29), change(30), lines(31, 40),
)},
}
for _, tt := range tests {
d := diff.Render("f.txt", old, new, diff.Options{Width: 60, Show: tt.show})
if got := layout(d); strings.Join(got, " ") != strings.Join(tt.want, " ") {
t.Errorf("%s:\n got %v\nwant %v", tt.name, got, tt.want)
}
}

// The header tells how many lines hide. A fold at the end has no hunk
// header. In the split layout, the header goes on across the right
// side, and no line is wider than the screen.
headers := func(o diff.Options) string {
d := diff.Render("f.txt", old, new, o)
var out []string
for i, r := range d.Rows {
if w := ansi.StringWidth(d.Lines[i]); w > o.Width {
t.Errorf("split %v: line %d is %d cells wide", o.Split, i, w)
}
if r.Kind == diff.KindHunk {
out = append(out, strings.TrimSpace(ansi.Strip(d.Lines[i])))
}
}
return strings.Join(out, "\n")
}
want := "… … ⋯ 6 unmodified lines @@ -7,7 +7,7 @@\n" +
"… … ⋯ 13 unmodified lines @@ -27,7 +27,7 @@\n" +
"… … ⋯ 7 unmodified lines"
if got := headers(diff.Options{Width: 60}); got != want {
t.Errorf("headers:\n%s\nwant:\n%s", got, want)
}
want = "… ⋯ 6 unmodified lines @@ -7,7 +7,7 @@\n" +
"… ⋯ 13 unmodified lines @@ -27,7 +27,7 @@\n" +
"… ⋯ 7 unmodified lines"
if got := headers(diff.Options{Width: 60, Split: true}); got != want {
t.Errorf("split headers:\n%s\nwant:\n%s", got, want)
}
}

// TestFoldEdges checks the folds that show no line. One line between the
// margins of two changes shows, because a fold of one line saves no row.
// After an insert, the old and the new numbers of a fold differ. A file
// without equal lines has no fold.
func TestFoldEdges(t *testing.T) {
tests := []struct {
name string
old, new string
want string
}{
// Changes at 10 and 18 leave line 14 alone between the margins.
{"one line", numbered(21), numbered(21, 10, 18),
"@1/1+6 7:7 8:8 9:9 10:0 0:10 11:11 12:12 13:13 14:14 15:15 16:16 17:17 18:0 0:18 19:19 20:20 21:21"},
{"insert at the top", numbered(12), "new\n" + numbered(12),
"@0/0+0 0:1 1:2 2:3 3:4 @4/5+9"},
{"all new", "", "a\nb\n", "@0/0+0 0:1 0:2"},
}
for _, tt := range tests {
d := diff.Render("f.txt", tt.old, tt.new, diff.Options{Width: 80})
if got := strings.Join(layout(d), " "); got != tt.want {
t.Errorf("%s:\n got %s\nwant %s", tt.name, got, tt.want)
}
// The split layout has the same folds.
split := diff.Render("f.txt", tt.old, tt.new, diff.Options{Width: 80, Split: true})
if got, want := folds(split), folds(d); !slices.Equal(got, want) {
t.Errorf("%s: the split layout has folds %v, want %v", tt.name, got, want)
}
}
}

// folds returns the folds of the hunk headers of d.
func folds(d *diff.Diff) []diff.Fold {
var out []diff.Fold
for _, r := range d.Rows {
if r.Kind == diff.KindHunk {
out = append(out, r.Fold)
}
}
return out
}
3 changes: 3 additions & 0 deletions internal/VENDORED.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@
fields are unexported; `RowFile` removed
- a coloured bar `▌` marks a changed line, in place of crush's `+` and `-`; `Signs` gives
the signs back
- `fold.go` (added): the diff has the whole file in one hunk, and `foldHunk` folds the
long runs of equal lines, as in herdr-reviewr; `Show` shows a fold, and `Row.Fold` tells
which lines a hunk header hides
- the syntax cache key tells "no background" from black, so a black tint does not
leak onto equal lines
47 changes: 26 additions & 21 deletions internal/diffview/diffview.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ type View struct {
isComputed bool
err error
unified udiff.UnifiedDiff
folds []Fold // folds[i] hides above unified.Hunks[i]
show func(Fold) bool // the folds that show, see [View.Show]

splitHunks []splitHunk

Expand Down Expand Up @@ -201,13 +203,19 @@ func (v *View) computeDiff() error {
}
v.isComputed = true
edits := udiff.Lines(v.before.content, v.after.content)
// With a context as long as the file, the diff has one hunk with all
// the lines. foldHunk then hides the long runs of equal lines.
context := strings.Count(v.before.content, "\n") + 1
v.unified, v.err = udiff.ToUnifiedDiff(
v.before.path,
v.after.path,
v.before.content,
edits,
udiff.DefaultContextLines,
context,
)
if v.err == nil && len(v.unified.Hunks) == 1 {
v.unified.Hunks, v.folds = foldHunk(v.unified.Hunks[0], v.show)
}
return v.err
}

Expand Down Expand Up @@ -269,10 +277,10 @@ func codeWidthOf(l *udiff.Line) int {
// header in the unified layout.
func (v *View) detectUnifiedCodeWidth() {
v.codeWidth = 0
for _, h := range v.unified.Hunks {
headerWidth := ansi.StringWidth(hunkHeader(h))
for i, h := range v.unified.Hunks {
v.codeWidth = max(v.codeWidth, ansi.StringWidth(v.header(i)))
for _, l := range h.Lines {
v.codeWidth = max(v.codeWidth, codeWidthOf(&l), headerWidth)
v.codeWidth = max(v.codeWidth, codeWidthOf(&l))
}
}
}
Expand All @@ -282,13 +290,13 @@ func (v *View) detectUnifiedCodeWidth() {
func (v *View) detectSplitCodeWidth() {
v.codeWidth = 0
for i, h := range v.splitHunks {
headerWidth := ansi.StringWidth(hunkHeader(v.unified.Hunks[i]))
v.codeWidth = max(v.codeWidth, ansi.StringWidth(v.header(i)))
for _, l := range h.lines {
if l.before != nil {
v.codeWidth = max(v.codeWidth, codeWidthOf(l.before), headerWidth)
v.codeWidth = max(v.codeWidth, codeWidthOf(l.before))
}
if l.after != nil {
v.codeWidth = max(v.codeWidth, codeWidthOf(l.after), headerWidth)
v.codeWidth = max(v.codeWidth, codeWidthOf(l.after))
}
}
}
Expand Down Expand Up @@ -319,8 +327,8 @@ func (v *View) renderUnified() string {
return v.lineNum(ls, before, v.beforeNumDigits) +
v.lineNum(ls, after, v.afterNumDigits)
}
for _, h := range v.unified.Hunks {
b.WriteString(v.unifiedHeader(h) + "\n")
for i, h := range v.unified.Hunks {
b.WriteString(v.unifiedHeader(i) + "\n")
oldN, newN := h.FromLine, h.ToLine
for _, l := range h.Lines {
ls, sym := v.kindStyle(l.Kind)
Expand All @@ -344,10 +352,10 @@ func (v *View) renderUnified() string {
return b.String()
}

// unifiedHeader renders the divider line of hunk h in the unified layout.
func (v *View) unifiedHeader(h *udiff.Hunk) string {
// unifiedHeader renders the divider line of hunk i in the unified layout.
func (v *View) unifiedHeader(i int) string {
div := v.style.dividerLine
header := ansi.Truncate(hunkHeader(h), v.fullCodeWidth, ellipsis)
header := ansi.Truncate(v.header(i), v.fullCodeWidth, ellipsis)
return div.lineNumber.Render(pad(ellipsis, v.beforeNumDigits)) +
div.lineNumber.Render(pad(ellipsis, v.afterNumDigits)) +
div.code.Width(v.fullCodeWidth).Render(header)
Expand All @@ -359,7 +367,7 @@ func (v *View) renderSplit() string {
var b strings.Builder
afterWidth := v.codeWidth + btoi(v.extraColOnAfter)
for i, h := range v.splitHunks {
b.WriteString(v.splitHeader(v.unified.Hunks[i]) + "\n")
b.WriteString(v.splitHeader(i) + "\n")
oldN, newN := h.fromLine, h.toLine
for _, l := range h.lines {
left, leftFill := v.splitSide(l.before, oldN, v.beforeNumDigits, v.codeWidth)
Expand All @@ -382,16 +390,13 @@ func (v *View) renderSplit() string {
return b.String()
}

// splitHeader renders the divider line of hunk h in the split layout. The
// header shows on the left side only.
func (v *View) splitHeader(h *udiff.Hunk) string {
// splitHeader renders the divider line of hunk i in the split layout. The
// header starts on the left side and can go on across the right side.
func (v *View) splitHeader(i int) string {
div := v.style.dividerLine
header := ansi.Truncate(hunkHeader(h), v.fullCodeWidth, ellipsis)
afterWidth := v.fullCodeWidth + btoi(v.extraColOnAfter)
width := 2*v.fullCodeWidth + btoi(v.extraColOnAfter) + v.afterNumDigits + lineNumPadding*2
return div.lineNumber.Render(pad(ellipsis, v.beforeNumDigits)) +
div.code.Width(v.fullCodeWidth).Render(header) +
div.lineNumber.Render(pad(ellipsis, v.afterNumDigits)) +
div.code.Width(afterWidth).Render(" ")
div.code.Width(width).Render(ansi.Truncate(v.header(i), width, ellipsis))
}

// splitSide renders line l on one side of the split layout, one string for
Expand Down
Loading
Loading