mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-13 17:16:23 -04:00
Focusing the main view by keyboard already selects a whole change block in hunk mode; a click, though, only ever placed a single-line selection, so the common "click the diff to stage this block" gesture still needed a follow-up `a`. Now a click on a change line selects that line's block too. A click on context still selects just that line, since the click points at it precisely (e.g. to edit it with `e`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
265 lines
9.2 KiB
Go
265 lines
9.2 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
|
|
}
|
|
|
|
resolved := self.resolveDiffLines(contents)
|
|
isChange := make([]bool, len(resolved))
|
|
for i, r := range resolved {
|
|
isChange[i] = r.ok && r.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
|
|
}
|
|
|
|
resolved := self.resolveDiffLines(contents)
|
|
paths := make([]string, len(resolved))
|
|
for i, r := range resolved {
|
|
if r.ok {
|
|
paths[i] = r.info.Path
|
|
}
|
|
}
|
|
|
|
target, ok := fileStart(paths, anchor, forward)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return view.ViewLineForBufferLine(target)
|
|
}
|
|
|
|
// FirstChangeLineInView returns the view line of the first change line at or below
|
|
// the top of the viewport, for placing the initial selection when focusing the main
|
|
// view: we select the first change the user can already see rather than jumping to
|
|
// the top of the diff (which would be jarring when the view is scrolled down). If
|
|
// the top visible line is itself mid-change-block, that line is returned, so the
|
|
// selection stays put. ok is false when no change line is loaded at or below the top
|
|
// (e.g. scrolled into trailing context, or the diff isn't loaded that far yet),
|
|
// leaving the caller to fall back.
|
|
func (self *StagingHelper) FirstChangeLineInView(view *gocui.View) (int, bool) {
|
|
top, ok := view.BufferLineForViewLine(view.OriginY())
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
|
|
resolved := self.resolveDiffLines(view.DiffLineContents())
|
|
for i := top; i < len(resolved); i++ {
|
|
if resolved[i].ok && resolved[i].info.IsChange() {
|
|
return view.ViewLineForBufferLine(i)
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// ViewHasChangeLines reports whether view's displayed diff contains any change line
|
|
// (an addition or deletion), i.e. whether there's anything to select. It's false when
|
|
// the main view shows a non-diff placeholder ("No changed files", a merge message) or a
|
|
// diff that is somehow all context — the cases where the focused main view should show
|
|
// no selection.
|
|
func (self *StagingHelper) ViewHasChangeLines(view *gocui.View) bool {
|
|
for _, resolved := range self.resolveDiffLines(view.DiffLineContents()) {
|
|
if resolved.ok && resolved.info.IsChange() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsChangeLine reports whether the given view line of view's displayed diff is a
|
|
// change line (an addition or deletion) rather than context, a header, or an
|
|
// unparseable row — i.e. whether a click there points at something stageable.
|
|
func (self *StagingHelper) IsChangeLine(view *gocui.View, viewLineIdx int) bool {
|
|
info, ok := self.GetDiffLineInfoForView(view, viewLineIdx)
|
|
return ok && info.IsChange()
|
|
}
|
|
|
|
// ChangeBlockBounds returns the view-line range [start, end] of the change block
|
|
// (lazygit's notion of a hunk; see AdjacentChangeBlock) to select when entering or
|
|
// moving in hunk mode in view's displayed diff. The block is the one containing
|
|
// anchorViewLine, or — when that line is context — the first block at or below it
|
|
// (matching how toggling hunk mode in the staging view snaps to the next change).
|
|
// ok is false when no change line lies at or below the anchor (e.g. scrolled into
|
|
// trailing context, or the diff isn't loaded that far yet).
|
|
func (self *StagingHelper) ChangeBlockBounds(view *gocui.View, anchorViewLine int) (int, int, bool) {
|
|
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
|
|
if !ok {
|
|
return 0, 0, false
|
|
}
|
|
|
|
resolved := self.resolveDiffLines(view.DiffLineContents())
|
|
isChange := make([]bool, len(resolved))
|
|
for i, r := range resolved {
|
|
isChange[i] = r.ok && r.info.IsChange()
|
|
}
|
|
|
|
// Snap to the first change line at or after the anchor, then expand over the
|
|
// whole contiguous run of change lines around it.
|
|
start := anchor
|
|
for start < len(isChange) && !isChange[start] {
|
|
start++
|
|
}
|
|
if start >= len(isChange) {
|
|
return 0, 0, false
|
|
}
|
|
end := start
|
|
for start > 0 && isChange[start-1] {
|
|
start--
|
|
}
|
|
for end < len(isChange)-1 && isChange[end+1] {
|
|
end++
|
|
}
|
|
|
|
startView, ok1 := view.ViewLineForBufferLine(start)
|
|
endView, ok2 := view.ViewLineForBufferLine(end)
|
|
if !ok1 || !ok2 {
|
|
return 0, 0, false
|
|
}
|
|
// ViewLineForBufferLine gives the first view line of the block's last buffer
|
|
// line; extend over any further view lines it wrapped to, so the highlight
|
|
// covers the whole block.
|
|
for endView < view.ViewLinesHeight()-1 {
|
|
next, ok := view.BufferLineForViewLine(endView + 1)
|
|
if !ok || next != end {
|
|
break
|
|
}
|
|
endView++
|
|
}
|
|
return startView, endView, true
|
|
}
|
|
|
|
// 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
|
|
}
|