mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-11 16:16:28 -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>
469 lines
14 KiB
Go
469 lines
14 KiB
Go
package patch_exploring
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/generics/set"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
// State represents the current state of the patch explorer context i.e. when
|
|
// you're staging a file or you're building a patch from an existing commit
|
|
// this struct holds the info about the diff you're interacting with and what's currently selected.
|
|
type State struct {
|
|
// These are in terms of view lines (wrapped), not patch lines
|
|
selectedLineIdx int
|
|
rangeStartLineIdx int
|
|
// If a range is sticky, it means we expand the range when we move up or down.
|
|
// Otherwise, we cancel the range when we move up or down.
|
|
rangeIsSticky bool
|
|
diff string
|
|
patch *patch.Patch
|
|
selectMode selectMode
|
|
|
|
// Array of indices of the wrapped lines indexed by a patch line index
|
|
viewLineIndices []int
|
|
// Array of indices of the original patch lines indexed by a wrapped view line index
|
|
patchLineIndices []int
|
|
|
|
// whether the user has switched to hunk mode manually; if hunk mode is on
|
|
// but this is false, then hunk mode was enabled because the config makes it
|
|
// on by default.
|
|
// this makes a difference for whether we want to escape out of hunk mode
|
|
userEnabledHunkMode bool
|
|
}
|
|
|
|
// these represent what select mode we're in
|
|
type selectMode int
|
|
|
|
const (
|
|
LINE selectMode = iota
|
|
RANGE
|
|
HUNK
|
|
)
|
|
|
|
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
|
|
// is why in that case we continue below
|
|
return oldState
|
|
}
|
|
|
|
patch := patch.Parse(diff)
|
|
|
|
if !patch.ContainsChanges() {
|
|
return nil
|
|
}
|
|
|
|
viewLineIndices, patchLineIndices := wrapPatchLines(diff, view)
|
|
|
|
if selectedRealLineIdx != -1 {
|
|
// 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)]
|
|
}
|
|
|
|
rangeStartLineIdx := 0
|
|
if oldState != nil {
|
|
rangeStartLineIdx = oldState.rangeStartLineIdx
|
|
}
|
|
|
|
selectMode := LINE
|
|
if useHunkModeByDefault && !patch.IsSingleHunkForWholeFile() {
|
|
selectMode = HUNK
|
|
}
|
|
|
|
userEnabledHunkMode := false
|
|
if oldState != nil {
|
|
userEnabledHunkMode = oldState.userEnabledHunkMode
|
|
}
|
|
|
|
// A non-negative line index means we were given a specific line to select:
|
|
// either by clicking or pressing enter on a line in a focused main view, or
|
|
// by clicking directly on the patch explorer view to focus it.
|
|
if selectedLineIdx >= 0 {
|
|
// Clamp to the number of wrapped view lines; index might be out of
|
|
// bounds if a custom diff renderer is being used which produces more lines
|
|
selectedLineIdx = min(selectedLineIdx, len(patchLineIndices)-1)
|
|
|
|
if selectLineInDefaultMode {
|
|
// Diving in from a focused main view: keep the default select mode
|
|
// computed above. In hunk mode the selection covers the block of
|
|
// changes around the line, so snap to a change line if the given one
|
|
// is a context line (just as toggling hunk mode does).
|
|
if selectMode == HUNK {
|
|
selectedLineIdx = viewLineIndices[patch.GetNextChangeIdx(patchLineIndices[selectedLineIdx])]
|
|
}
|
|
} else {
|
|
// Clicking directly on the view starts a range selection that can be
|
|
// extended by dragging.
|
|
selectMode = RANGE
|
|
rangeStartLineIdx = selectedLineIdx
|
|
}
|
|
} else if oldState != nil {
|
|
// if we previously had a selectMode of RANGE, we want that to now be line again (or hunk, if that's the default)
|
|
if oldState.selectMode != RANGE {
|
|
selectMode = oldState.selectMode
|
|
}
|
|
oldPatchLineIdx := oldState.patchLineIndices[oldState.selectedLineIdx]
|
|
newPatchLineIdx := patch.GetNextChangeIdx(oldPatchLineIdx)
|
|
// When staging an addition from a consecutive changes block, the unselected deletions get
|
|
// reordered to appear before the remaining additions in the new diff. This can cause the
|
|
// cursor to land on a deletion at the same patch line index where the staged addition used
|
|
// to be. In that case, skip forward past any deletions, then call GetNextChangeIdx from the
|
|
// first non-deletion position, which correctly lands on the next meaningful change.
|
|
newLines := patch.Lines()
|
|
if newPatchLineIdx == oldPatchLineIdx &&
|
|
oldState.patch.Lines()[oldPatchLineIdx].IsAddition() &&
|
|
newLines[newPatchLineIdx].IsDeletion() &&
|
|
patch.HunkOldStartForLine(newPatchLineIdx) == oldState.patch.HunkOldStartForLine(oldPatchLineIdx) {
|
|
for newPatchLineIdx < len(newLines) && newLines[newPatchLineIdx].IsDeletion() {
|
|
newPatchLineIdx++
|
|
}
|
|
newPatchLineIdx = patch.GetNextChangeIdx(newPatchLineIdx)
|
|
}
|
|
selectedLineIdx = viewLineIndices[newPatchLineIdx]
|
|
} else {
|
|
selectedLineIdx = viewLineIndices[patch.GetNextChangeIdx(0)]
|
|
}
|
|
|
|
return &State{
|
|
patch: patch,
|
|
selectedLineIdx: selectedLineIdx,
|
|
selectMode: selectMode,
|
|
rangeStartLineIdx: rangeStartLineIdx,
|
|
rangeIsSticky: false,
|
|
diff: diff,
|
|
viewLineIndices: viewLineIndices,
|
|
patchLineIndices: patchLineIndices,
|
|
userEnabledHunkMode: userEnabledHunkMode,
|
|
}
|
|
}
|
|
|
|
func (s *State) OnViewWidthChanged(view *gocui.View) {
|
|
if !view.Wrap {
|
|
return
|
|
}
|
|
|
|
selectedPatchLineIdx := s.patchLineIndices[s.selectedLineIdx]
|
|
var rangeStartPatchLineIdx int
|
|
if s.selectMode == RANGE {
|
|
rangeStartPatchLineIdx = s.patchLineIndices[s.rangeStartLineIdx]
|
|
}
|
|
s.viewLineIndices, s.patchLineIndices = wrapPatchLines(s.diff, view)
|
|
s.selectedLineIdx = s.viewLineIndices[selectedPatchLineIdx]
|
|
if s.selectMode == RANGE {
|
|
s.rangeStartLineIdx = s.viewLineIndices[rangeStartPatchLineIdx]
|
|
}
|
|
}
|
|
|
|
func (s *State) GetSelectedPatchLineIdx() int {
|
|
return s.patchLineIndices[s.selectedLineIdx]
|
|
}
|
|
|
|
func (s *State) GetSelectedViewLineIdx() int {
|
|
return s.selectedLineIdx
|
|
}
|
|
|
|
func (s *State) GetDiff() string {
|
|
return s.diff
|
|
}
|
|
|
|
func (s *State) ToggleSelectHunk() {
|
|
if s.selectMode == HUNK {
|
|
s.selectMode = LINE
|
|
} else {
|
|
s.selectMode = HUNK
|
|
s.userEnabledHunkMode = true
|
|
|
|
// If we are not currently on a change line, select the next one (or the
|
|
// previous one if there is no next one):
|
|
s.selectedLineIdx = s.viewLineIndices[s.patch.GetNextChangeIdx(
|
|
s.patchLineIndices[s.selectedLineIdx])]
|
|
}
|
|
}
|
|
|
|
func (s *State) ToggleStickySelectRange() {
|
|
s.ToggleSelectRange(true)
|
|
}
|
|
|
|
func (s *State) ToggleSelectRange(sticky bool) {
|
|
if s.SelectingRange() {
|
|
s.selectMode = LINE
|
|
} else {
|
|
s.selectMode = RANGE
|
|
s.rangeStartLineIdx = s.selectedLineIdx
|
|
s.rangeIsSticky = sticky
|
|
}
|
|
}
|
|
|
|
func (s *State) SetRangeIsSticky(value bool) {
|
|
s.rangeIsSticky = value
|
|
}
|
|
|
|
func (s *State) SelectingHunk() bool {
|
|
return s.selectMode == HUNK
|
|
}
|
|
|
|
func (s *State) SelectingHunkEnabledByUser() bool {
|
|
return s.selectMode == HUNK && s.userEnabledHunkMode
|
|
}
|
|
|
|
func (s *State) SelectingRange() bool {
|
|
return s.selectMode == RANGE && (s.rangeIsSticky || s.rangeStartLineIdx != s.selectedLineIdx)
|
|
}
|
|
|
|
func (s *State) SelectingLine() bool {
|
|
return s.selectMode == LINE
|
|
}
|
|
|
|
func (s *State) SetLineSelectMode() {
|
|
s.selectMode = LINE
|
|
}
|
|
|
|
func (s *State) DismissHunkSelectMode() {
|
|
if s.SelectingHunk() {
|
|
s.selectMode = LINE
|
|
}
|
|
}
|
|
|
|
// For when you move the cursor without holding shift (meaning if we're in
|
|
// a non-sticky range select, we'll cancel it)
|
|
func (s *State) SelectLine(newSelectedLineIdx int) {
|
|
if s.selectMode == RANGE && !s.rangeIsSticky {
|
|
s.selectMode = LINE
|
|
}
|
|
|
|
s.selectLineWithoutRangeCheck(newSelectedLineIdx)
|
|
}
|
|
|
|
func (s *State) clampLineIdx(lineIdx int) int {
|
|
return lo.Clamp(lineIdx, 0, len(s.patchLineIndices)-1)
|
|
}
|
|
|
|
// This just moves the cursor without caring about range select
|
|
func (s *State) selectLineWithoutRangeCheck(newSelectedLineIdx int) {
|
|
s.selectedLineIdx = s.clampLineIdx(newSelectedLineIdx)
|
|
}
|
|
|
|
func (s *State) SelectNewLineForRange(newSelectedLineIdx int) {
|
|
s.rangeStartLineIdx = s.clampLineIdx(newSelectedLineIdx)
|
|
|
|
s.selectMode = RANGE
|
|
|
|
s.selectLineWithoutRangeCheck(newSelectedLineIdx)
|
|
}
|
|
|
|
func (s *State) DragSelectLine(newSelectedLineIdx int) {
|
|
s.selectMode = RANGE
|
|
|
|
s.selectLineWithoutRangeCheck(newSelectedLineIdx)
|
|
}
|
|
|
|
func (s *State) CycleSelection(forward bool) {
|
|
if s.SelectingHunk() {
|
|
if forward {
|
|
s.SelectNextHunk()
|
|
} else {
|
|
s.SelectPreviousHunk()
|
|
}
|
|
} else {
|
|
s.CycleLine(forward)
|
|
}
|
|
}
|
|
|
|
func (s *State) SelectPreviousHunk() {
|
|
patchLines := s.patch.Lines()
|
|
patchLineIdx := s.patchLineIndices[s.selectedLineIdx]
|
|
nextNonChangeLine := patchLineIdx
|
|
for nextNonChangeLine >= 0 && patchLines[nextNonChangeLine].IsChange() {
|
|
nextNonChangeLine--
|
|
}
|
|
nextChangeLine := nextNonChangeLine
|
|
for nextChangeLine >= 0 && !patchLines[nextChangeLine].IsChange() {
|
|
nextChangeLine--
|
|
}
|
|
if nextChangeLine >= 0 {
|
|
// Now we found a previous hunk, but we're on its last line. Skip to the beginning.
|
|
for nextChangeLine > 0 && patchLines[nextChangeLine-1].IsChange() {
|
|
nextChangeLine--
|
|
}
|
|
s.selectedLineIdx = s.viewLineIndices[nextChangeLine]
|
|
}
|
|
}
|
|
|
|
func (s *State) SelectNextHunk() {
|
|
patchLines := s.patch.Lines()
|
|
patchLineIdx := s.patchLineIndices[s.selectedLineIdx]
|
|
nextNonChangeLine := patchLineIdx
|
|
for nextNonChangeLine < len(patchLines) && patchLines[nextNonChangeLine].IsChange() {
|
|
nextNonChangeLine++
|
|
}
|
|
nextChangeLine := nextNonChangeLine
|
|
for nextChangeLine < len(patchLines) && !patchLines[nextChangeLine].IsChange() {
|
|
nextChangeLine++
|
|
}
|
|
if nextChangeLine < len(patchLines) {
|
|
s.selectedLineIdx = s.viewLineIndices[nextChangeLine]
|
|
}
|
|
}
|
|
|
|
func (s *State) CycleLine(forward bool) {
|
|
change := 1
|
|
if !forward {
|
|
change = -1
|
|
}
|
|
|
|
s.SelectLine(s.selectedLineIdx + change)
|
|
}
|
|
|
|
// This is called when we use shift+arrow to expand the range (i.e. a non-sticky
|
|
// range)
|
|
func (s *State) CycleRange(forward bool) {
|
|
if !s.SelectingRange() {
|
|
s.ToggleSelectRange(false)
|
|
}
|
|
|
|
s.SetRangeIsSticky(false)
|
|
|
|
change := 1
|
|
if !forward {
|
|
change = -1
|
|
}
|
|
|
|
s.selectLineWithoutRangeCheck(s.selectedLineIdx + change)
|
|
}
|
|
|
|
// returns first and last patch line index of current hunk
|
|
func (s *State) CurrentHunkBounds() (int, int) {
|
|
hunkIdx := s.patch.HunkContainingLine(s.patchLineIndices[s.selectedLineIdx])
|
|
start := s.patch.HunkStartIdx(hunkIdx)
|
|
end := s.patch.HunkEndIdx(hunkIdx)
|
|
return start, end
|
|
}
|
|
|
|
func (s *State) selectionRangeForCurrentBlockOfChanges() (int, int) {
|
|
patchLines := s.patch.Lines()
|
|
patchLineIdx := s.patchLineIndices[s.selectedLineIdx]
|
|
|
|
patchStart := patchLineIdx
|
|
for patchStart > 0 && patchLines[patchStart-1].IsChange() {
|
|
patchStart--
|
|
}
|
|
|
|
patchEnd := patchLineIdx
|
|
for patchEnd < len(patchLines)-1 && patchLines[patchEnd+1].IsChange() {
|
|
patchEnd++
|
|
}
|
|
|
|
viewStart, viewEnd := s.viewLineIndices[patchStart], s.viewLineIndices[patchEnd]
|
|
|
|
// Increase viewEnd in case the last patch line is wrapped to more than one view line.
|
|
for viewEnd < len(s.patchLineIndices)-1 && s.patchLineIndices[viewEnd] == s.patchLineIndices[viewEnd+1] {
|
|
viewEnd++
|
|
}
|
|
|
|
return viewStart, viewEnd
|
|
}
|
|
|
|
func (s *State) SelectedViewRange() (int, int) {
|
|
switch s.selectMode {
|
|
case HUNK:
|
|
return s.selectionRangeForCurrentBlockOfChanges()
|
|
case RANGE:
|
|
if s.rangeStartLineIdx > s.selectedLineIdx {
|
|
return s.selectedLineIdx, s.rangeStartLineIdx
|
|
}
|
|
return s.rangeStartLineIdx, s.selectedLineIdx
|
|
case LINE:
|
|
return s.selectedLineIdx, s.selectedLineIdx
|
|
default:
|
|
// should never happen
|
|
return 0, 0
|
|
}
|
|
}
|
|
|
|
func (s *State) SelectedPatchRange() (int, int) {
|
|
start, end := s.SelectedViewRange()
|
|
return s.patchLineIndices[start], s.patchLineIndices[end]
|
|
}
|
|
|
|
// Returns the line indices of the selected patch range that are changes (i.e. additions or deletions)
|
|
func (s *State) LineIndicesOfAddedOrDeletedLinesInSelectedPatchRange() []int {
|
|
viewStart, viewEnd := s.SelectedViewRange()
|
|
patchStart, patchEnd := s.patchLineIndices[viewStart], s.patchLineIndices[viewEnd]
|
|
lines := s.patch.Lines()
|
|
indices := []int{}
|
|
for i := patchStart; i <= patchEnd; i++ {
|
|
if lines[i].IsChange() {
|
|
indices = append(indices, i)
|
|
}
|
|
}
|
|
return indices
|
|
}
|
|
|
|
func (s *State) CurrentLineNumber() int {
|
|
return s.patch.LineNumberOfLine(s.patchLineIndices[s.selectedLineIdx])
|
|
}
|
|
|
|
func (s *State) AdjustSelectedLineIdx(change int) {
|
|
s.DismissHunkSelectMode()
|
|
s.SelectLine(s.selectedLineIdx + change)
|
|
}
|
|
|
|
func (s *State) RenderForLineIndices(includedLineIndices []int) string {
|
|
includedLineIndicesSet := set.NewFromSlice(includedLineIndices)
|
|
return s.patch.FormatView(patch.FormatViewOpts{
|
|
IncLineIndices: includedLineIndicesSet,
|
|
})
|
|
}
|
|
|
|
func (s *State) PlainRenderSelected() string {
|
|
firstLineIdx, lastLineIdx := s.SelectedPatchRange()
|
|
return s.patch.FormatRangePlain(firstLineIdx, lastLineIdx)
|
|
}
|
|
|
|
func (s *State) SelectBottom() {
|
|
s.DismissHunkSelectMode()
|
|
s.SelectLine(len(s.patchLineIndices) - 1)
|
|
}
|
|
|
|
func (s *State) SelectTop() {
|
|
s.DismissHunkSelectMode()
|
|
s.SelectLine(0)
|
|
}
|
|
|
|
func (s *State) CalculateOrigin(currentOrigin int, bufferHeight int, numLines int) int {
|
|
firstLineIdx, lastLineIdx := s.SelectedViewRange()
|
|
|
|
return calculateOrigin(currentOrigin, bufferHeight, numLines, firstLineIdx, lastLineIdx, s.GetSelectedViewLineIdx(), s.selectMode)
|
|
}
|
|
|
|
func wrapPatchLines(diff string, view *gocui.View) ([]int, []int) {
|
|
_, viewLineIndices, patchLineIndices := utils.WrapViewLinesToWidth(
|
|
view.Wrap, view.Editable, strings.TrimSuffix(diff, "\n"), view.InnerWidth(), view.TabWidth)
|
|
return viewLineIndices, patchLineIndices
|
|
}
|
|
|
|
func (s *State) SelectNextStageableLineOfSameIncludedState(includedLines []int, included bool) {
|
|
_, lastLineIdx := s.SelectedPatchRange()
|
|
patchLineIdx, found := s.patch.GetNextChangeIdxOfSameIncludedState(lastLineIdx+1, includedLines, included)
|
|
if found {
|
|
s.SelectLine(s.viewLineIndices[patchLineIdx])
|
|
}
|
|
}
|