mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
Recover diff-line identity by parsing the buffer, behind a swappable seam
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>
This commit is contained in:
parent
165afd4652
commit
cf8e5fd27e
|
|
@ -16,6 +16,11 @@ type Hunk struct {
|
|||
newStart int
|
||||
// the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
|
||||
headerContext string
|
||||
// the lengths declared in the hunk header ('2' and '3' in the above example),
|
||||
// kept so we can verify the parsed body actually matches the header (see
|
||||
// Patch.IsWellFormed). Only populated by Parse.
|
||||
declaredOldLength int
|
||||
declaredNewLength int
|
||||
// the body of the hunk, excluding the header line
|
||||
bodyLines []*PatchLine
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
|
||||
// Captures, in order: old start, old length (omitted when 1), new start, new
|
||||
// length (omitted when 1), and the trailing context.
|
||||
var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$`)
|
||||
|
||||
func Parse(patchStr string) *Patch {
|
||||
// ignore trailing newline.
|
||||
|
|
@ -19,13 +21,15 @@ func Parse(patchStr string) *Patch {
|
|||
var currentHunk *Hunk
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
oldStart, newStart, headerContext := headerInfo(line)
|
||||
oldStart, oldLength, newStart, newLength, headerContext := headerInfo(line)
|
||||
|
||||
currentHunk = &Hunk{
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
oldStart: oldStart,
|
||||
newStart: newStart,
|
||||
declaredOldLength: oldLength,
|
||||
declaredNewLength: newLength,
|
||||
headerContext: headerContext,
|
||||
bodyLines: []*PatchLine{},
|
||||
}
|
||||
hunks = append(hunks, currentHunk)
|
||||
} else if currentHunk != nil {
|
||||
|
|
@ -41,14 +45,25 @@ func Parse(patchStr string) *Patch {
|
|||
}
|
||||
}
|
||||
|
||||
func headerInfo(header string) (int, int, string) {
|
||||
func headerInfo(header string) (oldStart int, oldLength int, newStart int, newLength int, headerContext string) {
|
||||
match := hunkHeaderRegexp.FindStringSubmatch(header)
|
||||
|
||||
oldStart := utils.MustConvertToInt(match[1])
|
||||
newStart := utils.MustConvertToInt(match[2])
|
||||
headerContext := match[3]
|
||||
oldStart = utils.MustConvertToInt(match[1])
|
||||
oldLength = declaredLength(match[2])
|
||||
newStart = utils.MustConvertToInt(match[3])
|
||||
newLength = declaredLength(match[4])
|
||||
headerContext = match[5]
|
||||
|
||||
return oldStart, newStart, headerContext
|
||||
return oldStart, oldLength, newStart, newLength, headerContext
|
||||
}
|
||||
|
||||
// declaredLength parses a hunk header length capture, which git omits when it
|
||||
// is 1 (e.g. "@@ -0,0 +1 @@").
|
||||
func declaredLength(match string) int {
|
||||
if match == "" {
|
||||
return 1
|
||||
}
|
||||
return utils.MustConvertToInt(match)
|
||||
}
|
||||
|
||||
func newHunkLine(line string) *PatchLine {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,23 @@ func (self *Patch) HunkEndIdx(hunkIndex int) int {
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -761,6 +761,30 @@ func TestPatchLineForOldLineNumber(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIsWellFormed(t *testing.T) {
|
||||
assert.True(t, Parse(simpleDiff).IsWellFormed())
|
||||
assert.True(t, Parse(twoHunks).IsWellFormed())
|
||||
assert.True(t, Parse(consecutiveDeletions).IsWellFormed())
|
||||
assert.True(t, Parse(newFile).IsWellFormed())
|
||||
assert.True(t, Parse(deletedFile).IsWellFormed())
|
||||
assert.True(t, Parse(addNewlineToEndOfFile).IsWellFormed())
|
||||
|
||||
// A diff whose body has been mangled so the +/- markers no longer start each
|
||||
// line (as delta's line-number gutters do) reads as all-context, so the body
|
||||
// lengths no longer match the header.
|
||||
gutterMangled := `diff --git a/filename b/filename
|
||||
index 9320895..6d79956 100644
|
||||
--- a/filename
|
||||
+++ b/filename
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
grape
|
||||
pear
|
||||
lemon
|
||||
`
|
||||
assert.False(t, Parse(gutterMangled).IsWellFormed())
|
||||
}
|
||||
|
||||
func TestGetNextStageableLineIndex(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
|
|
|
|||
|
|
@ -551,9 +551,11 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName
|
|||
// Capture before any mutation below that might re-render the main view.
|
||||
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context(), clickedLineIdx)
|
||||
|
||||
clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx)
|
||||
if !ok {
|
||||
line = -1
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
|
||||
line := -1
|
||||
isDeletion := false
|
||||
if ok {
|
||||
line, isDeletion = info.PatchSelectLine()
|
||||
}
|
||||
|
||||
node := self.getSelectedItem()
|
||||
|
|
@ -562,7 +564,7 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName
|
|||
}
|
||||
|
||||
if !node.IsFile() && ok {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), clickedFile)
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -580,7 +582,7 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName
|
|||
}
|
||||
|
||||
// Entered from the focused main view, so escaping returns there.
|
||||
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, SelectLineInDefaultMode: true})
|
||||
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -412,9 +412,11 @@ func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName strin
|
|||
// Capture before any mutation below that might re-render the main view.
|
||||
snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context(), clickedLineIdx)
|
||||
|
||||
clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx)
|
||||
if !ok {
|
||||
line = -1
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
|
||||
line := -1
|
||||
isDeletion := false
|
||||
if ok {
|
||||
line, isDeletion = info.PatchSelectLine()
|
||||
}
|
||||
|
||||
node := self.context().GetSelected()
|
||||
|
|
@ -423,7 +425,7 @@ func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName strin
|
|||
}
|
||||
|
||||
if !node.IsFile() && ok {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), clickedFile)
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -439,7 +441,7 @@ func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName strin
|
|||
}
|
||||
}
|
||||
|
||||
return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, SelectLineInDefaultMode: true})
|
||||
return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
154
pkg/gui/controllers/helpers/diff_line_parser.go
Normal file
154
pkg/gui/controllers/helpers/diff_line_parser.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
// diffFilePrefix marks the start of a file's section in a (possibly multi-file)
|
||||
// unified diff.
|
||||
const diffFilePrefix = "diff --git "
|
||||
|
||||
// parsedDiffLine is what parseDiffLineFromBuffer recovers about a rendered diff
|
||||
// row. RelPath is repo-relative (as the diff header spells it); the caller turns
|
||||
// it into the absolute path of types.DiffLineInfo.
|
||||
type parsedDiffLine struct {
|
||||
RelPath string
|
||||
Type types.DiffLineType
|
||||
NewLine int
|
||||
OldLine int
|
||||
}
|
||||
|
||||
// parseDiffLineFromBuffer recovers a rendered diff row's patch-space identity by
|
||||
// parsing the decolorized diff buffer (mechanism #1 in diff-line-metadata-notes.md).
|
||||
//
|
||||
// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to
|
||||
// resolve. A commit diff spans multiple files, so we first split on the
|
||||
// "diff --git" boundaries to isolate the file section containing targetIdx, then
|
||||
// reuse patch.Parse on that single-file section: its patch line indices line up
|
||||
// 1:1 with the section's buffer lines, so the type and old/new line numbers fall
|
||||
// straight out of the patch arithmetic.
|
||||
//
|
||||
// ok is false when the buffer isn't a parseable unified diff at targetIdx (e.g.
|
||||
// a pager that restructures the diff, like delta's default mode), so the caller
|
||||
// can fall back to another backend.
|
||||
func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) {
|
||||
if targetIdx < 0 || targetIdx >= len(bufferLines) {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
// Find the file section containing the target: the nearest "diff --git" at or
|
||||
// above it, up to the next one (or the end of the buffer).
|
||||
fileStart := -1
|
||||
for i := targetIdx; i >= 0; i-- {
|
||||
if strings.HasPrefix(bufferLines[i], diffFilePrefix) {
|
||||
fileStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if fileStart == -1 {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
fileEnd := len(bufferLines)
|
||||
for i := fileStart + 1; i < len(bufferLines); i++ {
|
||||
if strings.HasPrefix(bufferLines[i], diffFilePrefix) {
|
||||
fileEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fileLines := bufferLines[fileStart:fileEnd]
|
||||
relPath := pathFromDiffHeader(fileLines)
|
||||
if relPath == "" {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
p := patch.Parse(strings.Join(fileLines, "\n"))
|
||||
// Bail if the body doesn't match the hunk headers: the rendering restructured
|
||||
// the diff (e.g. delta's line-number gutters push the +/- marker off the
|
||||
// start of the line, so every body line reads as context), and trusting the
|
||||
// mis-parse would land us on the wrong line. Better to fall back.
|
||||
if !p.IsWellFormed() {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
patchLines := p.Lines()
|
||||
patchLineIdx := targetIdx - fileStart
|
||||
if patchLineIdx < 0 || patchLineIdx >= len(patchLines) {
|
||||
return parsedDiffLine{}, false
|
||||
}
|
||||
|
||||
result := parsedDiffLine{
|
||||
RelPath: relPath,
|
||||
Type: diffLineTypeForKind(patchLines[patchLineIdx].Kind),
|
||||
NewLine: p.LineNumberOfLine(patchLineIdx),
|
||||
}
|
||||
if result.Type == types.DiffLineDeleted {
|
||||
result.OldLine = p.OldLineNumberOfLine(patchLineIdx)
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func diffLineTypeForKind(kind patch.PatchLineKind) types.DiffLineType {
|
||||
switch kind {
|
||||
case patch.PATCH_HEADER:
|
||||
return types.DiffLineFileHeader
|
||||
case patch.HUNK_HEADER:
|
||||
return types.DiffLineHunkHeader
|
||||
case patch.ADDITION:
|
||||
return types.DiffLineAdded
|
||||
case patch.DELETION:
|
||||
return types.DiffLineDeleted
|
||||
case patch.CONTEXT:
|
||||
return types.DiffLineContext
|
||||
default:
|
||||
return types.DiffLineOther
|
||||
}
|
||||
}
|
||||
|
||||
// pathFromDiffHeader extracts the new-file path of a single file's diff section.
|
||||
// It prefers the "+++ b/<path>" line (falling back to "--- a/<path>" when the
|
||||
// new path is /dev/null, i.e. a deleted file), and as a last resort the
|
||||
// "diff --git" line. Paths with characters git C-quotes are not handled (this is
|
||||
// a prototype); the common unquoted case is.
|
||||
func pathFromDiffHeader(fileLines []string) string {
|
||||
var oldPath, newPath string
|
||||
for _, line := range fileLines {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
break // past the header
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
newPath = stripDiffPathPrefix(strings.TrimPrefix(line, "+++ "))
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
oldPath = stripDiffPathPrefix(strings.TrimPrefix(line, "--- "))
|
||||
}
|
||||
}
|
||||
|
||||
if newPath != "" && newPath != "/dev/null" {
|
||||
return newPath
|
||||
}
|
||||
if oldPath != "" && oldPath != "/dev/null" {
|
||||
return oldPath
|
||||
}
|
||||
return pathFromDiffGitLine(fileLines[0])
|
||||
}
|
||||
|
||||
// stripDiffPathPrefix removes git's default a/ or b/ diff path prefix if present.
|
||||
func stripDiffPathPrefix(path string) string {
|
||||
if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") {
|
||||
return path[2:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// pathFromDiffGitLine extracts the new-file path from a "diff --git a/X b/X"
|
||||
// line, used only when the +++/--- lines are absent.
|
||||
func pathFromDiffGitLine(line string) string {
|
||||
rest := strings.TrimPrefix(line, diffFilePrefix)
|
||||
if idx := strings.LastIndex(rest, " b/"); idx != -1 {
|
||||
return rest[idx+len(" b/"):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
92
pkg/gui/controllers/helpers/diff_line_parser_test.go
Normal file
92
pkg/gui/controllers/helpers/diff_line_parser_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// A two-file commit diff as it appears (decolorized) in the main view. file1 has
|
||||
// two consecutive deletions (grape, pear) that share a new-file line number;
|
||||
// file2 has two consecutive additions.
|
||||
const twoFileDiff = `diff --git a/file1.go b/file1.go
|
||||
index 1111111..2222222 100644
|
||||
--- a/file1.go
|
||||
+++ b/file1.go
|
||||
@@ -1,4 +1,2 @@
|
||||
apple
|
||||
-grape
|
||||
-pear
|
||||
lemon
|
||||
diff --git a/dir/file2.go b/dir/file2.go
|
||||
index 3333333..4444444 100644
|
||||
--- a/dir/file2.go
|
||||
+++ b/dir/file2.go
|
||||
@@ -10,2 +9,4 @@ func foo() {
|
||||
ctx
|
||||
+added1
|
||||
+added2
|
||||
ctx2`
|
||||
|
||||
func TestParseDiffLineFromBuffer(t *testing.T) {
|
||||
bufferLines := strings.Split(twoFileDiff, "\n")
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
targetIdx int
|
||||
expected parsedDiffLine
|
||||
expectOk bool
|
||||
}{
|
||||
{"file header", 0, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineFileHeader, NewLine: 1}, true},
|
||||
{"hunk header", 4, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineHunkHeader, NewLine: 1}, true},
|
||||
{"context line", 5, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineContext, NewLine: 1}, true},
|
||||
// The two deletions share new-file line 2 but have distinct old-file lines.
|
||||
{"first deletion", 6, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 2}, true},
|
||||
{"second deletion", 7, parsedDiffLine{RelPath: "file1.go", Type: types.DiffLineDeleted, NewLine: 2, OldLine: 3}, true},
|
||||
// The second file: its path comes from the second "diff --git" section,
|
||||
// and its additions get distinct new-file line numbers.
|
||||
{"first addition", 15, parsedDiffLine{RelPath: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, true},
|
||||
{"second addition", 16, parsedDiffLine{RelPath: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 11}, true},
|
||||
{"out of range", 999, parsedDiffLine{}, false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result, ok := parseDiffLineFromBuffer(bufferLines, s.targetIdx)
|
||||
assert.Equal(t, s.expectOk, ok)
|
||||
if s.expectOk {
|
||||
assert.Equal(t, s.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferNotADiff(t *testing.T) {
|
||||
// A rendering with no "diff --git" line (e.g. delta's default mode) can't be
|
||||
// parsed, so the caller falls back to another backend.
|
||||
bufferLines := []string{"some", "lines", "that", "are not a diff"}
|
||||
_, ok := parseDiffLineFromBuffer(bufferLines, 2)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseDiffLineFromBufferGutterMangled(t *testing.T) {
|
||||
// delta with line-number gutters keeps the diff/hunk headers but pushes the
|
||||
// +/- markers off the start of each body line, so every line reads as
|
||||
// context. The body no longer matches the hunk header, so we refuse to parse
|
||||
// (and the caller falls back) rather than return a confident mis-parse.
|
||||
mangled := strings.Split(`diff --git a/file1.txt b/file1.txt
|
||||
index 1111111..2222222 100644
|
||||
--- a/file1.txt
|
||||
+++ b/file1.txt
|
||||
@@ -1,5 +1,3 @@
|
||||
1 ⋮ 1 │ apple
|
||||
2 ⋮ │-grape
|
||||
3 ⋮ │-pear
|
||||
4 ⋮ 2 │ lemon
|
||||
5 ⋮ 3 │ mango`, "\n")
|
||||
|
||||
_, ok := parseDiffLineFromBuffer(mangled, 6)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
|
@ -159,7 +159,7 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt
|
|||
|
||||
oldState := context.GetState()
|
||||
|
||||
state := patch_exploring.NewState(diff, selectedLineIdx, selectedRealLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView, opts.SelectLineInDefaultMode)
|
||||
state := patch_exploring.NewState(diff, selectedLineIdx, selectedRealLineIdx, opts.ClickedViewRealLineIsDeletion, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView, opts.SelectLineInDefaultMode)
|
||||
context.SetState(state)
|
||||
if state == nil {
|
||||
self.Escape()
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/patch_exploring"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
var lazygitEditURLRegexp = regexp.MustCompile(`^lazygit-edit://(.+?):(\d+)$`)
|
||||
|
||||
type StagingHelper struct {
|
||||
c *HelperCommon
|
||||
windowHelper *WindowHelper
|
||||
|
|
@ -74,11 +78,11 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) {
|
|||
|
||||
hunkMode := self.c.UserConfig().Gui.UseHunkModeInStagingView
|
||||
mainContext.SetState(
|
||||
patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainSelectedRealLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode),
|
||||
patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainSelectedRealLineIdx, focusOpts.ClickedViewRealLineIsDeletion, mainContext.GetView(), mainContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode),
|
||||
)
|
||||
|
||||
secondaryContext.SetState(
|
||||
patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondarySelectedRealLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode),
|
||||
patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondarySelectedRealLineIdx, focusOpts.ClickedViewRealLineIsDeletion, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode),
|
||||
)
|
||||
|
||||
mainState := mainContext.GetState()
|
||||
|
|
@ -136,19 +140,67 @@ func (self *StagingHelper) mainStagingFocused() bool {
|
|||
return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().Staging.GetKey()
|
||||
}
|
||||
|
||||
func (self *StagingHelper) GetFileAndLineForClickedDiffLine(windowName string, lineIdx int) (string, int, bool) {
|
||||
// GetDiffLineInfo recovers the patch-space identity — (file, type, new-line,
|
||||
// old-line) — of a rendered diff row, given the window showing the diff and the
|
||||
// (wrapped) view line index. It is the single seam the focused main view and
|
||||
// patch explorer consumers go through to act on the line the user is pointing
|
||||
// at, and the strategy behind it is swappable (see diff-line-metadata-notes.md).
|
||||
//
|
||||
// Today it first parses the decolorized view buffer (mechanism #1), which serves
|
||||
// the structure-preserving renderings (no pager, git diff --color,
|
||||
// delta --color-only, diff-so-fancy --patch). When that fails — e.g. a pager
|
||||
// restructured the diff, as delta's default mode does — it falls back to delta's
|
||||
// lazygit-edit:// hyperlinks. The hyperlink can't convey the side, so its result
|
||||
// is reported as a non-deletion content line. A future backend reading #2's
|
||||
// per-cell OSC metadata would slot in ahead of these, behind the same shape.
|
||||
func (self *StagingHelper) GetDiffLineInfo(windowName string, viewLineIdx int) (types.DiffLineInfo, bool) {
|
||||
v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName))
|
||||
hyperlink, ok := v.HyperLinkInLine(lineIdx, "lazygit-edit:")
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
if v == nil {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^lazygit-edit://(.+?):(\d+)$`)
|
||||
matches := re.FindStringSubmatch(hyperlink)
|
||||
if matches == nil {
|
||||
return "", 0, false
|
||||
if info, ok := self.diffLineInfoFromBuffer(v, viewLineIdx); ok {
|
||||
return info, true
|
||||
}
|
||||
filepath := matches[1]
|
||||
lineNumber := utils.MustConvertToInt(matches[2])
|
||||
return filepath, lineNumber, true
|
||||
return self.diffLineInfoFromHyperlink(v, viewLineIdx)
|
||||
}
|
||||
|
||||
func (self *StagingHelper) diffLineInfoFromBuffer(v *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
|
||||
bufferLineIdx, ok := v.BufferLineForViewLine(viewLineIdx)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
parsed, ok := parseDiffLineFromBuffer(v.BufferLines(), bufferLineIdx)
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
return types.DiffLineInfo{
|
||||
Path: filepath.Join(self.c.Git().RepoPaths.WorktreePath(), parsed.RelPath),
|
||||
Type: parsed.Type,
|
||||
NewLine: parsed.NewLine,
|
||||
OldLine: parsed.OldLine,
|
||||
}, true
|
||||
}
|
||||
|
||||
func (self *StagingHelper) diffLineInfoFromHyperlink(v *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) {
|
||||
hyperlink, ok := v.HyperLinkInLine(viewLineIdx, "lazygit-edit:")
|
||||
if !ok {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
matches := lazygitEditURLRegexp.FindStringSubmatch(hyperlink)
|
||||
if matches == nil {
|
||||
return types.DiffLineInfo{}, false
|
||||
}
|
||||
|
||||
return types.DiffLineInfo{
|
||||
// delta emits an absolute path here, which is what the consumers want.
|
||||
Path: matches[1],
|
||||
// The hyperlink carries no side, so it can't distinguish a deletion from
|
||||
// an addition or context line; report it as a plain content line.
|
||||
Type: types.DiffLineOther,
|
||||
NewLine: utils.MustConvertToInt(matches[2]),
|
||||
}, true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,13 +228,13 @@ func (self *MainViewController) editLine() error {
|
|||
return nil
|
||||
}
|
||||
// Figure out the clicked file and line the same way entering staging does.
|
||||
path, lineNumber, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(
|
||||
self.context.GetViewName(), self.context.GetView().SelectedLineIdx())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context.GetViewName())
|
||||
return self.c.Helpers().Files.EditFileAtLine(path, lineNumber)
|
||||
lineNumber := self.c.Helpers().Diff.AdjustLineNumber(info.Path, info.NewLine, self.context.GetViewName())
|
||||
return self.c.Helpers().Files.EditFileAtLine(info.Path, lineNumber)
|
||||
}
|
||||
|
||||
func (self *MainViewController) openPullRequestForSelectedLine() error {
|
||||
|
|
@ -273,20 +273,24 @@ func (self *MainViewController) openPullRequestForSelectedLine() error {
|
|||
}
|
||||
|
||||
// Figure out the clicked file and line the same way entering staging does.
|
||||
path, lineNumber, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(
|
||||
self.context.GetViewName(), self.context.GetView().SelectedLineIdx())
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A deletion isn't on the right (new) side of the diff, so anchor it on the
|
||||
// left (old) side; everything else on the right.
|
||||
side, lineNumber := info.PullRequestAnchor()
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
|
||||
return self.c.OS().OpenLink(
|
||||
githubPullRequestLineURL(pr.Url, commitSha, filepath.ToSlash(relativePath), lineNumber))
|
||||
githubPullRequestLineURL(pr.Url, commitSha, filepath.ToSlash(relativePath), side, lineNumber))
|
||||
}
|
||||
|
||||
// branchForPullRequest returns the local branch whose pull request applies to
|
||||
|
|
@ -318,12 +322,12 @@ func (self *MainViewController) branchForPullRequest(sidePanelContext types.Cont
|
|||
|
||||
// githubPullRequestLineURL builds a URL that opens the given line of a file in
|
||||
// the diff of a specific commit within a GitHub pull request. The file is
|
||||
// identified by the SHA-256 of its repo-relative path, and R<line> targets the
|
||||
// right (new) side of the diff. See
|
||||
// identified by the SHA-256 of its repo-relative path, and side ("R"/"L")
|
||||
// selects the right (new) or left (old) side of the diff. See
|
||||
// https://github.com/orgs/community/discussions/55764.
|
||||
func githubPullRequestLineURL(prURL string, commitSha string, relativePath string, lineNumber int) string {
|
||||
func githubPullRequestLineURL(prURL string, commitSha string, relativePath string, side string, lineNumber int) string {
|
||||
pathHash := sha256.Sum256([]byte(relativePath))
|
||||
anchor := fmt.Sprintf("diff-%sR%d", hex.EncodeToString(pathHash[:]), lineNumber)
|
||||
anchor := fmt.Sprintf("diff-%s%s%d", hex.EncodeToString(pathHash[:]), side, lineNumber)
|
||||
return fmt.Sprintf("%s/changes/%s#%s", prURL, commitSha, anchor)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -151,15 +151,17 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO
|
|||
return self.withRenderAndFocus(self.HandleMouseDown)()
|
||||
}
|
||||
|
||||
_, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(self.context.GetWindowName(), opts.Y)
|
||||
if !ok {
|
||||
line = -1
|
||||
line := -1
|
||||
isDeletion := false
|
||||
if info, ok := self.c.Helpers().Staging.GetDiffLineInfo(self.context.GetWindowName(), opts.Y); ok {
|
||||
line, isDeletion = info.PatchSelectLine()
|
||||
}
|
||||
|
||||
self.c.Context().Push(self.context, types.OnFocusOpts{
|
||||
ClickedWindowName: self.context.GetWindowName(),
|
||||
ClickedViewLineIdx: opts.Y,
|
||||
ClickedViewRealLineIdx: line,
|
||||
ClickedWindowName: self.context.GetWindowName(),
|
||||
ClickedViewLineIdx: opts.Y,
|
||||
ClickedViewRealLineIdx: line,
|
||||
ClickedViewRealLineIsDeletion: isDeletion,
|
||||
})
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -54,10 +54,11 @@ func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOp
|
|||
|
||||
func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error {
|
||||
return func(mainViewName string, clickedLineIdx int) error {
|
||||
clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx)
|
||||
info, ok := self.c.Helpers().Staging.GetDiffLineInfo(mainViewName, clickedLineIdx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
line, isDeletion := info.PatchSelectLine()
|
||||
|
||||
// Capture before self.enter() pushes the commit files panel, which
|
||||
// re-renders the main view. We escape "all the way out" to this side
|
||||
|
|
@ -71,7 +72,7 @@ func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainVi
|
|||
context := self.c.Contexts().CommitFiles
|
||||
var node *filetree.CommitFileNode
|
||||
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), clickedFile)
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), info.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -88,7 +89,7 @@ func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainVi
|
|||
context.GetViewTrait().FocusPoint(
|
||||
context.ModelIndexToViewIndex(idx), false)
|
||||
node = context.GetSelected()
|
||||
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, SelectLineInDefaultMode: true})
|
||||
return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, ClickedViewRealLineIsDeletion: isDeletion, SelectLineInDefaultMode: true})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ const (
|
|||
HUNK
|
||||
)
|
||||
|
||||
func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool, selectLineInDefaultMode bool) *State {
|
||||
func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, selectedRealLineIsDeletion bool, view *gocui.View, oldState *State, useHunkModeByDefault bool, selectLineInDefaultMode bool) *State {
|
||||
if oldState != nil && diff == oldState.diff && selectedLineIdx == -1 {
|
||||
// if we're here then we can return the old state. If selectedLineIdx was not -1
|
||||
// then that would mean we were trying to click and potentially drag a range, which
|
||||
|
|
@ -62,10 +62,18 @@ func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, view *g
|
|||
viewLineIndices, patchLineIndices := wrapPatchLines(diff, view)
|
||||
|
||||
if selectedRealLineIdx != -1 {
|
||||
// PatchLineForLineNumber returns a patch line index, but selectedLineIdx
|
||||
// is in view-line (wrapped) space, so convert it. Without this the
|
||||
// landing line is off by the number of wrapped lines above it.
|
||||
patchLineIdx := patch.PatchLineForLineNumber(selectedRealLineIdx)
|
||||
// Look the source line number up in the freshly parsed patch. A deletion
|
||||
// is identified by its old-file line number (two consecutive deletions
|
||||
// share a new-file line number), everything else by its new-file one.
|
||||
// The result is a patch line index, but selectedLineIdx is in view-line
|
||||
// (wrapped) space, so convert it; without this the landing line is off by
|
||||
// the number of wrapped lines above it.
|
||||
var patchLineIdx int
|
||||
if selectedRealLineIsDeletion {
|
||||
patchLineIdx = patch.PatchLineForOldLineNumber(selectedRealLineIdx)
|
||||
} else {
|
||||
patchLineIdx = patch.PatchLineForLineNumber(selectedRealLineIdx)
|
||||
}
|
||||
selectedLineIdx = viewLineIndices[lo.Clamp(patchLineIdx, 0, len(viewLineIndices)-1)]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -258,9 +258,17 @@ type OnFocusOpts struct {
|
|||
ClickedWindowName string
|
||||
ClickedViewLineIdx int
|
||||
|
||||
// If not -1, takes precedence over ClickedViewLineIdx.
|
||||
// A source line number identifying the line to land on in the patch
|
||||
// explorer. If not -1, takes precedence over ClickedViewLineIdx. It is a
|
||||
// new-file line number, unless ClickedViewRealLineIsDeletion is set, in which
|
||||
// case it is an old-file line number (two consecutive deletions share a
|
||||
// new-file line number, so only the old-file number identifies a deletion).
|
||||
ClickedViewRealLineIdx int
|
||||
|
||||
// Whether ClickedViewRealLineIdx is an old-file line number for a deletion;
|
||||
// see above.
|
||||
ClickedViewRealLineIsDeletion bool
|
||||
|
||||
// When entering a patch explorer (staging or patch building) by clicking or
|
||||
// pressing enter on a line in a focused main view, we select that line using
|
||||
// the default select mode (hunk or line, per the UseHunkModeInStagingView
|
||||
|
|
|
|||
56
pkg/gui/types/diff_line_info.go
Normal file
56
pkg/gui/types/diff_line_info.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package types
|
||||
|
||||
// DiffLineType classifies a rendered diff row. It mirrors the per-line "type"
|
||||
// of the metadata model described in diff-line-metadata-notes.md, so that the
|
||||
// host-side buffer parser (mechanism #1) and the future pager-emitted OSC
|
||||
// metadata (#2) produce the same shape.
|
||||
type DiffLineType int
|
||||
|
||||
const (
|
||||
DiffLineFileHeader DiffLineType = iota
|
||||
DiffLineHunkHeader
|
||||
DiffLineContext
|
||||
DiffLineAdded
|
||||
DiffLineDeleted
|
||||
// DiffLineOther is anything that isn't one of the above (e.g. the
|
||||
// "\ No newline at end of file" marker). It is also what a backend that
|
||||
// can't determine the side reports — delta's lazygit-edit hyperlinks carry
|
||||
// no side — so consumers treat it like a non-deletion content line.
|
||||
DiffLineOther
|
||||
)
|
||||
|
||||
// DiffLineInfo is the patch-space identity of a rendered diff row, as recovered
|
||||
// by StagingHelper.GetDiffLineInfo. It is the single shape the focused main view
|
||||
// and patch explorer consumers act on, regardless of which backend produced it.
|
||||
type DiffLineInfo struct {
|
||||
// Path is the absolute path of the file the line belongs to.
|
||||
Path string
|
||||
Type DiffLineType
|
||||
// NewLine is the line's position in the new file. Set for all content lines
|
||||
// (for a deletion it is the new-file position the deletion sits at).
|
||||
NewLine int
|
||||
// OldLine is the line's position in the old file. Set only for deletions.
|
||||
OldLine int
|
||||
}
|
||||
|
||||
// PatchSelectLine returns the source line to land on when diving into the patch
|
||||
// explorer for this row, in source-line-number space so it survives the patch
|
||||
// being regenerated. For a deletion it is the old-file line number — two
|
||||
// consecutive deletions share a new-file line number, so only the old-file
|
||||
// number tells them apart — and for everything else the new-file line number.
|
||||
func (self DiffLineInfo) PatchSelectLine() (lineNumber int, isDeletion bool) {
|
||||
if self.Type == DiffLineDeleted {
|
||||
return self.OldLine, true
|
||||
}
|
||||
return self.NewLine, false
|
||||
}
|
||||
|
||||
// PullRequestAnchor returns the side ("L"/"R") and line number to anchor a
|
||||
// GitHub PR deep-link at: the left/old side for a deletion, the right/new side
|
||||
// otherwise.
|
||||
func (self DiffLineInfo) PullRequestAnchor() (side string, lineNumber int) {
|
||||
if self.Type == DiffLineDeleted {
|
||||
return "L", self.OldLine
|
||||
}
|
||||
return "R", self.NewLine
|
||||
}
|
||||
Loading…
Reference in a new issue