summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJesse Duffield <jessedduffield@gmail.com>2023-03-19 16:53:02 +1100
committerGitHub <noreply@github.com>2023-03-19 16:53:02 +1100
commitcef804f27aaaec6a728bf1c0df89df2ffb355669 (patch)
treecca09391436d59a10ba342d528b4a8a14a5ccf09
parentb542579db31f160a8d13d255b447d654d253db17 (diff)
parent60f902f026559b1501b34ea3cfbacaf1fff168bf (diff)
Merge pull request #2513 from jesseduffield/refactor-patch-handling
-rw-r--r--go.mod2
-rw-r--r--go.sum4
-rw-r--r--pkg/commands/git.go8
-rw-r--r--pkg/commands/git_commands/patch.go26
-rw-r--r--pkg/commands/patch/format.go169
-rw-r--r--pkg/commands/patch/hunk.go159
-rw-r--r--pkg/commands/patch/parse.go85
-rw-r--r--pkg/commands/patch/patch.go151
-rw-r--r--pkg/commands/patch/patch_builder.go (renamed from pkg/commands/patch/patch_manager.go)97
-rw-r--r--pkg/commands/patch/patch_line.go30
-rw-r--r--pkg/commands/patch/patch_modifier.go187
-rw-r--r--pkg/commands/patch/patch_parser.go230
-rw-r--r--pkg/commands/patch/patch_test.go (renamed from pkg/commands/patch/patch_modifier_test.go)134
-rw-r--r--pkg/commands/patch/transform.go156
-rw-r--r--pkg/gui/commits_panel.go4
-rw-r--r--pkg/gui/context_config.go6
-rw-r--r--pkg/gui/controllers/commits_files_controller.go30
-rw-r--r--pkg/gui/controllers/context_lines_controller.go2
-rw-r--r--pkg/gui/controllers/helpers/patch_building_helper.go2
-rw-r--r--pkg/gui/controllers/patch_building_controller.go6
-rw-r--r--pkg/gui/controllers/staging_controller.go44
-rw-r--r--pkg/gui/custom_patch_options_panel.go14
-rw-r--r--pkg/gui/list_context_config.go2
-rw-r--r--pkg/gui/modes.go2
-rw-r--r--pkg/gui/patch_exploring/state.go47
-rw-r--r--pkg/gui/presentation/files.go6
-rw-r--r--pkg/gui/presentation/files_test.go6
-rw-r--r--pkg/gui/refresh.go4
-rw-r--r--pkg/integration/components/viewDriver.go20
-rw-r--r--vendor/github.com/jesseduffield/gocui/view.go6
-rw-r--r--vendor/modules.txt2
31 files changed, 931 insertions, 710 deletions
diff --git a/go.mod b/go.mod
index 48401ba65..46ad9d742 100644
--- a/go.mod
+++ b/go.mod
@@ -18,7 +18,7 @@ require (
github.com/integrii/flaggy v1.4.0
github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68
github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d
- github.com/jesseduffield/gocui v0.3.1-0.20230314081453-8d2162479b92
+ github.com/jesseduffield/gocui v0.3.1-0.20230319043340-e793609bfbf5
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e
diff --git a/go.sum b/go.sum
index a79a09470..96b177507 100644
--- a/go.sum
+++ b/go.sum
@@ -72,8 +72,8 @@ github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 h1:EQP2Tv8T
github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk=
github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d h1:bO+OmbreIv91rCe8NmscRwhFSqkDJtzWCPV4Y+SQuXE=
github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d/go.mod h1:nGNEErzf+NRznT+N2SWqmHnDnF9aLgANB1CUNEan09o=
-github.com/jesseduffield/gocui v0.3.1-0.20230314081453-8d2162479b92 h1:GO1Cpn3kdnBuUEzEVuvooD9vxmTd9hbyjwJJ6tvafwI=
-github.com/jesseduffield/gocui v0.3.1-0.20230314081453-8d2162479b92/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU=
+github.com/jesseduffield/gocui v0.3.1-0.20230319043340-e793609bfbf5 h1:8k7VTfj/RSKwYQ7Cn+iy876CjixBrTyyn+npxT/Wn/Q=
+github.com/jesseduffield/gocui v0.3.1-0.20230319043340-e793609bfbf5/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU=
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 h1:jmpr7KpX2+2GRiE91zTgfq49QvgiqB0nbmlwZ8UnOx0=
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10/go.mod h1:aA97kHeNA+sj2Hbki0pvLslmE4CbDyhBeSSTUUnOuVo=
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY=
diff --git a/pkg/commands/git.go b/pkg/commands/git.go
index 35cead260..caf03db75 100644
--- a/pkg/commands/git.go
+++ b/pkg/commands/git.go
@@ -117,14 +117,14 @@ func NewGitCommandAux(
workingTreeCommands := git_commands.NewWorkingTreeCommands(gitCommon, submoduleCommands, fileLoader)
rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands)
stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands)
- // TODO: have patch manager take workingTreeCommands in its entirety
- patchManager := patch.NewPatchManager(cmn.Log, workingTreeCommands.ApplyPatch,
+ // TODO: have patch builder take workingTreeCommands in its entirety
+ patchBuilder := patch.NewPatchBuilder(cmn.Log, workingTreeCommands.ApplyPatch,
func(from string, to string, reverse bool, filename string, plain bool) (string, error) {
- // TODO: make patch manager take Gui.IgnoreWhitespaceInDiffView into
+ // TODO: make patch builder take Gui.IgnoreWhitespaceInDiffView into
// account. For now we just pass false.
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, plain, false)
})
- patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchManager)
+ patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, branchCommands.GetRawBranches, branchCommands.CurrentBranchInfo, configCommands)
diff --git a/pkg/commands/git_commands/patch.go b/pkg/commands/git_commands/patch.go
index 643091f63..7511d1a5b 100644
--- a/pkg/commands/git_commands/patch.go
+++ b/pkg/commands/git_commands/patch.go
@@ -16,7 +16,7 @@ type PatchCommands struct {
status *StatusCommands
stash *StashCommands
- PatchManager *patch.PatchManager
+ PatchBuilder *patch.PatchBuilder
}
func NewPatchCommands(
@@ -25,7 +25,7 @@ func NewPatchCommands(
commit *CommitCommands,
status *StatusCommands,
stash *StashCommands,
- patchManager *patch.PatchManager,
+ patchBuilder *patch.PatchBuilder,
) *PatchCommands {
return &PatchCommands{
GitCommon: gitCommon,
@@ -33,7 +33,7 @@ func NewPatchCommands(
commit: commit,
status: status,
stash: stash,
- PatchManager: patchManager,
+ PatchBuilder: patchBuilder,
}
}
@@ -44,7 +44,7 @@ func (self *PatchCommands) DeletePatchesFromCommit(commits []*models.Commit, com
}
// apply each patch in reverse
- if err := self.PatchManager.ApplyPatches(true); err != nil {
+ if err := self.PatchBuilder.ApplyPatches(true); err != nil {
_ = self.rebase.AbortRebase()
return err
}
@@ -55,7 +55,7 @@ func (self *PatchCommands) DeletePatchesFromCommit(commits []*models.Commit, com
}
self.rebase.onSuccessfulContinue = func() error {
- self.PatchManager.Reset()
+ self.PatchBuilder.Reset()
return nil
}
@@ -70,7 +70,7 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
}
// apply each patch forward
- if err := self.PatchManager.ApplyPatches(false); err != nil {
+ if err := self.PatchBuilder.ApplyPatches(false); err != nil {
// Don't abort the rebase here; this might cause conflicts, so give
// the user a chance to resolve them
return err
@@ -82,7 +82,7 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
}
self.rebase.onSuccessfulContinue = func() error {
- self.PatchManager.Reset()
+ self.PatchBuilder.Reset()
return nil
}
@@ -117,7 +117,7 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
}
// apply each patch in reverse
- if err := self.PatchManager.ApplyPatches(true); err != nil {
+ if err := self.PatchBuilder.ApplyPatches(true); err != nil {
_ = self.rebase.AbortRebase()
return err
}
@@ -152,7 +152,7 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
}
self.rebase.onSuccessfulContinue = func() error {
- self.PatchManager.Reset()
+ self.PatchBuilder.Reset()
return nil
}
@@ -173,7 +173,7 @@ func (self *PatchCommands) MovePatchIntoIndex(commits []*models.Commit, commitId
return err
}
- if err := self.PatchManager.ApplyPatches(true); err != nil {
+ if err := self.PatchBuilder.ApplyPatches(true); err != nil {
if self.status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
_ = self.rebase.AbortRebase()
}
@@ -210,7 +210,7 @@ func (self *PatchCommands) MovePatchIntoIndex(commits []*models.Commit, commitId
}
}
- self.PatchManager.Reset()
+ self.PatchBuilder.Reset()
return nil
}
@@ -222,7 +222,7 @@ func (self *PatchCommands) PullPatchIntoNewCommit(commits []*models.Commit, comm
return err
}
- if err := self.PatchManager.ApplyPatches(true); err != nil {
+ if err := self.PatchBuilder.ApplyPatches(true); err != nil {
_ = self.rebase.AbortRebase()
return err
}
@@ -253,7 +253,7 @@ func (self *PatchCommands) PullPatchIntoNewCommit(commits []*models.Commit, comm
return errors.New("You are midway through another rebase operation. Please abort to start again")
}
- self.PatchManager.Reset()
+ self.PatchBuilder.Reset()
return self.rebase.ContinueRebase()
}
diff --git a/pkg/commands/patch/format.go b/pkg/commands/patch/format.go
new file mode 100644
index 000000000..d04b6bec1
--- /dev/null
+++ b/pkg/commands/patch/format.go
@@ -0,0 +1,169 @@
+package patch
+
+import (
+ "strings"
+
+ "github.com/jesseduffield/generics/set"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+ "github.com/jesseduffield/lazygit/pkg/theme"
+ "github.com/samber/lo"
+)
+
+type patchPresenter struct {
+ patch *Patch
+ // if true, all following fields are ignored
+ plain bool
+
+ isFocused bool
+ // first line index for selected cursor range
+ firstLineIndex int
+ // last line index for selected cursor range
+ lastLineIndex int
+ // line indices for tagged lines (e.g. lines added to a custom patch)
+ incLineIndices *set.Set[int]
+}
+
+// formats the patch as a plain string
+func formatPlain(patch *Patch) string {
+ presenter := &patchPresenter{
+ patch: patch,
+ plain: true,
+ incLineIndices: set.New[int](),
+ }
+ return presenter.format()
+}
+
+func formatRangePlain(patch *Patch, startIdx int, endIdx int) string {
+ lines := patch.Lines()[startIdx : endIdx+1]
+ return strings.Join(
+ lo.Map(lines, func(line *PatchLine, _ int) string {
+ return line.Content + "\n"
+ }),
+ "",
+ )
+}
+
+type FormatViewOpts struct {
+ IsFocused bool
+ // first line index for selected cursor range
+ FirstLineIndex int
+ // last line index for selected cursor range
+ LastLineIndex int
+ // line indices for tagged lines (e.g. lines added to a custom patch)
+ IncLineIndices *set.Set[int]
+}
+
+// formats the patch for rendering within a view, meaning it's coloured and
+// highlights selected items
+func formatView(patch *Patch, opts FormatViewOpts) string {
+ includedLineIndices := opts.IncLineIndices
+ if includedLineIndices == nil {
+ includedLineIndices = set.New[int]()
+ }
+ presenter := &patchPresenter{
+ patch: patch,
+ plain: false,
+ isFocused: opts.IsFocused,
+ firstLineIndex: opts.FirstLineIndex,
+ lastLineIndex: opts.LastLineIndex,
+ incLineIndices: includedLineIndices,
+ }
+ return presenter.format()
+}
+
+func (self *patchPresenter) format() string {
+ // if we have no changes in our patch (i.e. no additions or deletions) then
+ // the patch is effectively empty and we can return an empty string
+ if !self.patch.ContainsChanges() {
+ return ""
+ }
+
+ stringBuilder := &strings.Builder{}
+ lineIdx := 0
+ appendLine := func(line string) {
+ _, _ = stringBuilder.WriteString(line + "\n")
+
+ lineIdx++
+ }
+ appendFormattedLine := func(line string, style style.TextStyle) {
+ formattedLine := self.formatLine(
+ line,
+ style,
+ lineIdx,
+ )
+
+ appendLine(formattedLine)
+ }
+
+ for _, line := range self.patch.header {
+ appendFormattedLine(line, theme.DefaultTextColor.SetBold())
+ }
+
+ for _, hunk := range self.patch.hunks {
+ appendLine(
+ self.formatLine(
+ hunk.formatHeaderStart(),
+ style.FgCyan,
+ lineIdx,
+ ) +
+ // we're splitting the line into two parts: the diff header and the context
+ // We explicitly pass 'included' as false here so that we're only tagging the
+ // first half of the line as included if the line is indeed included.
+ self.formatLineAux(
+ hunk.headerContext,
+ theme.DefaultTextColor,
+ lineIdx,
+ false,
+ ),
+ )
+
+ for _, line := range hunk.bodyLines {
+ appendFormattedLine(line.Content, self.patchLineStyle(line))
+ }
+ }
+
+ return stringBuilder.String()
+}
+
+func (self *patchPresenter) patchLineStyle(patchLine *PatchLine) style.TextStyle {
+ switch patchLine.Kind {
+ case ADDITION:
+ return style.FgGreen
+ case DELETION:
+ return style.FgRed
+ default:
+ return theme.DefaultTextColor
+ }
+}
+
+func (self *patchPresenter) formatLine(str string, textStyle style.TextStyle, index int) string {
+ included := self.incLineIndices.Includes(index)
+
+ return self.formatLineAux(str, textStyle, index, included)
+}
+
+// 'selected' means you've got it highlighted with your cursor
+// 'included' means the line has been included in the patch (only applicable when
+// building a patch)
+func (self *patchPresenter) formatLineAux(str string, textStyle style.TextStyle, index int, included bool) string {
+ if self.plain {
+ return str
+ }
+
+ selected := self.isFocused && index >= self.firstLineIndex && index <= self.lastLineIndex
+
+ if selected {
+ textStyle = textStyle.MergeStyle(theme.SelectedRangeBgColor)
+ }
+
+ firstCharStyle := textStyle
+ if included {
+ firstCharStyle = firstCharStyle.MergeStyle(style.BgGreen)
+ }
+
+ if len(str) < 2 {
+ return firstCharStyle.Sprint(str)
+ }
+
+ return firstCharStyle.Sprint(str[:1]) + textStyle.Sprint(str[1:])
+}
diff --git a/pkg/commands/patch/hunk.go b/pkg/commands/patch/hunk.go
index 605c473c1..6d0177d05 100644
--- a/pkg/commands/patch/hunk.go
+++ b/pkg/commands/patch/hunk.go
@@ -1,130 +1,67 @@
package patch
-import (
- "fmt"
- "strings"
-
- "github.com/jesseduffield/lazygit/pkg/utils"
- "github.com/samber/lo"
-)
-
-type PatchHunk struct {
- FirstLineIdx int
- oldStart int
- newStart int
- heading string
- bodyLines []string
-}
-
-func (hunk *PatchHunk) LastLineIdx() int {
- return hunk.FirstLineIdx + len(hunk.bodyLines)
+import "fmt"
+
+// Example hunk:
+// @@ -16,2 +14,3 @@ func (f *CommitFile) Description() string {
+// return f.Name
+// -}
+// +
+// +// test
+
+type Hunk struct {
+ // the line number of the first line in the old file ('16' in the above example)
+ oldStart int
+ // the line number of the first line in the new file ('14' in the above example)
+ newStart int
+ // the context at the end of the header line (' func (f *CommitFile) Description() string {' in the above example)
+ headerContext string
+ // the body of the hunk, excluding the header line
+ bodyLines []*PatchLine
}
-func newHunk(lines []string, firstLineIdx int) *PatchHunk {
- header := lines[0]
- bodyLines := lines[1:]
-
- oldStart, newStart, heading := headerInfo(header)
-
- return &PatchHunk{
- oldStart: oldStart,
- newStart: newStart,
- heading: heading,
- FirstLineIdx: firstLineIdx,
- bodyLines: bodyLines,
- }
+// Returns the number of lines in the hunk in the original file ('2' in the above example)
+func (self *Hunk) oldLength() int {
+ return nLinesWithKind(self.bodyLines, []PatchLineKind{CONTEXT, DELETION})
}
-func headerInfo(header string) (int, int, string) {
- match := hunkHeaderRegexp.FindStringSubmatch(header)
-
- oldStart := utils.MustConvertToInt(match[1])
- newStart := utils.MustConvertToInt(match[2])
- heading := match[3]
-
- return oldStart, newStart, heading
+// Returns the number of lines in the hunk in the new file ('3' in the above example)
+func (self *Hunk) newLength() int {
+ return nLinesWithKind(self.bodyLines, []PatchLineKind{CONTEXT, ADDITION})
}
-func (hunk *PatchHunk) updatedLines(lineIndices []int, reverse bool) []string {
- skippedNewlineMessageIndex := -1
- newLines := []string{}
-
- lineIdx := hunk.FirstLineIdx
- for _, line := range hunk.bodyLines {
- lineIdx++ // incrementing at the start to skip the header line
- if line == "" {
- break
- }
- isLineSelected := lo.Contains(lineIndices, lineIdx)
-
- firstChar, content := line[:1], line[1:]
- transformedFirstChar := transformedFirstChar(firstChar, reverse, isLineSelected)
-
- if isLineSelected || (transformedFirstChar == "\\" && skippedNewlineMessageIndex != lineIdx) || transformedFirstChar == " " {
- newLines = append(newLines, transformedFirstChar+content)
- continue
- }
-
- if transformedFirstChar == "+" {
- // we don't want to include the 'newline at end of file' line if it involves an addition we're not including
- skippedNewlineMessageIndex = lineIdx + 1
- }
- }
-
- return newLines
+// Returns true if the hunk contains any changes (i.e. if it's not just a context hunk).
+// We'll end up with a context hunk if we're transforming a patch and one of the hunks
+// has no selected lines.
+func (self *Hunk) containsChanges() bool {
+ return nLinesWithKind(self.bodyLines, []PatchLineKind{ADDITION, DELETION}) > 0
}
-func transformedFirstChar(firstChar string, reverse bool, isLineSelected bool) string {
- linesToKeepInPatchContext := "-"
- if reverse {
- linesToKeepInPatchContext = "+"
- }
- if !isLineSelected && firstChar == linesToKeepInPatchContext {
- return " "
- }
-
- return firstChar
+// Returns the number of lines in the hunk, including the header line
+func (self *Hunk) lineCount() int {
+ return len(self.bodyLines) + 1
}
-func (hunk *PatchHunk) formatHeader(oldStart int, oldLength int, newStart int, newLength int, heading string) string {
- return fmt.Sprintf("@@ -%d,%d +%d,%d @@%s\n", oldStart, oldLength, newStart, newLength, heading)
+// Returns all lines in the hunk, including the header line
+func (self *Hunk) allLines() []*PatchLine {
+ lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}}
+ lines = append(lines, self.bodyLines...)
+ return lines
}
-func (hunk *PatchHunk) formatWithChanges(lineIndices []int, reverse bool, startOffset int) (int, string) {
- bodyLines := hunk.updatedLines(lineIndices, reverse)
- startOffset, header, ok := hunk.updatedHeader(bodyLines, startOffset)
- if !ok {
- return startOffset, ""
- }
- return startOffset, header + strings.Join(bodyLines, "")
+// Returns the header line, including the unified diff header and the context
+func (self *Hunk) formatHeaderLine() string {
+ return fmt.Sprintf("%s%s", self.formatHeaderStart(), self.headerContext)
}
-func (hunk *PatchHunk) updatedHeader(newBodyLines []string, startOffset int) (int, string, bool) {
- changeCount := nLinesWithPrefix(newBodyLines, []string{"+", "-"})
- oldLength := nLinesWithPrefix(newBodyLines, []string{" ", "-"})
- newLength := nLinesWithPrefix(newBodyLines, []string{"+", " "})
-
- if changeCount == 0 {
- // if nothing has changed we just return nothing
- return startOffset, "", false
- }
-
- oldStart := hunk.oldStart
-
- var newStartOffset int
- // if the hunk went from zero to positive length, we need to increment the starting point by one
- // if the hunk went from positive to zero length, we need to decrement the starting point by one
- if oldLength == 0 {
- newStartOffset = 1
- } else if newLength == 0 {
- newStartOffset = -1
- } else {
- newStartOffset = 0
+// Returns the first part of the header line i.e. the unified diff part (excluding any context)
+func (self *Hunk) formatHeaderStart() string {
+ newLengthDisplay := ""
+ newLength := self.newLength()
+ // if the new length is 1, it's omitted
+ if newLength != 1 {
+ newLengthDisplay = fmt.Sprintf(",%d", newLength)
}
- newStart := oldStart + startOffset + newStartOffset
-
- newStartOffset = startOffset + newLength - oldLength
- formattedHeader := hunk.formatHeader(oldStart, oldLength, newStart, newLength, hunk.heading)
- return newStartOffset, formattedHeader, true
+ return fmt.Sprintf("@@ -%d,%d +%d%s @@", self.oldStart, self.oldLength(), self.newStart, newLengthDisplay)
}
diff --git a/pkg/commands/patch/parse.go b/pkg/commands/patch/parse.go
new file mode 100644
index 000000000..fee7d2918
--- /dev/null
+++ b/pkg/commands/patch/parse.go
@@ -0,0 +1,85 @@
+package patch
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+var hunkHeaderRegexp = regexp.MustCompile(`(?m)^@@ -(\d+)[^\+]+\+(\d+)[^@]+@@(.*)$`)
+
+func Parse(patchStr string) *Patch {
+ // ignore trailing newline.
+ lines := strings.Split(strings.TrimSuffix(patchStr, "\n"), "\n")
+
+ hunks := []*Hunk{}
+ patchHeader := []string{}
+
+ var currentHunk *Hunk
+ for _, line := range lines {
+ if strings.HasPrefix(line, "@@") {
+ oldStart, newStart, headerContext := headerInfo(line)
+
+ currentHunk = &Hunk{
+ oldStart: oldStart,
+ newStart: newStart,
+ headerContext: headerContext,
+ bodyLines: []*PatchLine{},
+ }
+ hunks = append(hunks, currentHunk)
+ } else if currentHunk != nil {
+ currentHunk.bodyLines = append(currentHunk.bodyLines, newHunkLine(line))
+ } else {
+ patchHeader = append(patchHeader, line)
+ }
+ }
+
+ return &Patch{
+ hunks: hunks,
+ header: patchHeader,
+ }
+}
+
+func headerInfo(header string) (int, int, string) {
+ match := hunkHeaderRegexp.FindStringSubmatch(header)
+
+ oldStart := utils.MustConvertToInt(match[1])
+ newStart := utils.MustConvertToInt(match[2])
+ headerContext := match[3]
+
+ return oldStart, newStart, headerContext
+}
+
+func newHunkLine(line string) *PatchLine {
+ if line == "" {
+ return &PatchLine{
+ Kind: CONTEXT,
+ Content: "",
+ }
+ }
+
+ firstChar := line[:1]
+
+ kind := parseFirstChar(firstChar)
+
+ return &PatchLine{
+ Kind: kind,
+ Content: line,
+ }
+}
+
+func parseFirstChar(firstChar string) PatchLineKind {
+ switch firstChar {
+ case " ":
+ return CONTEXT
+ case "+":
+ return ADDITION
+ case "-":
+ return DELETION
+ case "\\":
+ return NEWLINE_MESSAGE
+ }
+
+ return CONTEXT
+}
diff --git a/pkg/commands/patch/patch.go b/pkg/commands/patch/patch.go
new file mode 100644
index 000000000..5275fb613
--- /dev/null
+++ b/pkg/commands/patch/patch.go
@@ -0,0 +1,151 @@
+package patch
+
+import (
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "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 patch line index of the first line in the given hunk
+func (self *Patch) HunkStartIdx(hunkIndex int) int {
+ hunkIndex = utils.Clamp(hunkIndex, 0, len(self.hunks)-1)
+
+ result := len(self.header)
+ for i := 0; i < hunkIndex; i++ {
+ 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 = utils.Clamp(hunkIndex, 0, len(self.hunks)-1)
+
+ return self.HunkStartIdx(hunkIndex) + self.hunks[hunkIndex].lineCount() - 1
+}
+
+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.oldStart
+ }
+
+ lines := hunk.bodyLines[:idxInHunk-1]
+ offset := nLinesWithKind(lines, []PatchLineKind{ADDITION, CONTEXT})
+ return hunk.oldStart + offset
+}
+
+// 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() {