mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
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>
166 lines
5.4 KiB
Go
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
|
|
}
|