diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index f21fb607f..dc5a77834 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -68,6 +68,7 @@ func (gui *Gui) resetHelpersAndControllers() { searchHelper, ) diffHelper := helpers.NewDiffHelper(helperCommon) + diffLineHelper := helpers.NewDiffLineHelper(helperCommon) cherryPickHelper := helpers.NewCherryPickHelper( helperCommon, rebaseHelper, @@ -110,6 +111,7 @@ func (gui *Gui) resetHelpersAndControllers() { SuspendResume: helpers.NewSuspendResumeHelper(helperCommon), Snake: helpers.NewSnakeHelper(helperCommon), Diff: diffHelper, + DiffLine: diffLineHelper, Repos: reposHelper, RecordDirectory: recordDirectoryHelper, Update: helpers.NewUpdateHelper(helperCommon, gui.Updater), diff --git a/pkg/gui/controllers/helpers/diff_line_helper.go b/pkg/gui/controllers/helpers/diff_line_helper.go new file mode 100644 index 000000000..de4a60960 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_helper.go @@ -0,0 +1,55 @@ +package helpers + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +type DiffLineHelper struct { + c *HelperCommon +} + +func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper { + return &DiffLineHelper{c: c} +} + +// GetDiffLineInfo recovers the identity — file, kind, and old/new line number — +// of the diff row at the given (wrapped) view line of the given view. It is the +// seam every consumer of a diff row goes through, so that how we recover that +// identity can change without them noticing: today the only way is to parse the +// view's contents as a unified diff, which works for the renderings that keep a +// diff's structure (no renderer, `git diff --color`, a renderer that only +// colorizes) and fails for the ones that restructure it. +// +// ok is false when the row's identity can't be recovered, in which case the +// caller must not act on the line at all. +func (self *DiffLineHelper) GetDiffLineInfo(view *gocui.View, viewLineIdx int) (types.DiffLineInfo, bool) { + // The cursor and clicks land on a view line, which counts wrapped segments; + // the contents are indexed by unwrapped buffer line. + bufferLineIdx, ok := view.BufferLineForViewLine(viewLineIdx) + if !ok { + return types.DiffLineInfo{}, false + } + + // The lines as written, not as shown: git ends the path field of a diff header + // with a tab when the path contains a space, and the view shows a tab as spaces. + parsed, ok := parseDiffLineFromBuffer(view.LinesAsWritten(), bufferLineIdx) + if !ok { + return types.DiffLineInfo{}, false + } + + return self.diffLineInfoFromParsed(parsed), true +} + +// diffLineInfoFromParsed turns the parser's repo-relative result into the +// absolute-path identity consumers work with. +func (self *DiffLineHelper) diffLineInfoFromParsed(parsed parsedDiffLine) types.DiffLineInfo { + return types.DiffLineInfo{ + Path: filepath.Join(self.c.Git().RepoPaths.WorktreePath(), parsed.RelPath), + Type: parsed.Type, + NewLine: parsed.NewLine, + OldLine: parsed.OldLine, + } +} diff --git a/pkg/gui/controllers/helpers/diff_line_parser.go b/pkg/gui/controllers/helpers/diff_line_parser.go new file mode 100644 index 000000000..c9abcf5c2 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_parser.go @@ -0,0 +1,286 @@ +package helpers + +import ( + "regexp" + "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 " + +// submodulePrefix opens the line a submodule's section of a diff starts with, which +// stands in for the "diff --git" header a file of the repo gets. +const submodulePrefix = "Submodule " + +// submoduleSectionPattern matches that line and captures the submodule's path. git +// writes one of two kinds: the commit the submodule is checked out at has moved +// ("Submodule sub a32f27c..2d9f921:", with "..." in place of ".." where the move is +// no fast-forward, " (rewind)" where it goes backwards, and a message in brackets in +// place of the colon where the two commits can't both be read), or its working tree +// is dirty ("Submodule sub contains untracked content"). +// +// The path is captured greedily: git writes it unquoted, so a path that itself ends +// in something reading like a range of commits is told apart by taking the last such +// range on the line. +var submoduleSectionPattern = regexp.MustCompile( + `^Submodule (.+) (?:contains (?:untracked|modified) content|[0-9a-f]+\.{2,3}[0-9a-f]+(?: \(.*\))?:?)$`) + +// parsedDiffLine is what the parser recovers about a row of a rendered diff. +// RelPath is the path as the diff header spells it, i.e. relative to the repo +// root; the caller turns it into the absolute path of types.DiffLineInfo. +type parsedDiffLine struct { + RelPath string + Type types.DiffLineType + NewLine int + OldLine int +} + +// bufferLineParse is the parser's result for one buffer line: the recovered +// identity, and whether the line could be resolved at all (false for a line in +// an unparseable section, or outside any file section). +type bufferLineParse struct { + parsed parsedDiffLine + ok bool +} + +// parseDiffLineFromBuffer recovers the identity of a row of a rendered diff by +// parsing the view's decolorized contents. +// +// bufferLines is the full unwrapped view buffer; targetIdx is the buffer line to +// resolve. A commit's diff spans several files, so we isolate the file section +// containing targetIdx and parse just that one (see parseFileSection). Use this +// for a single line, e.g. the one under the cursor; to resolve every line of a +// buffer, use parseAllDiffLinesFromBuffer, which parses each section only once. +// +// ok is false when the buffer isn't a parseable unified diff at targetIdx, +// because the diff renderer restructured it, so that the caller can fall back. +func parseDiffLineFromBuffer(bufferLines []string, targetIdx int) (parsedDiffLine, bool) { + if targetIdx < 0 || targetIdx >= len(bufferLines) { + return parsedDiffLine{}, false + } + start, end := fileSectionBounds(bufferLines, targetIdx) + if start == -1 { + return parsedDiffLine{}, false + } + r := parseFileSection(bufferLines[start:end], end == len(bufferLines))[targetIdx-start] + return r.parsed, r.ok +} + +// parseAllDiffLinesFromBuffer resolves every line of a (possibly multi-file) +// diff buffer in one pass, parsing each file section exactly once. It is the +// batch form of parseDiffLineFromBuffer, for callers that scan a whole buffer: +// resolving line by line would re-parse a section once per line of it — O(n²) on +// a large single-file diff — whereas this is O(n). The result is indexed 1:1 +// with bufferLines; a line in an unparseable section, or above the first one, is +// left ok=false. +func parseAllDiffLinesFromBuffer(bufferLines []string) []bufferLineParse { + result := make([]bufferLineParse, len(bufferLines)) + for i := 0; i < len(bufferLines); { + if !startsFileSection(bufferLines[i]) { + i++ // in no file section; leave it unresolved + continue + } + end := fileSectionEnd(bufferLines, i) + copy(result[i:end], parseFileSection(bufferLines[i:end], end == len(bufferLines))) + i = end + } + return result +} + +// fileSectionBounds returns the half-open range [start, end) of the file section +// containing targetIdx: the nearest line starting a section at or above it, up to +// where that section ends. start is -1 when targetIdx is above the first file +// section, or below the end of the last one that begins above it. +func fileSectionBounds(bufferLines []string, targetIdx int) (start, end int) { + for start = targetIdx; start >= 0; start-- { + if !startsFileSection(bufferLines[start]) { + continue + } + if end = fileSectionEnd(bufferLines, start); targetIdx < end { + return start, end + } + return -1, -1 + } + return -1, -1 +} + +// fileSectionEnd returns the line the file section beginning at start ends before. +// +// A file's section runs to the next one, since every line between them is part of +// its diff. A submodule's runs only as far as what git writes for it — the line +// naming it, and the log of the commits it moved over — because the lines after +// that need not belong to any section at all. A diff renderer's output has no +// "diff --git" line to stop at, and the rows it puts between one file and the next +// belong to neither. +func fileSectionEnd(bufferLines []string, start int) int { + if submodulePath(bufferLines[start]) != "" { + end := start + 1 + for end < len(bufferLines) && isSubmoduleLogLine(bufferLines[end]) { + end++ + } + return end + } + + for i := start + 1; i < len(bufferLines); i++ { + if startsFileSection(bufferLines[i]) { + return i + } + } + return len(bufferLines) +} + +// startsFileSection reports whether the line opens a section of a diff: git's header +// for a file of the repo, or the line a submodule's section begins with. +func startsFileSection(line string) bool { + return strings.HasPrefix(line, diffFilePrefix) || submodulePath(line) != "" +} + +// isSubmoduleLogLine reports whether the line is one of the commits git lists under +// a submodule's header, which it writes as two spaces, the direction the commit was +// moved in, and the commit's subject. +func isSubmoduleLogLine(line string) bool { + return strings.HasPrefix(line, " > ") || strings.HasPrefix(line, " < ") +} + +// submodulePath returns the submodule whose section the given line opens, and "" for +// every other line. +// +// A submodule gets no "diff --git" header and no hunks: git states which commits it +// moved between and lists them, so that one line is all there is to take the path +// from. The prefix is tested first so that the pattern is run over next to no lines +// of a diff. +func submodulePath(line string) string { + if !strings.HasPrefix(line, submodulePrefix) { + return "" + } + if match := submoduleSectionPattern.FindStringSubmatch(line); match != nil { + return match[1] + } + return "" +} + +// parseFileSection parses one file's diff section (fileLines, starting at the line +// that opens it) a single time and returns the identity of each of its +// lines, indexed 1:1 with fileLines. patch.Parse's line indices line up with the +// section's buffer lines, so the type and the old/new line numbers fall out of +// the patch arithmetic. A submodule's section has no hunks at all, so every row of +// it comes out as a header of the submodule, which is what they are: what git states +// there is which commits it moved between, not lines of a file. +// +// Every line is left ok=false when the section has no +// recoverable path or isn't a well-formed unified diff — the rendering +// restructured it, and acting on a mis-parse would land us on the wrong line, so +// the caller should fall back. +// +// endsTheBuffer says the section runs to the end of what we were given. That is +// where a diff we have only part of breaks off. A long one is read a screenful +// at a time and the rest as the user scrolls, so its last hunk holds fewer lines +// than its header declares until the reading is done. Insisting on the whole +// hunk there would leave every line of the file unresolved while the diff is the +// one on screen, so a section in that position is held to what has arrived. +func parseFileSection(fileLines []string, endsTheBuffer bool) []bufferLineParse { + result := make([]bufferLineParse, len(fileLines)) + + relPath := pathFromDiffHeader(fileLines) + if relPath == "" { + return result + } + p := patch.Parse(strings.Join(fileLines, "\n")) + isWellFormed := p.IsWellFormed + if endsTheBuffer { + isWellFormed = p.IsWellFormedSoFar + } + if !isWellFormed() { + return result + } + patchLines := p.Lines() + for i := range fileLines { + if i >= len(patchLines) { + break + } + parsed := parsedDiffLine{ + RelPath: relPath, + Type: diffLineTypeForKind(patchLines[i].Kind), + NewLine: p.LineNumberOfLine(i), + } + if parsed.Type == types.DiffLineDeleted { + parsed.OldLine = p.OldLineNumberOfLine(i) + } + result[i] = bufferLineParse{parsed, true} + } + return result +} + +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 diff section. A +// submodule's section states its path in the line it opens with. For a file of the +// repo the path comes from the "+++ b/" line, falling back to "--- a/" +// when the new path is /dev/null (a deleted file), and to the "diff --git" line when +// there are no such lines at all (a pure rename, which has no hunks). +func pathFromDiffHeader(fileLines []string) string { + if path := submodulePath(fileLines[0]); path != "" { + return path + } + + 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 the a/ or b/ prefix git puts on the paths in a +// diff header. We ask git for these prefixes explicitly (diff.noprefix=false), +// so they are always there. +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. A path containing " b/" would defeat this, but the +++/--- lines are +// unambiguous and we only get here when they 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 "" +} diff --git a/pkg/gui/controllers/helpers/diff_line_parser_test.go b/pkg/gui/controllers/helpers/diff_line_parser_test.go new file mode 100644 index 000000000..8e8bec91a --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_parser_test.go @@ -0,0 +1,266 @@ +package helpers + +import ( + "slices" + "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 TestParseDiffLineFromBufferRename(t *testing.T) { + // A rename with no content change has no hunks and no +++/--- lines, so the + // path has to come from the "diff --git" line; a rename with a content + // change has them, and they carry the new path. + pureRename := strings.Split(`diff --git a/old.go b/new.go +similarity index 100% +rename from old.go +rename to new.go`, "\n") + + result, ok := parseDiffLineFromBuffer(pureRename, 2) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{RelPath: "new.go", Type: types.DiffLineFileHeader, NewLine: 1}, result) + + renameWithModification := strings.Split(`diff --git a/old.go b/new.go +similarity index 62% +rename from old.go +rename to new.go +index 1111111..2222222 100644 +--- a/old.go ++++ b/new.go +@@ -1,2 +1,2 @@ + apple +-grape ++kiwi`, "\n") + + result, ok = parseDiffLineFromBuffer(renameWithModification, 10) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{RelPath: "new.go", Type: types.DiffLineAdded, NewLine: 2}, result) +} + +func TestParseDiffLineFromBufferDeletedFile(t *testing.T) { + // The new path is /dev/null, so the identity comes from the old path. + deletedFile := strings.Split(`diff --git a/gone.go b/gone.go +deleted file mode 100644 +index 1111111..0000000 +--- a/gone.go ++++ /dev/null +@@ -1,2 +0,0 @@ +-apple +-grape`, "\n") + + result, ok := parseDiffLineFromBuffer(deletedFile, 7) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{RelPath: "gone.go", Type: types.DiffLineDeleted, NewLine: 0, OldLine: 2}, result) +} + +func TestParseDiffLineFromBufferSubmodule(t *testing.T) { + // A submodule has no "diff --git" header and no hunks: git opens its section with + // the commits it moved between and lists them below. So the section ends the one + // above it, and every row of it belongs to the submodule as a whole. + withSubmodule := strings.Split(`diff --git a/file.txt b/file.txt +index 1111111..2222222 100644 +--- a/file.txt ++++ b/file.txt +@@ -1,2 +1,3 @@ + hello + world ++world +Submodule modules/xyz a32f27c..2d9f921: + > bump the thing`, "\n") + + for _, targetIdx := range []int{8, 9} { + result, ok := parseDiffLineFromBuffer(withSubmodule, targetIdx) + assert.True(t, ok) + assert.Equal(t, + parsedDiffLine{RelPath: "modules/xyz", Type: types.DiffLineFileHeader, NewLine: 1}, + result, "line %d", targetIdx) + } + + result, ok := parseDiffLineFromBuffer(withSubmodule, 7) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{RelPath: "file.txt", Type: types.DiffLineAdded, NewLine: 3}, result) +} + +func TestParseDiffLineFromBufferSubmoduleInARendering(t *testing.T) { + // A diff renderer passes the lines git writes for a submodule through as they + // are, while printing nothing below them that a section could end at. The + // section has to end where what git writes for the submodule ends all the same: + // the rows below belong to the other files of the diff, and can only be placed + // by the records the renderer states for them. + rendered := strings.Split(`Submodule modules/xyz a32f27c..2d9f921: + > bump the thing + +products/a.txt + + one + two`, "\n") + + all := parseAllDiffLinesFromBuffer(rendered) + assert.Equal(t, "modules/xyz", all[0].parsed.RelPath) + assert.Equal(t, "modules/xyz", all[1].parsed.RelPath) + for i := 2; i < len(rendered); i++ { + assert.False(t, all[i].ok, "line %d: %q", i, rendered[i]) + } +} + +func TestSubmodulePath(t *testing.T) { + scenarios := []struct { + name string + line string + expected string + }{ + {"moved on", "Submodule modules/xyz a32f27c..2d9f921:", "modules/xyz"}, + {"moved back", "Submodule modules/xyz 2d9f921..a32f27c (rewind):", "modules/xyz"}, + {"moved sideways", "Submodule modules/xyz a32f27c...2d9f921:", "modules/xyz"}, + {"added", "Submodule modules/xyz 0000000...2d9f921 (new submodule)", "modules/xyz"}, + {"removed", "Submodule modules/xyz a32f27c...0000000 (submodule deleted)", "modules/xyz"}, + {"commits missing", "Submodule modules/xyz a32f27c...2d9f921 (commits not present)", "modules/xyz"}, + {"dirty", "Submodule modules/xyz contains modified content", "modules/xyz"}, + {"with something new in it", "Submodule modules/xyz contains untracked content", "modules/xyz"}, + // git writes the path unquoted, so one with a space in it, or one ending in + // something that reads like a range of commits, is told apart by matching the + // last range on the line. + {"path with a space", "Submodule my modules/xyz a32f27c..2d9f921:", "my modules/xyz"}, + {"path reading like a range", "Submodule a32f27c..2d9f921 deadbee..fa1afe1:", "a32f27c..2d9f921"}, + // A line of a file that reads like one of these is indented by the column the + // diff states the line's side in, so it cannot be mistaken for one. + {"a line of a file", " Submodule modules/xyz contains modified content", ""}, + {"something else entirely", "Submodule support was added", ""}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, submodulePath(s.line)) + }) + } +} + +func TestParseDiffLineFromBufferNotADiff(t *testing.T) { + // A rendering with no "diff --git" line can't be parsed, so the caller falls + // back rather than acting on the line. + bufferLines := []string{"some", "lines", "that", "are not a diff"} + _, ok := parseDiffLineFromBuffer(bufferLines, 2) + assert.False(t, ok) +} + +func TestParseDiffLineFromBufferGutterMangled(t *testing.T) { + // A diff renderer that moves the line numbers into a gutter keeps the diff + // and 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 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) +} + +func TestParseDiffLineFromBufferReadInPart(t *testing.T) { + lines := strings.Split(twoFileDiff, "\n") + + // A long diff is read a screenful at a time, so the buffer breaks off part way + // through a hunk. The lines that did arrive are resolved all the same, since + // holding out for the whole hunk would leave the diff on screen with nothing to + // act on. + cutShort := lines[:len(lines)-1] + result, ok := parseDiffLineFromBuffer(cutShort, 15) + assert.True(t, ok) + assert.Equal(t, parsedDiffLine{RelPath: "dir/file2.go", Type: types.DiffLineAdded, NewLine: 10}, result) + + // Only the section the buffer breaks off in is read that way. One that another + // section follows is all there, so a hunk short of what its header declares means + // the rendering restructured the diff, and none of it is resolved. + shortFirstSection := append(slices.Clone(lines[:8]), lines[9:]...) + _, ok = parseDiffLineFromBuffer(shortFirstSection, 5) + assert.False(t, ok) +} + +func TestParseAllDiffLinesFromBuffer(t *testing.T) { + // Some decoration above the diff, which belongs to no file section: a commit + // message and a diffstat, as `git show` renders them. + bufferLines := append( + []string{"commit 1234567", "", " do a thing", "", " file1.go | 2 --", ""}, + strings.Split(twoFileDiff, "\n")..., + ) + + all := parseAllDiffLinesFromBuffer(bufferLines) + + // The batch parse resolves each file section once, and has to agree with + // resolving the lines one at a time. + assert.Len(t, all, len(bufferLines)) + for i := range bufferLines { + parsed, ok := parseDiffLineFromBuffer(bufferLines, i) + assert.Equal(t, bufferLineParse{parsed, ok}, all[i], "line %d: %q", i, bufferLines[i]) + } + + // The lines above the first file section are left unresolved. + for i := range 6 { + assert.False(t, all[i].ok) + } + assert.True(t, all[6].ok) +} diff --git a/pkg/gui/controllers/helpers/helpers.go b/pkg/gui/controllers/helpers/helpers.go index 4c9c79f3d..ea1214699 100644 --- a/pkg/gui/controllers/helpers/helpers.go +++ b/pkg/gui/controllers/helpers/helpers.go @@ -39,6 +39,7 @@ type Helpers struct { Snake *SnakeHelper // lives in context package because our contexts need it to render to main Diff *DiffHelper + DiffLine *DiffLineHelper Repos *ReposHelper RecordDirectory *RecordDirectoryHelper Update *UpdateHelper @@ -76,6 +77,7 @@ func NewStubHelpers() *Helpers { Commits: &CommitsHelper{}, Snake: &SnakeHelper{}, Diff: &DiffHelper{}, + DiffLine: &DiffLineHelper{}, Repos: &ReposHelper{}, RecordDirectory: &RecordDirectoryHelper{}, Update: &UpdateHelper{}, diff --git a/pkg/gui/types/diff_line_info.go b/pkg/gui/types/diff_line_info.go new file mode 100644 index 000000000..ae3d8eee9 --- /dev/null +++ b/pkg/gui/types/diff_line_info.go @@ -0,0 +1,42 @@ +package types + +// DiffLineType classifies a row of a rendered diff. +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. + DiffLineOther +) + +// DiffLineInfo is the identity of a row of a rendered diff in terms of the patch +// it was rendered from: which file the row belongs to, what kind of row it is, +// and where the line sits in the old and new versions of that file. This lets us +// act on the line the user is pointing at in a diff view: stage it, open it in an +// editor, keep the cursor on it across a re-render. The rendered text alone tells +// us none of that. +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 version of the file. Set for all + // content lines (for a deletion it is the position the deletion sits at) and + // for hunk headers (the first line of the hunk they head). + NewLine int + // OldLine is the line's position in the old version of the file. Set only + // for deletions, which are the only rows that need it: two consecutive + // deletions share a new-file position and differ only here. + OldLine int +} + +// IsChange reports whether the row is an added or deleted line, as opposed to a +// context line or a header. It mirrors patch.PatchLine.IsChange: those are the +// rows a patch is built from, and the rows navigation moves between. +func (self DiffLineInfo) IsChange() bool { + return self.Type == DiffLineAdded || self.Type == DiffLineDeleted +}