mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
The focused main view's click/enter/e/G handlers all need the same thing: given a rendered diff row, the patch-space line it corresponds to. Until now that came solely from delta's lazygit-edit:// hyperlinks, which only carry a path and a single line number — no side. That's lossy: for a deletion the number is the old line, but the consumers fed it into new-file lookups, and two consecutive deletions (which share a new-file line number) couldn't be told apart at all. Replace GetFileAndLineForClickedDiffLine with GetDiffLineInfo, returning the fuller (file, type, new-line, old-line) record from diff-line-metadata-notes.md. This is mechanism #1: parse the decolorized view buffer — walk up to the file's "diff --git" section, reuse patch.Parse on it (splitting multi-file commit diffs on the "diff --git" boundaries), and read the type and line numbers off the patch arithmetic. It serves the structure-preserving renderings — no pager, git diff --color, and delta --color-only without line numbers — with no external dependency. To avoid trusting a mis-parse, the parser bails when a hunk's body no longer matches its header (Patch.IsWellFormed). That's what happens when a pager keeps the diff/hunk headers but restructures the body: delta's line-number gutters push the +/- marker off the start of each line, so every body line reads as context. Such renderings fall through to the next backend rather than yielding a confident wrong answer. (diff-so-fancy goes further and rewrites the headers too, so it fails even earlier, on the missing "diff --git".) GetDiffLineInfo is a seam with swappable backends: the buffer parser first, then the old hyperlink reader as a fallback for renderings the parser can't handle (delta's default mode, or delta with line-number gutters). The future #2 OSC per-cell metadata reader plugs in ahead of both, behind the same record shape. Wire the consumers to the record per that doc's field mapping: - dive into staging/patch building lands on the exact patch line, looking a deletion up by its old-file line number (PatchLineForOldLineNumber) so the two-deletions case resolves correctly; - `e` edits at the new-file line; - `G` anchors the PR link on the left (old) side for a deletion, the right (new) side otherwise. The hyperlink fallback can't convey the side, so it reports DiffLineOther, which the consumers treat as a non-deletion — i.e. exactly today's behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
329 lines
9.8 KiB
Go
329 lines
9.8 KiB
Go
package patch
|
|
|
|
import (
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
type Patch struct {
|
|
// header of the patch (split on newlines) e.g.
|
|
// diff --git a/filename b/filename
|
|
// index dcd3485..1ba5540 100644
|
|
// --- a/filename
|
|
// +++ b/filename
|
|
header []string
|
|
// hunks of the patch
|
|
hunks []*Hunk
|
|
}
|
|
|
|
// Returns a new patch with the specified transformation applied (e.g.
|
|
// only selecting a subset of changes).
|
|
// Leaves the original patch unchanged.
|
|
func (self *Patch) Transform(opts TransformOpts) *Patch {
|
|
return transform(self, opts)
|
|
}
|
|
|
|
// Returns the patch as a plain string
|
|
func (self *Patch) FormatPlain() string {
|
|
return formatPlain(self)
|
|
}
|
|
|
|
// Returns a range of lines from the patch as a plain string (range is inclusive)
|
|
func (self *Patch) FormatRangePlain(startIdx int, endIdx int) string {
|
|
return formatRangePlain(self, startIdx, endIdx)
|
|
}
|
|
|
|
// Returns the patch as a string with ANSI color codes for displaying in a view
|
|
func (self *Patch) FormatView(opts FormatViewOpts) string {
|
|
return formatView(self, opts)
|
|
}
|
|
|
|
// Returns the lines of the patch
|
|
func (self *Patch) Lines() []*PatchLine {
|
|
lines := []*PatchLine{}
|
|
for _, line := range self.header {
|
|
lines = append(lines, &PatchLine{Content: line, Kind: PATCH_HEADER})
|
|
}
|
|
|
|
for _, hunk := range self.hunks {
|
|
lines = append(lines, hunk.allLines()...)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// Returns the old-file starting line number of the hunk containing the given
|
|
// patch line index. Returns 0 if the line is not inside any hunk.
|
|
func (self *Patch) HunkOldStartForLine(idx int) int {
|
|
hunkIdx := self.HunkContainingLine(idx)
|
|
if hunkIdx == -1 {
|
|
return 0
|
|
}
|
|
return self.hunks[hunkIdx].oldStart
|
|
}
|
|
|
|
// Returns the patch line index of the first line in the given hunk
|
|
func (self *Patch) HunkStartIdx(hunkIndex int) int {
|
|
hunkIndex = lo.Clamp(hunkIndex, 0, len(self.hunks)-1)
|
|
|
|
result := len(self.header)
|
|
for i := range hunkIndex {
|
|
result += self.hunks[i].lineCount()
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Returns the patch line index of the last line in the given hunk
|
|
func (self *Patch) HunkEndIdx(hunkIndex int) int {
|
|
hunkIndex = lo.Clamp(hunkIndex, 0, len(self.hunks)-1)
|
|
|
|
return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
|
|
}
|
|
|
|
// IsWellFormed reports whether every hunk's body matches the lengths declared
|
|
// in its header. A faithful unified diff always satisfies this. A rendering that
|
|
// restructured the diff body does not — e.g. delta with line-number gutters
|
|
// shifts the +/- marker off the start of each line, so every body line reads as
|
|
// context and the computed lengths no longer match the header. This lets a
|
|
// parser tell a real unified diff from a mangled one and fall back rather than
|
|
// trust a mis-parse. Only meaningful for patches produced by Parse (the lengths
|
|
// are read from the header there).
|
|
func (self *Patch) IsWellFormed() bool {
|
|
for _, hunk := range self.hunks {
|
|
if hunk.oldLength() != hunk.declaredOldLength || hunk.newLength() != hunk.declaredNewLength {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (self *Patch) ContainsChanges() bool {
|
|
return lo.SomeBy(self.hunks, func(hunk *Hunk) bool {
|
|
return hunk.containsChanges()
|
|
})
|
|
}
|
|
|
|
// Takes a line index in the patch and returns the line number in the new file.
|
|
// If the line is a header line, returns 1.
|
|
// If the line is a hunk header line, returns the first file line number in that hunk.
|
|
// If the line is out of range below, returns the last file line number in the last hunk.
|
|
func (self *Patch) LineNumberOfLine(idx int) int {
|
|
if idx < len(self.header) || len(self.hunks) == 0 {
|
|
return 1
|
|
}
|
|
|
|
hunkIdx := self.HunkContainingLine(idx)
|
|
// cursor out of range, return last file line number
|
|
if hunkIdx == -1 {
|
|
lastHunk := self.hunks[len(self.hunks)-1]
|
|
return lastHunk.newStart + lastHunk.newLength() - 1
|
|
}
|
|
|
|
hunk := self.hunks[hunkIdx]
|
|
hunkStartIdx := self.HunkStartIdx(hunkIdx)
|
|
idxInHunk := idx - hunkStartIdx
|
|
|
|
if idxInHunk == 0 {
|
|
return hunk.newStart
|
|
}
|
|
|
|
lines := hunk.bodyLines[:idxInHunk-1]
|
|
offset := nLinesWithKind(lines, []PatchLineKind{ADDITION, CONTEXT})
|
|
return hunk.newStart + offset
|
|
}
|
|
|
|
// Takes a line index in the patch and returns the line number in the old file.
|
|
// This is the old-file counterpart of LineNumberOfLine; for a deletion it gives
|
|
// the line's position in the old file (additions get the position they sit at).
|
|
// If the line is a header line, returns 1.
|
|
// If the line is a hunk header line, returns the first old-file line number in
|
|
// that hunk.
|
|
// If the line is out of range below, returns the last old-file line number in
|
|
// the last hunk.
|
|
func (self *Patch) OldLineNumberOfLine(idx int) int {
|
|
if idx < len(self.header) || len(self.hunks) == 0 {
|
|
return 1
|
|
}
|
|
|
|
hunkIdx := self.HunkContainingLine(idx)
|
|
// cursor out of range, return last file line number
|
|
if hunkIdx == -1 {
|
|
lastHunk := self.hunks[len(self.hunks)-1]
|
|
return lastHunk.oldStart + lastHunk.oldLength() - 1
|
|
}
|
|
|
|
hunk := self.hunks[hunkIdx]
|
|
hunkStartIdx := self.HunkStartIdx(hunkIdx)
|
|
idxInHunk := idx - hunkStartIdx
|
|
|
|
if idxInHunk == 0 {
|
|
return hunk.oldStart
|
|
}
|
|
|
|
lines := hunk.bodyLines[:idxInHunk-1]
|
|
offset := nLinesWithKind(lines, []PatchLineKind{DELETION, CONTEXT})
|
|
return hunk.oldStart + offset
|
|
}
|
|
|
|
// Takes a line number in the new file and returns the line index in the patch.
|
|
// This is the opposite of LineNumberOfLine.
|
|
// If the line number is not contained in any of the hunks, it returns the
|
|
// closest position.
|
|
func (self *Patch) PatchLineForLineNumber(lineNumber int) int {
|
|
if len(self.hunks) == 0 {
|
|
return len(self.header)
|
|
}
|
|
|
|
for hunkIdx, hunk := range self.hunks {
|
|
if lineNumber <= hunk.newStart {
|
|
return self.HunkStartIdx(hunkIdx)
|
|
}
|
|
|
|
if lineNumber < hunk.newStart+hunk.newLength() {
|
|
lines := hunk.bodyLines
|
|
offset := lineNumber - hunk.newStart
|
|
for i, line := range lines {
|
|
if offset == 0 {
|
|
return self.HunkStartIdx(hunkIdx) + i + 1
|
|
}
|
|
|
|
if line.Kind == ADDITION || line.Kind == CONTEXT {
|
|
offset--
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return self.LineCount() - 1
|
|
}
|
|
|
|
// Takes a line number in the old file and returns the line index in the patch.
|
|
// This is the old-file counterpart of PatchLineForLineNumber. It is what lets us
|
|
// land on the right patch line for a deletion: two consecutive deletions share a
|
|
// new-file line number, so only the old-file number tells them apart.
|
|
// If the line number is not contained in any of the hunks, it returns the
|
|
// closest position.
|
|
func (self *Patch) PatchLineForOldLineNumber(lineNumber int) int {
|
|
if len(self.hunks) == 0 {
|
|
return len(self.header)
|
|
}
|
|
|
|
for hunkIdx, hunk := range self.hunks {
|
|
if lineNumber <= hunk.oldStart {
|
|
return self.HunkStartIdx(hunkIdx)
|
|
}
|
|
|
|
if lineNumber < hunk.oldStart+hunk.oldLength() {
|
|
lines := hunk.bodyLines
|
|
offset := lineNumber - hunk.oldStart
|
|
for i, line := range lines {
|
|
if offset == 0 {
|
|
return self.HunkStartIdx(hunkIdx) + i + 1
|
|
}
|
|
|
|
if line.Kind == DELETION || line.Kind == CONTEXT {
|
|
offset--
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return self.LineCount() - 1
|
|
}
|
|
|
|
// Returns hunk index containing the line at the given patch line index
|
|
func (self *Patch) HunkContainingLine(idx int) int {
|
|
for hunkIdx, hunk := range self.hunks {
|
|
hunkStartIdx := self.HunkStartIdx(hunkIdx)
|
|
if idx >= hunkStartIdx && idx < hunkStartIdx+hunk.lineCount() {
|
|
return hunkIdx
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Returns the patch line index of the next change (i.e. addition or deletion)
|
|
// that matches the same "included" state, given the includedLines. If you don't
|
|
// care about included states, pass nil for includedLines and false for included.
|
|
func (self *Patch) GetNextChangeIdxOfSameIncludedState(idx int, includedLines []int, included bool) (int, bool) {
|
|
idx = lo.Clamp(idx, 0, self.LineCount()-1)
|
|
|
|
lines := self.Lines()
|
|
|
|
isMatch := func(i int, line *PatchLine) bool {
|
|
sameIncludedState := lo.Contains(includedLines, i) == included
|
|
return line.IsChange() && sameIncludedState
|
|
}
|
|
|
|
for i, line := range lines[idx:] {
|
|
if isMatch(i+idx, line) {
|
|
return i + idx, true
|
|
}
|
|
}
|
|
|
|
// there are no changes from the cursor onwards so we'll instead
|
|
// return the index of the last change
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
line := lines[i]
|
|
if isMatch(i, line) {
|
|
return i, true
|
|
}
|
|
}
|
|
|
|
return 0, false
|
|
}
|
|
|
|
// Returns the patch line index of the next change (i.e. addition or deletion).
|
|
func (self *Patch) GetNextChangeIdx(idx int) int {
|
|
result, _ := self.GetNextChangeIdxOfSameIncludedState(idx, nil, false)
|
|
return result
|
|
}
|
|
|
|
// Returns the length of the patch in lines
|
|
func (self *Patch) LineCount() int {
|
|
count := len(self.header)
|
|
for _, hunk := range self.hunks {
|
|
count += hunk.lineCount()
|
|
}
|
|
return count
|
|
}
|
|
|
|
// Returns the number of hunks of the patch
|
|
func (self *Patch) HunkCount() int {
|
|
return len(self.hunks)
|
|
}
|
|
|
|
// Adjust the given line number (one-based) according to the current patch. The
|
|
// patch is supposed to be a diff of an old file state against the working
|
|
// directory; the line number is a line number in that old file, and the
|
|
// function returns the corresponding line number in the working directory file.
|
|
func (self *Patch) AdjustLineNumber(lineNumber int) int {
|
|
adjustedLineNumber := lineNumber
|
|
for _, hunk := range self.hunks {
|
|
if hunk.oldStart >= lineNumber {
|
|
break
|
|
}
|
|
|
|
if hunk.oldStart+hunk.oldLength() > lineNumber {
|
|
return hunk.newStart
|
|
}
|
|
|
|
adjustedLineNumber += hunk.newLength() - hunk.oldLength()
|
|
}
|
|
|
|
return adjustedLineNumber
|
|
}
|
|
|
|
func (self *Patch) IsSingleHunkForWholeFile() bool {
|
|
if len(self.hunks) != 1 {
|
|
return false
|
|
}
|
|
|
|
// We consider a patch to be a single hunk for the whole file if it has only additions or
|
|
// deletions but not both, and no context lines. This not quite correct, because it will also
|
|
// return true for a block of added or deleted lines if the diff context size is 0, but in this
|
|
// case you wouldn't be able to stage things anyway, so it doesn't matter.
|
|
bodyLines := self.hunks[0].bodyLines
|
|
return nLinesWithKind(bodyLines, []PatchLineKind{DELETION, CONTEXT}) == 0 ||
|
|
nLinesWithKind(bodyLines, []PatchLineKind{ADDITION, CONTEXT}) == 0
|
|
}
|