jesseduffield.lazygit/pkg/gui/controllers/helpers/diff_line_navigation.go
Stefan Haller a967fc3099 Navigate the focused main view by file and hunk
Add file and change-block ("hunk") navigation to the focused main view,
mirroring the staging view's hunk keys: `<left>`/`<right>` jump to the
previous/next hunk and `n`/`N` to the next/previous file. A "hunk" here is
lazygit's notion — a run of consecutive added/deleted lines separated by
context, not a git `@@` section — matching what the staging view jumps
between.

This is a consumer of the diff-line primitive in its forward direction:
resolve each rendered row's patch identity, then scan for the next/previous
change block (by the line type) or file boundary (by the path changing). The
file scan lands on the top of the neighbouring file even when a restructuring
pager leaves the header rows untagged, by backing up over them from the
file's first identifiable row — which is impossible without the per-line
metadata once the pager stops emitting a parseable unified diff. The
boundary arithmetic is pulled out into pure functions and unit-tested.

The anchor is the selected line if a selection is showing, else the top
visible line. With a selection we move it to the target and scroll it into
view, like the staging view; with none we stay in scroll mode, bringing the
target to the top without creating a selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00

166 lines
5.4 KiB
Go

package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
)
// AdjacentChangeBlock returns the view line to move to for next/previous change-block
// navigation in view's displayed diff, starting from anchorViewLine. A "change block"
// is lazygit's notion of a hunk — a run of consecutive added/deleted lines separated
// by context, of which there may be several within one git @@ hunk — matching what
// the staging view's hunk navigation jumps between. forward=true targets the start of
// the next block; forward=false the start of the previous one (from mid-block this
// skips to the previous block, mirroring State.SelectPreviousHunk). ok is false when
// there's no further block, so the caller leaves the view where it is.
func (self *StagingHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
contents := view.DiffLineContents()
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, false
}
isChange := make([]bool, len(contents))
for i := range contents {
if info, ok := self.diffLineInfoFromContents(contents, i); ok {
isChange[i] = info.IsChange()
}
}
target, ok := changeBlockStart(isChange, anchor, forward)
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(target)
}
// AdjacentFile returns the view line to move to for next/previous file navigation in
// view's (possibly multi-file) displayed diff, starting from anchorViewLine: the
// first row belonging to the next/previous file, found where the per-row metadata's
// file changes. ok is false at the first/last file.
func (self *StagingHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
contents := view.DiffLineContents()
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return 0, false
}
paths := make([]string, len(contents))
for i := range contents {
if info, ok := self.diffLineInfoFromContents(contents, i); ok {
paths[i] = info.Path
}
}
target, ok := fileStart(paths, anchor, forward)
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(target)
}
// changeBlockStart finds, in a diff whose lines are flagged by isChange, the first
// line of the change block adjacent to `from` in the given direction. It is the pure
// index arithmetic behind AdjacentChangeBlock, mirroring the staging view's
// State.SelectNextHunk / SelectPreviousHunk line by line.
func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) {
if forward {
i := from
for i < len(isChange) && isChange[i] { // leave the current block
i++
}
for i < len(isChange) && !isChange[i] { // skip the separating context
i++
}
if i < len(isChange) {
return i, true
}
return 0, false
}
i := from
for i >= 0 && isChange[i] { // leave the current block
i--
}
for i >= 0 && !isChange[i] { // skip context, landing on the previous block's last line
i--
}
if i < 0 {
return 0, false
}
for i > 0 && isChange[i-1] { // walk back to that block's first line
i--
}
return i, true
}
// fileStart finds, in a diff whose lines carry the file path they belong to (empty
// for a row no backend could place, e.g. a restructuring pager's file headers), the
// top row of the file adjacent to `from` in the given direction. It is the pure
// index arithmetic behind AdjacentFile. A file is identified by its path, so we find
// where the path changes and then back up over the neighbouring file's unplaced
// header rows, landing on its first row — the `diff --git`/`@@` header when the
// buffer is parseable, or whatever the pager renders above the file's first tagged
// line otherwise.
func fileStart(paths []string, from int, forward bool) (int, bool) {
anchorPath, ok := anchorFilePath(paths, from)
if !ok {
return 0, false
}
if forward {
for i := from; i < len(paths); i++ {
if paths[i] != "" && paths[i] != anchorPath {
return backUpOverHeader(paths, i), true
}
}
return 0, false
}
// Walk back past the current file (its rows and any unplaced rows) to the
// previous file's last located row, then back over that whole file to its top.
i := from
for i >= 0 && (paths[i] == "" || paths[i] == anchorPath) {
i--
}
if i < 0 {
return 0, false
}
prevPath := paths[i]
for i > 0 && (paths[i-1] == "" || paths[i-1] == prevPath) {
i--
}
return i, true
}
// backUpOverHeader moves from a file's first located row up over the unplaced header
// rows directly above it, to the file's top. It stops at the previous file's last
// located row, so it never crosses into it.
func backUpOverHeader(paths []string, firstLocated int) int {
i := firstLocated
for i > 0 && paths[i-1] == "" {
i--
}
return i
}
// anchorFilePath returns the path of the file the anchor sits in: the first row at or
// below it that carries a path — the file whose content is at or below the top of the
// view — falling back to the nearest above when there's nothing below. Scanning down
// first matters because the anchor is often an untagged file-header row whose nearest
// tagged row is the *previous* file's content just above it; taking that would make
// next-file navigation jump back into the file just left (so a second `n` wouldn't
// advance). ok is false when no row carries a path.
func anchorFilePath(paths []string, from int) (string, bool) {
for i := from; i < len(paths); i++ {
if paths[i] != "" {
return paths[i], true
}
}
for i := from - 1; i >= 0; i-- {
if paths[i] != "" {
return paths[i], true
}
}
return "", false
}