mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 08:06:25 -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>
155 lines
4.9 KiB
Go
155 lines
4.9 KiB
Go
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 ""
|
|
}
|