From 875375bfb14c9fd3a0ff0ae5c51d6d27f3019c48 Mon Sep 17 00:00:00 2001 From: Redowan Delowar Date: Sun, 25 Oct 2026 09:06:46 +0100 Subject: [PATCH] =?UTF-8?q?Fold=20long=20runs=20of=20unchanged=20lines,=20?= =?UTF-8?q?and=20show=20them=20on=20=E2=86=92=20or=20a=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diff showed only three lines around each change, and the rest of the file was not there. Now the diff has the whole file, and a long run of unchanged lines folds into one row, "⋯ 13 unmodified lines", as in herdr-reviewr. → or a click on the row shows the lines. On a line of code, → still scrolls sideways. A fold with a comment on one of its lines always shows, and ":" with a line in a fold shows the fold. A new scope folds the lines again. Fixes #2 --- README.md | 4 + diff/diff.go | 18 ++++ diff/fold_test.go | 164 ++++++++++++++++++++++++++++++++++ internal/VENDORED.md | 3 + internal/diffview/diffview.go | 47 +++++----- internal/diffview/fold.go | 95 ++++++++++++++++++++ internal/diffview/rows.go | 15 +++- tui/fold_test.go | 159 ++++++++++++++++++++++++++++++++ tui/keys.go | 5 ++ tui/model.go | 3 + tui/mouse.go | 4 + tui/nav.go | 49 +++++++++- tui/render.go | 4 +- tui/view.go | 1 + 14 files changed, 544 insertions(+), 27 deletions(-) create mode 100644 diff/fold_test.go create mode 100644 internal/diffview/fold.go create mode 100644 tui/fold_test.go diff --git a/README.md b/README.md index fdf0161..358b032 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ 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. @@ -51,6 +54,7 @@ are gone, rexdiff marks the comment as stale. | --- | --- | | `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 | diff --git a/diff/diff.go b/diff/diff.go index e44af78..e3d3610 100644 --- a/diff/diff.go +++ b/diff/diff.go @@ -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. @@ -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 @@ -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() } @@ -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 { diff --git a/diff/fold_test.go b/diff/fold_test.go new file mode 100644 index 0000000..d6c420c --- /dev/null +++ b/diff/fold_test.go @@ -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 +} diff --git a/internal/VENDORED.md b/internal/VENDORED.md index d6064ee..86d1146 100644 --- a/internal/VENDORED.md +++ b/internal/VENDORED.md @@ -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 diff --git a/internal/diffview/diffview.go b/internal/diffview/diffview.go index 1f5a5d9..e29339e 100644 --- a/internal/diffview/diffview.go +++ b/internal/diffview/diffview.go @@ -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 @@ -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 } @@ -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)) } } } @@ -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)) } } } @@ -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) @@ -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) @@ -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) @@ -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 diff --git a/internal/diffview/fold.go b/internal/diffview/fold.go new file mode 100644 index 0000000..62e3272 --- /dev/null +++ b/internal/diffview/fold.go @@ -0,0 +1,95 @@ +package diffview + +import ( + "fmt" + + "github.com/aymanbagabas/go-udiff" +) + +// foldMargin is the number of equal lines that show beside a change. A +// longer run of equal lines folds into one row, as in herdr-reviewr. +const foldMargin = 3 + +// Fold is a run of equal lines that hides. Before and After are the line +// numbers of its first line. Lines is the number of lines, 0 if no line +// hides. +type Fold struct { + Before, After, Lines int +} + +// Show shows the lines of each fold for which show returns true. +func (v *View) Show(show func(Fold) bool) *View { + v.show = show + return v +} + +// foldHunk splits h, the hunk of the whole file, into the hunks that show. +// folds[i] is the run of lines that hides above hunks[i]. If the last run +// of equal lines hides, the last hunk has no lines. A run hides if more +// than one of its lines is farther than foldMargin from a change, and show +// is nil or returns false for it. +func foldHunk(h *udiff.Hunk, show func(Fold) bool) (hunks []*udiff.Hunk, folds []Fold) { + n := len(h.Lines) + keep := make([]bool, n) + for i, l := range h.Lines { + if l.Kind != udiff.Equal { + for j := max(i-foldMargin, 0); j <= min(i+foldMargin, n-1); j++ { + keep[j] = true + } + } + } + var cur *udiff.Hunk + var above Fold + b, a := h.FromLine, h.ToLine + for i := 0; i < n; { + if !keep[i] { + j := i + for j < n && !keep[j] { + j++ + } + f := Fold{Before: b, After: a, Lines: j - i} + if f.Lines > 1 && (show == nil || !show(f)) { + above, cur = f, nil + b, a, i = b+f.Lines, a+f.Lines, j + continue + } + for k := i; k < j; k++ { + keep[k] = true // the run shows as a whole + } + } + if cur == nil { + cur = &udiff.Hunk{FromLine: b, ToLine: a} + hunks = append(hunks, cur) + folds = append(folds, above) + above = Fold{} + } + l := h.Lines[i] + cur.Lines = append(cur.Lines, l) + if l.Kind != udiff.Insert { + b++ + } + if l.Kind != udiff.Delete { + a++ + } + i++ + } + if above.Lines > 0 { + hunks = append(hunks, &udiff.Hunk{FromLine: b, ToLine: a}) + folds = append(folds, above) + } + return hunks, folds +} + +// header returns the header line of hunk i: the fold above it, if lines +// hide, and the hunk header, if the hunk has lines. For example +// " ⋯ 12 unmodified lines @@ -15,7 +15,8 @@ ". +func (v *View) header(i int) string { + h, s := v.unified.Hunks[i], "" + if f := v.folds[i]; f.Lines > 0 { + s = fmt.Sprintf(" ⋯ %d unmodified lines", f.Lines) + } + if len(h.Lines) == 0 { + return s + " " + } + return s + hunkHeader(h) +} diff --git a/internal/diffview/rows.go b/internal/diffview/rows.go index 3a3b433..5b7a7cc 100644 --- a/internal/diffview/rows.go +++ b/internal/diffview/rows.go @@ -16,10 +16,12 @@ const ( // Row tells which source lines one rendered line shows. Before and After are // line numbers from 1, or 0 if the row has no line on that side. Continued // is true for the second and the next screen lines of a wrapped line. +// For a hunk header, Fold is the run of lines that hides above it. type Row struct { Kind RowKind Before, After int Continued bool + Fold Fold } // rows returns one row for each line of the output of text. Call it after @@ -56,8 +58,8 @@ func (v *View) lineRows() []Row { // unifiedRows returns the rows of the unified layout. func (v *View) unifiedRows() []Row { var rows []Row - for _, h := range v.unified.Hunks { - rows = append(rows, Row{Kind: RowHunk}) + for i, h := range v.unified.Hunks { + rows = append(rows, v.headerRow(i)) b, a := h.FromLine, h.ToLine for _, l := range h.Lines { switch l.Kind { @@ -80,8 +82,8 @@ func (v *View) unifiedRows() []Row { // splitRows returns the rows of the split layout. func (v *View) splitRows() []Row { var rows []Row - for _, h := range v.splitHunks { - rows = append(rows, Row{Kind: RowHunk}) + for i, h := range v.splitHunks { + rows = append(rows, v.headerRow(i)) b, a := h.fromLine, h.toLine for _, l := range h.lines { r := Row{Kind: splitKind(l)} @@ -99,6 +101,11 @@ func (v *View) splitRows() []Row { return rows } +// headerRow returns the row of the header of hunk i. +func (v *View) headerRow(i int) Row { + return Row{Kind: RowHunk, Fold: v.folds[i]} +} + // splitKind returns the kind of the row of l in the split layout. func splitKind(l *splitLine) RowKind { switch { diff --git a/tui/fold_test.go b/tui/fold_test.go new file mode 100644 index 0000000..b980b56 --- /dev/null +++ b/tui/fold_test.go @@ -0,0 +1,159 @@ +package tui + +import ( + "fmt" + "slices" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/rednafi/rexdiff/diff" + "github.com/rednafi/rexdiff/vcs" +) + +// newFoldModel returns a model that shows f.txt, a file of 40 lines with +// changes at lines 10 and 30. The diff folds lines 1 to 6, 14 to 26, and +// 34 to 40. +func newFoldModel(t *testing.T) model { + t.Helper() + dir := t.TempDir() + lines := func(upper ...int) string { + var b strings.Builder + for i := 1; i <= 40; i++ { + word := "line" + if slices.Contains(upper, i) { + word = "LINE" + } + fmt.Fprintf(&b, "%s %d\n", word, i) + } + return b.String() + } + git(t, dir, "init", "-q", "-b", "main") + write(t, dir, "f.txt", lines()) + git(t, dir, "add", ".") + git(t, dir, "commit", "-qm", "init") + write(t, dir, "f.txt", lines(10, 30)) + repo, err := vcs.Open(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + m := newModel(t.Context(), Options{Repo: repo, Layout: LayoutUnified}, defaultConfig()) + m.reload() + return step(t, m, tea.WindowSizeMsg{Width: 100, Height: 70}) +} + +// foldsOf returns the number of lines of each fold that hides in p. +func foldsOf(p *pane) []int { + var out []int + for _, r := range p.rows { + if r.Fold.Lines > 0 { + out = append(out, r.Fold.Lines) + } + } + return out +} + +// rowOf returns the row of new line n in p, or -1. +func rowOf(p *pane, n int) int { + return slices.IndexFunc(p.rows, func(r diff.Row) bool { return r.New == n && r.Kind != diff.KindHunk }) +} + +// TestExpandFold checks that → on a fold shows its lines, and moves the +// cursor to the first of them. The footer tells about the key. On a line +// of code, → scrolls sideways. +func TestExpandFold(t *testing.T) { + m := newFoldModel(t) + p := &m.changes + if got := foldsOf(p); !slices.Equal(got, []int{6, 13, 7}) { + t.Fatalf("folds = %v, want [6 13 7]", got) + } + if !shows(m, "⋯ 13 unmodified lines", "→ show the lines") { + t.Fatalf("the screen does not show the fold and its key:\n%s", view(m)) + } + m = press(t, m, "]") // the first change, line 10 + if shows(m, "→ show the lines") { + t.Error("the footer tells about → on a line of code") + } + m = press(t, m, "]", "k", "k", "k", "k") // the second change, then up to its header + if r := m.changes.rows[m.changes.cur]; r.Fold.Lines != 13 || !shows(m, "→ show the lines") { + t.Fatalf("the cursor is on %+v, want the middle fold", r) + } + m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + p = &m.changes + if got := foldsOf(p); !slices.Equal(got, []int{6, 7}) { + t.Errorf("after →, folds = %v, want [6 7]", got) + } + if r := p.rows[p.cur]; r.New != 14 { + t.Errorf("after →, the cursor is on %+v, want line 14", r) + } + for n := 14; n <= 26; n++ { + if rowOf(p, n) < 0 { + t.Errorf("line %d does not show", n) + } + } + // On a line of code, → scrolls. + m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.changes.xoff == 0 || !slices.Equal(foldsOf(&m.changes), []int{6, 7}) { + t.Errorf("→ on code: xoff %d, folds %v; want a scroll and no change", + m.changes.xoff, foldsOf(&m.changes)) + } +} + +// TestGotoLineInFold checks that : with a line in a fold shows the fold and +// moves the cursor to the line. +func TestGotoLineInFold(t *testing.T) { + m := newFoldModel(t) + m = press(t, m, ":") + m = step(t, m, tea.KeyPressMsg{Text: "20", Code: '2'}) + m = press(t, m, "enter") + p := &m.changes + if r := p.rows[p.cur]; r.New != 20 { + t.Errorf("the cursor is on %+v, want line 20", r) + } + if got := foldsOf(p); !slices.Equal(got, []int{6, 7}) { + t.Errorf("folds = %v, want [6 7]", got) + } +} + +// TestClickFold checks that a click on a fold shows its lines, also in the +// split layout and with wrap. +func TestClickFold(t *testing.T) { + for _, keys := range [][]string{nil, {"|"}, {"w"}} { + m := press(t, newFoldModel(t), keys...) + tail := slices.IndexFunc(m.changes.rows, func(r diff.Row) bool { return r.Fold.Lines == 7 }) + m = click(t, m, m.mainX()+10, screenY(t, m, tail)) + if got := foldsOf(&m.changes); !slices.Equal(got, []int{6, 13}) { + t.Errorf("keys %v: after the click, folds = %v, want [6 13]", keys, got) + } + if rowOf(&m.changes, 40) < 0 { + t.Errorf("keys %v: line 40 does not show", keys) + } + } +} + +// TestFoldKeepsComment checks that a fold with a comment on one of its +// lines shows. A new scope folds the lines again, but the comment must +// stay on the screen. +func TestFoldKeepsComment(t *testing.T) { + m := newFoldModel(t) + m.changes.cur = slices.IndexFunc(m.changes.rows, func(r diff.Row) bool { return r.Fold.Lines == 13 }) + m = step(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + m.changes.cur = rowOf(&m.changes, 20) + m = press(t, m, "c") + m = step(t, m, tea.KeyPressMsg{Text: "why", Code: 'w'}) + m = press(t, m, "enter") + if m.comments.Len() != 1 { + t.Fatalf("%d comments, want 1", m.comments.Len()) + } + m = press(t, m, "b", "u") // the branch scope, then back + if m.folds != nil { + t.Errorf("a new scope keeps the expanded folds %v", m.folds) + } + if rowOf(&m.changes, 20) < 0 || !shows(m, "why") { + t.Errorf("the commented line 20 does not show:\n%s", view(m)) + } + if got := foldsOf(&m.changes); !slices.Equal(got, []int{6, 7}) { + t.Errorf("folds = %v, want [6 7]", got) + } +} diff --git a/tui/keys.go b/tui/keys.go index e582716..b1dbce8 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -226,6 +226,7 @@ type helpKeys struct { split bool visual bool finding bool + onFold bool // the cursor is on a fold } // ShortHelp returns the keys of the footer. @@ -250,6 +251,10 @@ func (h helpKeys) ShortHelp() []key.Binding { } case h.tab == tabPR: return []key.Binding{k.Down, k.OpenPR, k.Refresh, k.TabChanges, k.Keys, k.Quit} + case h.onFold: + return []key.Binding{ + hint(k.Right, k.Right.Help().Key, "show the lines"), k.Comment, k.Send, k.NextHunk, k.Keys, k.Quit, + } } return []key.Binding{k.Comment, k.Send, k.NextHunk, k.NextFile, k.Split, k.Keys, k.Quit} } diff --git a/tui/model.go b/tui/model.go index 8162632..e56ffd1 100644 --- a/tui/model.go +++ b/tui/model.go @@ -196,6 +196,9 @@ type model struct { tab tab scope Scope + // folds has the folds that the user expanded in the Changes tab, by + // path and by the new line number of their first line. + folds map[string]map[int]bool base string from string // the commits scope: the oldest commit that the user picked to string // the commits scope: the newest commit that the user picked diff --git a/tui/mouse.go b/tui/mouse.go index 5c80208..abc401f 100644 --- a/tui/mouse.go +++ b/tui/mouse.go @@ -57,6 +57,10 @@ func (m *model) onClick(mouse tea.Mouse) tea.Cmd { p := m.cur() m.focusList = false p.cur, p.anchor = p.head(rowAt[y]), -1 + if m.onFold() { + m.expandFold(p.cur) + return nil + } side, onNumber := p.side(mouse.X - m.mainX() - gutter) m.drag = dragState{ active: true, diff --git a/tui/nav.go b/tui/nav.go index c6c30e7..9b05ca1 100644 --- a/tui/nav.go +++ b/tui/nav.go @@ -144,6 +144,8 @@ func (m *model) moveKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { m.pickFile(m.listSel() + 1) case key.Matches(msg, k.PrevFile): m.pickFile(m.listSel() - 1) + case key.Matches(msg, k.Right) && m.onFold(): + m.expandFold(p.cur) case key.Matches(msg, k.Right) && !m.wrap: p.xoff += scrollStep m.rerender() @@ -281,11 +283,49 @@ func (m *model) setTab(t tab) { // setScope shows the Changes tab in scope s. func (m *model) setScope(s Scope) { - m.tab, m.scope = tabChanges, s + m.tab, m.scope, m.folds = tabChanges, s, nil m.reload() m.changes.cur, m.changes.top = 0, 0 } +// onFold tells if the cursor is on a fold of the Changes tab. +func (m *model) onFold() bool { + p := m.cur() + return m.tab == tabChanges && p.cur < len(p.rows) && p.rows[p.cur].Fold.Lines > 0 +} + +// showFold tells if the lines of fold f of the file at path show: the user +// expanded the fold, or a comment is on one of its lines. +func (m *model) showFold(path string, f diff.Fold) bool { + if m.folds[path][f.New] { + return true + } + for _, c := range m.comments.All() { + lo := f.New + if c.Side == diff.SideOld { + lo = f.Old + } + if c.Path == path && c.Start < lo+f.Lines && c.End >= lo { + return true + } + } + return false +} + +// expandFold shows the lines of the fold at row i of the Changes tab. A +// cursor on the fold moves to the first of them. +func (m *model) expandFold(i int) { + p := &m.changes + if m.folds == nil { + m.folds = map[string]map[int]bool{} + } + if m.folds[p.path] == nil { + m.folds[p.path] = map[int]bool{} + } + m.folds[p.path][p.rows[i].Fold.New] = true + m.renderChanges() +} + // listSel returns the index of the selected file in the file list. func (m *model) listSel() int { if m.tab == tabFiles { @@ -467,6 +507,13 @@ func (m *model) gotoLine(s string) { m.flash("not a line number: " + s) return } + // A line in a fold shows after the fold expands. + for i, r := range p.rows { + if f := r.Fold; p == &m.changes && n >= f.New && n < f.New+f.Lines { + m.expandFold(i) + break + } + } for i, r := range p.rows { if r.New == n || (r.New == 0 && r.Old == n) { p.cur = i diff --git a/tui/render.go b/tui/render.go index fc94b68..69cfa9c 100644 --- a/tui/render.go +++ b/tui/render.go @@ -120,7 +120,9 @@ func (m *model) renderChanges() { case m.rendered && isMarkdown(f.Path) && f.Status != vcs.StatusDeleted: m.setMarkdown(p, f.New) default: - p.setDiff(diff.Render(f.Path, f.Old, f.New, m.diffOptions(p.xoff))) + o := m.diffOptions(p.xoff) + o.Show = func(fd diff.Fold) bool { return m.showFold(f.Path, fd) } + p.setDiff(diff.Render(f.Path, f.Old, f.New, o)) } } diff --git a/tui/view.go b/tui/view.go index b36b677..5c0f092 100644 --- a/tui/view.go +++ b/tui/view.go @@ -507,6 +507,7 @@ func (m *model) footerLine() string { split: m.split, visual: m.cur().anchor >= 0, finding: m.find.active(), + onFold: m.onFold(), } if m.help.ShowAll { h := m.help.View(hk)