mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 16:16:28 -04:00
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>
297 lines
10 KiB
Go
297 lines
10 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)
|
|
}
|
|
|
|
// DiffFile is a file shown in a (possibly multi-file) diff: its absolute path and the
|
|
// view line its section starts at — the row that next/previous-file navigation lands on.
|
|
type DiffFile struct {
|
|
Path string
|
|
FirstViewLine int
|
|
}
|
|
|
|
// FilesInDiff lists the files shown in view's diff, in display order, each paired with
|
|
// the view line its section starts at. It is the jump-to-file menu's source: jumping to
|
|
// a file goes to its FirstViewLine — the file's first located row, the same row that
|
|
// AdjacentFile lands on, so the menu and n/N agree on where each file begins. A file
|
|
// whose start row isn't currently mapped to a view line (not loaded yet) is skipped.
|
|
func (self *StagingHelper) FilesInDiff(view *gocui.View) []DiffFile {
|
|
resolved := self.resolveDiffLines(view.DiffLineContents())
|
|
paths := make([]string, len(resolved))
|
|
for i, r := range resolved {
|
|
if r.ok {
|
|
paths[i] = r.info.Path
|
|
}
|
|
}
|
|
|
|
var files []DiffFile
|
|
seen := map[string]bool{}
|
|
for i, path := range paths {
|
|
if path == "" || seen[path] {
|
|
continue
|
|
}
|
|
seen[path] = true
|
|
if viewLine, ok := view.ViewLineForBufferLine(i); ok {
|
|
files = append(files, DiffFile{Path: path, FirstViewLine: viewLine})
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
// 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), 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
|
|
// find where the path changes, skipping unlocated rows: those are blank separator
|
|
// rows between files, or the header rows of a pager that doesn't emit `f`/`h`
|
|
// records. So the landing row is the file's header for any conforming source (a
|
|
// parseable buffer, or a pager tagging its headers), and the first content line
|
|
// under a pager that leaves its headers untagged — an accepted degradation. (An
|
|
// earlier version instead backed up over the untagged rows above the first located
|
|
// one, to reach the file's top under such pagers; but that overshoots onto the
|
|
// blank line above the header whenever the headers themselves are tagged.)
|
|
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 rows) to the
|
|
// previous file's last located row, then back over that whole file, landing
|
|
// on its first located row.
|
|
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'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
|
|
}
|