jesseduffield.lazygit/pkg/gui/controllers/helpers/diff_line_navigation_test.go
Stefan Haller 1fcbc4d5a5 Parse the f/h header records a conforming pager emits
The spec regained file-header (f) and hunk-header (h) records: f never
carries a line number, h always does (the first line of the hunk it
heads). Accept them in the OSC metadata backend, so a conforming pager's
header rows resolve to the same DiffLineFileHeader/DiffLineHunkHeader
identities the buffer parser already reports for raw diffs.

With header rows located, next/previous file navigation and the
jump-to-file menu land on a file's header row, and header rows become
usable scroll-restore anchors. The consumers need a few adjustments:

- File navigation used to reach a file's top by backing up over the
  untagged rows above its first located row. With tagged headers that
  overshoots onto the blank separator row above the file header, so drop
  the back-up (backUpOverHeader) and land on the first located row
  itself: the header for any conforming source, or the first content
  line under a pager that leaves its headers untagged — an accepted
  degradation for non-conforming pagers, now that the spec makes f/h
  mandatory.

- SamePatchLine now requires headers to match headers of the same kind.
  A hunk header shares its line number with the hunk's first content
  line (and a file header shares "0" with a deleted file's hunk header),
  so without this a position restore aiming at one could land on the
  other. This also applied to raw diffs before, but headers used to be
  unlikely restore targets; now that navigation deliberately lands on
  them, the ambiguity would bite.

- Editing a file-header row opens the file without jumping to a line,
  like pressing edit on a whole file in a side panel. (It used to open
  at line 1 for raw diffs, where headers resolved already.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:59:00 +02:00

117 lines
5.1 KiB
Go

package helpers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestChangeBlockStart(t *testing.T) {
// A diff with three change blocks separated by context:
// 0 file header 1 hunk header 2 context
// 3 + 4 + (block A)
// 5 context
// 6 - (block B)
// 7 context
// 8 + (block C)
isChange := []bool{false, false, false, true, true, false, true, false, true}
scenarios := []struct {
name string
from int
forward bool
expected int
found bool
}{
{"forward from a header lands on the first block", 0, true, 3, true},
{"forward from separating context lands on the next block", 5, true, 6, true},
{"forward from the start of a block skips to the next", 3, true, 6, true},
{"forward from inside a block skips the rest of it", 4, true, 6, true},
{"forward from the last block finds nothing", 8, true, 0, false},
{"backward from a later block lands on the previous one's start", 8, false, 6, true},
{"backward from a block start lands on the previous block's start", 6, false, 3, true},
{"backward from inside the first block finds nothing", 4, false, 0, false},
{"backward from the first block's start finds nothing", 3, false, 0, false},
{"backward from context lands on the preceding block's start", 7, false, 6, true},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
got, found := changeBlockStart(isChange, s.from, s.forward)
assert.Equal(t, s.found, found)
if s.found {
assert.Equal(t, s.expected, got)
}
})
}
}
func TestFileStart(t *testing.T) {
// A parseable two-file diff: every row carries its file's path (the headers
// included), as the buffer parser reports.
parseable := []string{"a", "a", "a", "a", "b", "b", "b", "b"}
// The same diff as a pager without `f`/`h` header records emits it: only content
// lines carry the path; the file/hunk header rows above each file are untagged
// (empty), so navigation can only land on each file's first content line.
tagged := []string{"", "", "a", "a", "", "", "b", "b"}
// Three such files, to exercise navigating from one file's untagged header to the
// next: the row just above b's header is a's content, so the anchor file must be
// found by scanning down (b), not up (a) — otherwise next-file would jump back
// into b and a second `n` couldn't advance.
taggedThree := []string{"", "", "a", "a", "", "", "b", "b", "", "", "c", "c"}
// A pager that tags its header rows with `f`/`h` records (delta): the two-row
// file header and the hunk-header box carry the file's path, but the blank
// separator rows — above each file header, and between it and the first hunk
// header — carry nothing. Navigation must land on the header's first row, not
// the blank line above it.
// 0 blank 1 file hdr 2 file hdr 3 blank 4-6 hunk hdr box 7 content
// 8 blank 9 file hdr 10 file hdr 11 blank 12-13 hunk hdr box 14 content
headerTagged := []string{"", "a", "a", "", "a", "a", "a", "a", "", "b", "b", "", "b", "b", "b"}
scenarios := []struct {
name string
paths []string
from int
forward bool
expected int
found bool
}{
{"parseable: next file lands on its header", parseable, 1, true, 4, true},
{"parseable: next from the last file finds nothing", parseable, 6, true, 0, false},
{"parseable: previous file lands on its header", parseable, 5, false, 0, true},
{"parseable: previous from the first file finds nothing", parseable, 1, false, 0, false},
// With only content tagged, both directions land on the file's first content
// line — the untagged header rows above it can't be told apart from the
// blank separator rows, so they are never a landing spot.
{"tagged: next file lands on its first content line", tagged, 2, true, 6, true},
{"tagged: next from an untagged header still advances", tagged, 0, true, 6, true},
{"tagged: previous file lands on its first content line", tagged, 7, false, 2, true},
{"tagged: previous from the first file finds nothing", tagged, 2, false, 0, false},
// From b's untagged header (row 4), the anchor file is b (below), so next goes
// to c and previous goes to a — neither sticks on b.
{"tagged: next from a middle file's header advances past it", taggedThree, 4, true, 10, true},
{"tagged: previous from a middle file's header lands on the prior file", taggedThree, 4, false, 2, true},
// With headers tagged, both directions land on the header's first row —
// crucially not on the blank separator row above it.
{"header-tagged: next file lands on the header, not the blank above it", headerTagged, 7, true, 9, true},
{"header-tagged: next from inside a file's header advances", headerTagged, 2, true, 9, true},
{"header-tagged: previous file lands on the header, not the blank above it", headerTagged, 14, false, 1, true},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
got, found := fileStart(s.paths, s.from, s.forward)
assert.Equal(t, s.found, found)
if s.found {
assert.Equal(t, s.expected, got)
}
})
}
}