mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 21:46:49 -04:00
Reading a diff of any size means moving in bigger steps than a line at a time, which the staging view offers and the main view didn't. So the same hunk keys work here, and n / N step from file to file — worth having only here, since a diff spanning several files is something the staging view never showed. Where "the next file" begins isn't in the text: a diff renderer may print whatever it likes above a file's content. So navigation lands on the first row that states which file it belongs to, which is the file's header when the source says so and its first content line otherwise. The anchor's own file is found by scanning down rather than to the nearest row either way — having just landed on a header, the nearest row above belongs to the file we came from, and taking it would send the next press back where we started. A file you go to is brought to the top of the view, since the file is what you went there for and the more of it is on screen the better. That only applies where the view has to scroll at all: a file already on screen leaves it where it is. In hunk mode what ends up selected is the file's first change rather than the row the file begins at, and a large context size can put that change further down than a screenful; the selection is scrolled into view afterwards as any other jump's is, and the alignment gives way where the two can't both hold. The diff loads lazily, so a target below the loaded portion isn't there to be found; rather than doing nothing, we read the rest in and look again. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
360 lines
13 KiB
Go
360 lines
13 KiB
Go
package helpers
|
|
|
|
import (
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
// The questions a diff view can be asked about what it is showing — where the change
|
|
// lines are, which block or file a row belongs to — answered in the view-line terms a
|
|
// cursor and a click speak. They are all built on the identities recovered in
|
|
// diff_line_helper.go, which is where the answering stops and the recovering starts.
|
|
|
|
// changeLines resolves view's rendered diff to one flag per buffer line: whether
|
|
// that row is a change line (an addition or a deletion), as opposed to context, a
|
|
// header, or a row whose identity couldn't be recovered. Those are the rows a
|
|
// selection is anchored on and navigation moves between.
|
|
func (self *DiffLineHelper) changeLines(view *gocui.View) []bool {
|
|
resolved := self.resolveDiffLines(view.DiffLineContents())
|
|
isChange := make([]bool, len(resolved))
|
|
for i, r := range resolved {
|
|
isChange[i] = r.ok && r.info.IsChange()
|
|
}
|
|
return isChange
|
|
}
|
|
|
|
// FirstChangeLineInView returns the view line of the first change line on screen. It
|
|
// is where the selection goes when the main view is focused by keyboard: focusing a
|
|
// diff you are reading points at something in it without moving it, so the search
|
|
// stops at the bottom of the viewport rather than going after a change further down.
|
|
// ok is false when the viewport holds no change line — scrolled into a long stretch
|
|
// of context, or past the last change.
|
|
func (self *DiffLineHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
|
|
top, bottom, ok := visibleBufferLines(view)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
|
|
isChange := self.changeLines(view)
|
|
for i := top; i <= min(bottom, len(isChange)-1); i++ {
|
|
if isChange[i] {
|
|
return view.ViewLineForBufferLine(i)
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// FirstChangeBlockInView returns the view line of the first change block on screen:
|
|
// the first one that *begins* in the viewport, and failing that the one that reaches
|
|
// into the viewport from above, whose start is off screen. Hunk mode wants that order
|
|
// for the block it offers up on focus: preferably a block whose beginning the user can
|
|
// see, rather than the tail of one they have scrolled past the start of. The block
|
|
// bleeding in from above is kept as the answer for a change too long to fit on screen,
|
|
// where there is no other. ok is false when the viewport shows no change line.
|
|
func (self *DiffLineHelper) FirstChangeBlockInView(view *gocui.View) (int, bool) {
|
|
top, bottom, ok := visibleBufferLines(view)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
|
|
isChange := self.changeLines(view)
|
|
for i := top; i <= min(bottom, len(isChange)-1); i++ {
|
|
if isChange[i] && (i == 0 || !isChange[i-1]) {
|
|
return view.ViewLineForBufferLine(i)
|
|
}
|
|
}
|
|
// A block covering the top line is one that began above it: nothing else can put a
|
|
// change there once no block starts on screen.
|
|
if top < len(isChange) && isChange[top] {
|
|
return view.ViewLineForBufferLine(top)
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// visibleBufferLines returns the first and last line of view's content that the
|
|
// viewport shows any part of, for the queries that only care about what the user can
|
|
// see. The last line is the one at the bottom edge, or the content's last when the
|
|
// content ends above it. ok is false for a view showing no content at all.
|
|
func visibleBufferLines(view *gocui.View) (int, int, bool) {
|
|
top, ok := view.BufferLineForViewLine(view.OriginY())
|
|
if !ok {
|
|
return 0, 0, false
|
|
}
|
|
|
|
lastVisible := min(view.OriginY()+view.InnerHeight(), view.ViewLinesHeight()) - 1
|
|
bottom, ok := view.BufferLineForViewLine(lastVisible)
|
|
if !ok {
|
|
return top, top, true
|
|
}
|
|
return top, bottom, true
|
|
}
|
|
|
|
// ViewHasChangeLines reports whether view's rendered diff holds any change line at
|
|
// all, i.e. whether there is anything to select. It is false over a non-diff
|
|
// placeholder, and over a diff with nothing in it — an empty commit, a binary file —
|
|
// which are the cases where the focused main view shows no selection.
|
|
func (self *DiffLineHelper) ViewHasChangeLines(view *gocui.View) bool {
|
|
return lo.Contains(self.changeLines(view), true)
|
|
}
|
|
|
|
// IsChangeLine reports whether the given view line of view's rendered diff is a
|
|
// change line rather than context, a header, or an unresolvable row — i.e. whether
|
|
// pointing at it points at something a patch could be built from.
|
|
func (self *DiffLineHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool {
|
|
info, ok := self.GetDiffLineInfo(view, viewLineIdx)
|
|
return ok && info.IsChange()
|
|
}
|
|
|
|
// IsSingleHunkForWholeFile reports whether the file the given change line belongs to
|
|
// is shown as one solid block of changes — every row of its diff a change of the same
|
|
// kind, no context — which is what a newly added or deleted file looks like. That is
|
|
// the case where widening the selection to the change block would select the file
|
|
// entire, so hunk mode drops to a single line there instead. It asks of a rendered
|
|
// diff the question patch.Patch.IsSingleHunkForWholeFile asks of a patch.
|
|
//
|
|
// It says false while the diff is still being read in, since the rows that would
|
|
// answer otherwise — a context line, a change of the other kind — may not have
|
|
// arrived yet. That errs towards hunk mode, which is what the user asked for.
|
|
func (self *DiffLineHelper) IsSingleHunkForWholeFile(view *gocui.View, changeViewLine int) bool {
|
|
if manager := self.c.GetViewBufferManagerForView(view); manager != nil && manager.IsLoading() {
|
|
return false
|
|
}
|
|
|
|
anchor, ok := view.BufferLineForViewLine(changeViewLine)
|
|
if !ok {
|
|
return false
|
|
}
|
|
resolved := self.resolveDiffLines(view.DiffLineContents())
|
|
if anchor >= len(resolved) || !resolved[anchor].ok {
|
|
return false
|
|
}
|
|
|
|
// The question is per file: a commit's diff may hold a newly added file next to an
|
|
// edited one.
|
|
path := resolved[anchor].info.Path
|
|
kind := resolved[anchor].info.Type
|
|
for _, row := range resolved {
|
|
if !row.ok || row.info.Path != path {
|
|
continue
|
|
}
|
|
if row.info.Type == types.DiffLineContext {
|
|
return false
|
|
}
|
|
if row.info.IsChange() && row.info.Type != kind {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ChangeBlockBounds returns the inclusive view-line range of the change block to
|
|
// select in hunk mode around anchorViewLine. A change block is lazygit's notion of a
|
|
// hunk — a run of consecutive added or deleted lines bounded by context, of which a
|
|
// single git @@ hunk may hold several. When the anchor is context, the block used is
|
|
// the first at or below it, or — with nothing below, the cursor sitting past the last
|
|
// change — the nearest above, so that hunk mode always has a block to select. ok is
|
|
// false only when the diff holds no change line at all.
|
|
func (self *DiffLineHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) {
|
|
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
|
if !ok {
|
|
return 0, 0, false
|
|
}
|
|
|
|
isChange := self.changeLines(view)
|
|
start := anchor
|
|
for start < len(isChange) && !isChange[start] {
|
|
start++
|
|
}
|
|
if start >= len(isChange) {
|
|
for start = min(anchor, len(isChange)-1); start >= 0 && !isChange[start]; start-- {
|
|
}
|
|
if start < 0 {
|
|
return 0, 0, false
|
|
}
|
|
}
|
|
end := start
|
|
for start > 0 && isChange[start-1] {
|
|
start--
|
|
}
|
|
for end < len(isChange)-1 && isChange[end+1] {
|
|
end++
|
|
}
|
|
|
|
startView, startOk := view.ViewLineForBufferLine(start)
|
|
// The block's last line goes to its last view line, so that a line the view
|
|
// wrapped is highlighted to its end rather than only where it begins.
|
|
endView, endOk := view.LastViewLineForBufferLine(end)
|
|
if !startOk || !endOk {
|
|
return 0, 0, false
|
|
}
|
|
return startView, endView, true
|
|
}
|
|
|
|
// SelectedHunkBounds returns the change block selected in hunk mode. The range
|
|
// anchor stays on the block's far end when a click moves the cursor before its
|
|
// handler runs, so it still identifies the selected block.
|
|
func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool) {
|
|
anchor := view.RangeSelectStartY()
|
|
if anchor < 0 {
|
|
return 0, 0, false
|
|
}
|
|
return self.ChangeBlockBounds(view, anchor)
|
|
}
|
|
|
|
// AdjacentChangeBlock returns the view line to move to for next/previous change-block
|
|
// navigation in view's rendered diff, starting from anchorViewLine. A change block is
|
|
// lazygit's notion of a hunk (see ChangeBlockBounds). forward=true targets the start
|
|
// of the next block, forward=false the start of the previous one — from mid-block that
|
|
// means the previous block, rather than the one we are in. ok is false when there's no
|
|
// further block, so the caller leaves the view where it is.
|
|
func (self *DiffLineHelper) AdjacentChangeBlock(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
|
|
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
|
|
target, ok := changeBlockStart(self.changeLines(view), 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) rendered diff, starting from anchorViewLine: the first
|
|
// located row of the neighbouring file, found where the rows' file changes. ok is
|
|
// false at the first or last file.
|
|
func (self *DiffLineHelper) AdjacentFile(view *gocui.View, anchorViewLine int, forward bool) (int, bool) {
|
|
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
|
|
target, ok := fileStart(self.filePaths(view), anchor, forward)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return view.ViewLineForBufferLine(target)
|
|
}
|
|
|
|
// filePaths resolves view's rendered diff to the path each buffer line belongs to,
|
|
// empty for a row whose identity couldn't be recovered.
|
|
func (self *DiffLineHelper) filePaths(view *gocui.View) []string {
|
|
resolved := self.resolveDiffLines(view.DiffLineContents())
|
|
paths := make([]string, len(resolved))
|
|
for i, row := range resolved {
|
|
if row.ok {
|
|
paths[i] = row.info.Path
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// fileStart finds, in a diff whose lines carry the file path they belong to (empty for
|
|
// a row no backend could place), the first located row of the file adjacent to `from`
|
|
// in the given direction — the row file navigation lands on. It is the pure index
|
|
// arithmetic behind AdjacentFile.
|
|
//
|
|
// A file is identified by its path, so we look for where the path changes, skipping
|
|
// rows that carry none: those are the blank separator rows between files, or the
|
|
// header rows of a diff renderer that doesn't state which file its headers belong to.
|
|
// So the landing row is the file's header wherever the source says so — a parseable
|
|
// buffer, or a renderer that tags its headers — and the file's first content line
|
|
// otherwise, which is an accepted degradation.
|
|
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 i, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// Walk back past the current file (its rows and any unlocated ones) to the previous
|
|
// file's last located row, then back over that whole file, landing on its first.
|
|
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--
|
|
}
|
|
for paths[i] != prevPath {
|
|
i++
|
|
}
|
|
return i, true
|
|
}
|
|
|
|
// 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 is nothing below. Scanning down
|
|
// first matters because the anchor is often a file-header row that carries no path of
|
|
// its own, whose nearest tagged row above is the *previous* file's content; taking
|
|
// that would make next-file navigation jump back into the file just left, so a second
|
|
// press wouldn't advance. ok is false when no row carries a path.
|
|
func anchorFilePath(paths []string, from int) (string, bool) {
|
|
if from < 0 {
|
|
return "", false
|
|
}
|
|
for i := from; i < len(paths); i++ {
|
|
if paths[i] != "" {
|
|
return paths[i], true
|
|
}
|
|
}
|
|
for i := min(from, len(paths)) - 1; i >= 0; i-- {
|
|
if paths[i] != "" {
|
|
return paths[i], true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// 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.
|
|
func changeBlockStart(isChange []bool, from int, forward bool) (int, bool) {
|
|
if from < 0 || from >= len(isChange) {
|
|
return 0, false
|
|
}
|
|
|
|
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 0, false
|
|
}
|
|
return i, true
|
|
}
|
|
|
|
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
|
|
}
|