jesseduffield.lazygit/pkg/gui/controllers/helpers/diff_line_restore.go
2026-09-06 17:32:48 +02:00

608 lines
24 KiB
Go

package helpers
import (
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/samber/lo"
)
// Keeping a diff view where it is when the same diff is rendered again differently.
// The line the user is on is remembered by identity (diff_line_helper.go), because a
// new rendering puts it on a different line of the view — and may not have it at all,
// which is what the fallbacks below are for.
// diffLineAnchor is a line for a restore to land on: the identity to find it by in
// the new rendering, and the screen row it was on, so that it can be put back there.
type diffLineAnchor struct {
identity types.DiffLineInfo
row int
}
// PreserveDiffPositionOnRerender remembers where a diff view is and puts it back
// there as it next re-renders, instead of leaving the user at the top of a new
// rendering of the diff they were already reading. Call it on the view about to be
// re-rendered, right before triggering the re-render — on both panes of the main
// window where both are being rendered again, since either of them may hold the diff
// being read; a pane that isn't showing is left alone.
//
// The line to keep is the end of the selection that is on screen, and the middle
// visible line when there is no selection or the whole of it has been scrolled out of
// sight — what the user is looking at, rather than the view's top edge or a selection
// they have long since left behind. It may not survive the re-render: a context line
// goes when the context size shrinks, and a whole hunk or file goes when whitespace
// stops counting. So the lines around it come along as fallbacks and the view lands on
// the nearest one that is still there, put back on the screen row it was on. With none
// of them left — and with a renderer that says nothing about its rows there is nothing
// to look for in the first place — the view keeps the scroll offset it had, which is
// still nearer to what was being read than the top of the diff.
//
// An off-screen selection is still put back on the diff line it was on, wherever the
// new rendering has that; it is only the view that stays where it is.
//
// A range or hunk selection has a second end, which is remembered the same way, so
// that it still covers the same lines of the diff afterwards.
func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) {
// A view that isn't the one its window is currently showing — the merge-conflicts
// view takes the main window over — isn't the one about to be re-rendered, so a
// restore installed on it would sit there and claim a later render instead.
if !view.Visible {
return
}
// The re-render is produced by a different command from the one behind what is on
// screen — another context size, another renderer — so without being told otherwise
// it would be taken for content the user has never seen and shown from the top.
// Whether or not a line of the old rendering can be found in the new one, the offset
// into it is nearer to where they were reading than the top is.
if manager := self.c.GetViewBufferManagerForView(view); manager != nil {
manager.SetKeepScrollPositionForNextTask()
}
showSelection := view.Highlight
anchorViewLine := view.MiddleVisibleLineIdx()
farEnd, hasFarEnd := types.DiffLineInfo{}, false
// A cursor that has been scrolled away from is put back by its own lines rather
// than by the anchor's, so that it comes out on the same line of the diff without
// the view having to go there.
var cursorCandidates []diffLineAnchor
if showSelection {
farEnd, hasFarEnd = self.selectionFarEndIdentity(view)
if end, ok := visibleSelectionEnd(view); ok {
anchorViewLine = end
}
if anchorViewLine != view.SelectedLineIdx() {
cursorCandidates = self.nearbyDiffLines(view, view.SelectedLineIdx())
}
}
self.restoreDiffLinePositionOnRerender(view, self.nearbyDiffLines(view, anchorViewLine),
func(anchor diffLineAnchor, viewLine int) {
// Put the line back on the screen row it was on, clamped into the view for
// the fallback lines, which can come from off screen.
row := lo.Clamp(anchor.row, 0, max(0, view.InnerHeight()-1))
view.SetOrigin(0, max(0, viewLine-row))
if showSelection {
// Put the far end back before the cursor, so that the selection covers
// the same lines again; a selection whose far end didn't survive the
// re-render is left as the single line we landed on. The origin is
// already where it should be, so moving the cursor mustn't scroll.
view.CancelRangeSelect()
cursorViewLine := self.selectionLine(view, cursorCandidates, viewLine)
if hasFarEnd {
if farEndViewLine, ok := self.findDiffLine(view, farEnd); ok {
cursorViewLine, farEndViewLine = coverWholeLines(view, cursorViewLine, farEndViewLine)
view.SetRangeSelectStart(farEndViewLine)
}
}
view.FocusPoint(0, cursorViewLine, false)
}
})
}
// coverWholeLines moves the two ends of a restored selection out to the edges of the
// diff lines they are on, so that the selection covers those lines whole. Both ends
// arrive on the first view line of their diff line, which is where looking one up by
// identity lands, and the view draws a line it wraps as several — of which a
// selection of that line means all.
func coverWholeLines(view *gocui.View, cursorViewLine int, farEndViewLine int) (int, int) {
if cursorViewLine <= farEndViewLine {
return cursorViewLine, lastViewLineOfSameDiffLine(view, farEndViewLine)
}
return lastViewLineOfSameDiffLine(view, cursorViewLine), farEndViewLine
}
// lastViewLineOfSameDiffLine returns the last view line showing the same line of the
// diff as the given one, which is that line itself unless the view wrapped it.
func lastViewLineOfSameDiffLine(view *gocui.View, viewLine int) int {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok {
return viewLine
}
if last, ok := view.LastViewLineForBufferLine(bufferLine); ok {
return last
}
return viewLine
}
// visibleSelectionEnd returns the end of the selection to keep in place across a
// re-render: the selected line when it is on screen, and the range's other end when
// that is and the selected line isn't — a range can be long enough for the user to be
// looking at one end of it with the other far away. ok is false when the whole
// selection is off screen, and there is nothing of it to keep in place.
func visibleSelectionEnd(view *gocui.View) (int, bool) {
if view.IsLineVisible(view.SelectedLineIdx()) {
return view.SelectedLineIdx(), true
}
if farEnd, _, ok := selectionFarEndViewLine(view); ok && view.IsLineVisible(farEnd) {
return farEnd, true
}
return 0, false
}
// selectionLine returns the line to put the cursor on once a re-render is on screen:
// the line the position anchor landed on, which is the selected one whenever it was
// on screen, and otherwise the nearest surviving line to where the selection was —
// found among its own candidates, since the anchor's are a search of the diff from
// somewhere else entirely.
func (self *DiffLineHelper) selectionLine(
view *gocui.View, candidates []diffLineAnchor, anchorViewLine int,
) int {
if len(candidates) == 0 {
return anchorViewLine
}
_, bufferLine := self.nearestSurvivingCandidate(view.DiffLineContents(), candidates)
if bufferLine == -1 {
return anchorViewLine
}
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
return viewLine
}
return anchorViewLine
}
// selectionFarEndIdentity returns the identity of the end of a range or hunk
// selection the cursor isn't on, so that a re-render can put it back. ok is false for
// a selection that is only a cursor, where restoring that is the whole job, and for
// an end that resolves to no diff line.
//
// An end covers the whole of its row, so where the row shows more than one diff line
// — a rendering that puts a modification's two halves side by side, or a word diff
// that puts both on the one line it changed — the end takes the outermost of them:
// the last for the range's lower end and the first for its upper one. Otherwise a
// rendering that splits them apart again would get back only the half the row led
// with, and half a change selected where a whole one was.
func (self *DiffLineHelper) selectionFarEndIdentity(view *gocui.View) (types.DiffLineInfo, bool) {
farEnd, isLowerEnd, ok := selectionFarEndViewLine(view)
if !ok {
return types.DiffLineInfo{}, false
}
identities, ok := self.diffLineIdentitiesAt(view, farEnd)
if !ok {
return types.DiffLineInfo{}, false
}
if isLowerEnd {
return identities[len(identities)-1], true
}
return identities[0], true
}
// selectionFarEndViewLine returns the view line of the end of a range or hunk
// selection the cursor isn't on, and whether that is the lower of the two ends. ok
// is false when there is no range at all, only a cursor.
//
// A range whose two ends are on the same view line still has one, and is not the
// same thing as a cursor sitting there: it covers everything that row shows, which
// may be two lines of the diff at once.
func selectionFarEndViewLine(view *gocui.View) (int, bool, bool) {
if !view.HasRangeSelect() {
return 0, false, false
}
first, last := view.SelectedLineRange()
if view.SelectedLineIdx() == first {
return last, true, true
}
return first, false, true
}
// findDiffLine returns the view line showing the given diff line in what view is
// displaying now, for placing a remembered line once the re-render is on screen.
func (self *DiffLineHelper) findDiffLine(view *gocui.View, identity types.DiffLineInfo) (int, bool) {
bufferLine, ok := self.patchLineRows(view.DiffLineContents())[patchLineOf(identity)]
if !ok {
return 0, false
}
return view.ViewLineForBufferLine(bufferLine)
}
// restoreDiffLinePositionOnRerender arranges for view's next re-render to land on the
// first of the given candidate lines the new rendering still has, calling place with
// that candidate and the view line it ended up on. The candidates are in priority
// order (see nearbyDiffLines); if the rendering has none of them, place isn't called
// and the view re-renders as it otherwise would.
//
// The nearest candidate is looked for as the content loads, so that the re-render can
// be revealed at the right position as soon as that line and a screenful below it
// have arrived. Only the nearest one, because the candidates aren't in load order: a
// farther one can load first, and landing on it while a nearer one is still on its
// way would be settling for worse. The rest are considered together once the whole
// rendering is there.
func (self *DiffLineHelper) restoreDiffLinePositionOnRerender(
view *gocui.View, candidates []diffLineAnchor, place func(anchor diffLineAnchor, viewLine int),
) {
if len(candidates) == 0 {
return
}
// The search of the loading content runs on the task's own goroutine, where the
// repo we are in may not be read — a repo switch replaces it — so take it here, on
// the UI thread, for the search to work from.
worktreePath := self.c.Git().RepoPaths.WorktreePath()
// Which candidate the search settled on, for place to put back where it was.
found := diffLineAnchor{}
self.installDiffLineRestore(view,
func(rows []gocui.DiffLineContent, offset int) (int, bool) {
for i, row := range rows {
if rowShowsDiffLine(row, worktreePath, candidates[0].identity) {
found = candidates[0]
return offset + i, true
}
}
return 0, false
},
func(contents []gocui.DiffLineContent) (int, bool) {
anchor, bufferLine := self.nearestSurvivingCandidate(contents, candidates)
if bufferLine == -1 {
return 0, false
}
found = anchor
return bufferLine, true
},
func(viewLine int) { place(found, viewLine) },
nil,
)
}
// ChangeLineOrdinal returns how many change lines of view's rendered diff come before
// the one at the given view line — that line's place in the sequence of changes. ok is
// false when the view line belongs to no row of the content.
//
// It is how a place in a diff is remembered across acting on it: an action consumes
// the lines it acted on, so the identity of the line the user was on is gone, but the
// place it left behind is the same one that identity used to have.
func (self *DiffLineHelper) ChangeLineOrdinal(view *gocui.View, viewLine int) (int, bool) {
bufferLine, ok := view.BufferLineForViewLine(viewLine)
if !ok {
return 0, false
}
ordinal := 0
for i, row := range self.resolveDiffLines(view.DiffLineContents()) {
if i >= bufferLine {
break
}
if row.ok && row.info.IsChange() {
ordinal++
}
}
return ordinal, true
}
// RevealChangeLineAtOrdinal arranges for view's next re-render to be shown with the
// change line at the given ordinal placed by place — the diff having changed under the
// user, this is where what they were doing carries on. When the new diff has fewer
// changes than that, because the ones acted on were its last, it lands on the last
// change left.
//
// done is called once the selection is where it belongs, or once it turns out that no
// render is coming to put it there, for a caller that must not let the user act again
// in between.
func (self *DiffLineHelper) RevealChangeLineAtOrdinal(
view *gocui.View, ordinal int, place func(viewLine int), done func(),
) {
// How many change lines the incremental search has passed, so that it can carry on
// counting where it left off.
seen := 0
self.installDiffLineRestore(view,
func(rows []gocui.DiffLineContent, offset int) (int, bool) {
for i, row := range rows {
if info, ok := self.diffLineInfoFromRecords(row.Metadata); ok && info.IsChange() {
if seen == ordinal {
return offset + i, true
}
seen++
}
}
return 0, false
},
func(contents []gocui.DiffLineContent) (int, bool) {
last, count := -1, 0
for i, row := range self.resolveDiffLines(contents) {
if !row.ok || !row.info.IsChange() {
continue
}
if count == ordinal {
return i, true
}
count++
last = i
}
return last, last != -1
},
place,
done,
)
}
// installDiffLineRestore is what the restores are built on: it arranges for view's
// next re-render to be revealed with the row a search finds in it placed by place,
// instead of from the top.
//
// The search comes in two halves, because the content arrives a line at a time.
// findEarly is given the rows that have loaded since it last looked, so that the
// re-render can be revealed as soon as the row is there rather than waiting for the
// rest of a long diff; it can only go by what the renderer states about a row, a
// partly-loaded diff being unparseable. findComplete is given the whole rendering at
// the swap, for a target the incremental search couldn't settle on. Either returns the
// buffer line it found, and place is not called at all when neither does.
func (self *DiffLineHelper) installDiffLineRestore(
view *gocui.View,
findEarly func(rows []gocui.DiffLineContent, offset int) (int, bool),
findComplete func(contents []gocui.DiffLineContent) (int, bool),
place func(viewLine int),
done func(),
) {
// Get-or-create, because the pane may not have rendered anything yet: a file whose
// diff has only just become split has a second pane whose first render is the one
// this restore is for.
manager := self.c.GetOrCreateViewBufferManagerForView(view)
if manager == nil {
if done != nil {
done()
}
return
}
// The readiness check below runs on the task's own goroutine, which may not read
// the view's dimensions, so take them here, on the UI thread.
viewHeight := view.InnerHeight()
// What the search of the loading content has found, and how far it has looked, so
// that each line is looked at once.
foundLine := -1
scanned := 0
manager.SetRestoreForNextTask(&tasks.RenderRestore{
FirstPaintReady: func() bool {
if foundLine == -1 {
rows := view.OffscreenDiffLineContentsFrom(scanned)
if bufferLine, ok := findEarly(rows, scanned); ok {
foundLine = bufferLine
}
scanned += len(rows)
if foundLine == -1 {
return false
}
}
// Wait for a screenful below the line as well, so that the re-render isn't
// revealed with it stranded at the bottom of a half-filled view.
return view.OffscreenLineCount() >= foundLine+viewHeight
},
Apply: func(swapIn func()) {
bufferLine := foundLine
if bufferLine == -1 {
if line, ok := findComplete(view.OffscreenDiffLineContents()); ok {
bufferLine = line
}
}
swapIn()
if bufferLine == -1 {
return
}
if viewLine, ok := view.ViewLineForBufferLine(bufferLine); ok {
place(viewLine)
}
},
Done: done,
})
}
// nearbyDiffLines collects the lines of view's rendered diff as candidates for a
// restore to land on, ordered by proximity to the anchor line — the anchor itself
// first, then outward, preferring at-or-below on ties — each tagged with the screen
// row it is on. A restore lands on the first of them its re-render still has, so this
// order makes it land as near as possible to where the user was.
//
// The walk covers the whole diff rather than stopping at the change lines on either
// side of the anchor, which a context-size change always keeps: ignoring whitespace
// keeps nothing in particular, and can take a hunk or a whole file out of the diff,
// leaving the nearest surviving line in a neighbouring file.
func (self *DiffLineHelper) nearbyDiffLines(view *gocui.View, anchorViewLine int) []diffLineAnchor {
anchor, ok := view.BufferLineForViewLine(anchorViewLine)
if !ok {
return nil
}
resolved := self.resolveDiffLines(view.DiffLineContents())
if anchor >= len(resolved) {
return nil
}
rows := screenRows(view, len(resolved))
candidates := make([]diffLineAnchor, 0, len(resolved))
collect := func(bufferLine int) {
if line := resolved[bufferLine]; line.ok {
candidates = append(candidates, diffLineAnchor{identity: line.info, row: rows[bufferLine]})
}
}
collect(anchor)
for below, above := anchor+1, anchor-1; below < len(resolved) || above >= 0; below, above = below+1, above-1 {
if below < len(resolved) {
collect(below)
}
if above >= 0 {
collect(above)
}
}
return candidates
}
// screenRows maps each line of view's content to the screen row it is drawn on. The
// lines above the visible ones get -1 and those below them the view's height, so that
// putting one of them back where it was lands it at the top or bottom edge.
func screenRows(view *gocui.View, bufferLineCount int) []int {
height := view.InnerHeight()
originY := view.OriginY()
rows := make([]int, bufferLineCount)
for i := range rows {
rows[i] = -1
}
lastVisible := -1
for y := originY; y < min(originY+height, view.ViewLinesHeight()); y++ {
bufferLine, ok := view.BufferLineForViewLine(y)
if !ok || bufferLine >= bufferLineCount {
continue
}
if rows[bufferLine] == -1 {
rows[bufferLine] = y - originY
}
lastVisible = bufferLine
}
for i := lastVisible + 1; i < bufferLineCount; i++ {
rows[i] = height
}
return rows
}
// nearestSurvivingCandidate returns the first of the candidates that the given
// rendering still shows, and the line of it that does. The rendering is indexed
// first, rather than searched once per candidate: the candidate list is as long as
// the diff, and so is the rendering.
func (self *DiffLineHelper) nearestSurvivingCandidate(
contents []gocui.DiffLineContent, candidates []diffLineAnchor,
) (diffLineAnchor, int) {
rows := self.patchLineRows(contents)
for _, candidate := range candidates {
if line, ok := rows[patchLineOf(candidate.identity)]; ok {
return candidate, line
}
}
return diffLineAnchor{}, -1
}
// patchLineRows indexes a rendering by the diff lines it shows: for each of them, the
// first of its rows that does. A row can show more than one, and each is then a way
// of finding that row again.
func (self *DiffLineHelper) patchLineRows(contents []gocui.DiffLineContent) map[patchLine]int {
rows := map[patchLine]int{}
for i, identities := range self.resolveDiffLineIdentities(contents) {
for _, identity := range identities {
if _, seen := rows[patchLineOf(identity)]; !seen {
rows[patchLineOf(identity)] = i
}
}
}
return rows
}
// rowShowsDiffLine reports whether the given row of a rendering shows the given diff
// line — among any others it shows, since a side-by-side rendering puts a deletion
// beside the addition replacing it. It only knows what the renderer states about the
// row, since the alternative, parsing the rendering as a diff, needs whole hunks and
// this is asked of content that is still loading. It takes the repo's worktree path
// rather than reading it, being asked off the UI thread.
func rowShowsDiffLine(row gocui.DiffLineContent, worktreePath string, target types.DiffLineInfo) bool {
return lo.SomeBy(row.Metadata, func(record string) bool {
parsed, ok := parseDiffLineMetadata(record)
return ok && patchLineOf(diffLineInfoIn(worktreePath, parsed)) == patchLineOf(target)
})
}
// patchLine records what stays the same about a diff line when the same diff is
// rendered again differently: which file it belongs to, the line number that
// identifies it on the side it belongs to, and what kind of line it is.
type patchLine struct {
path string
// Every kind of content line collapses into DiffLineContext, since an addition
// and the context line it turns into when whitespace stops counting are the same
// line of the same file. The header rows keep their kind: a file's header and the
// first line of the file it heads are not the same place.
kind types.DiffLineType
// The old file's line number for a deletion, since two consecutive deletions
// share a new-file position and differ only here; the new file's otherwise.
line int
isDeletion bool
}
func patchLineOf(info types.DiffLineInfo) patchLine {
switch info.Type {
case types.DiffLineFileHeader, types.DiffLineHunkHeader:
return patchLine{path: info.Path, kind: info.Type, line: info.NewLine}
case types.DiffLineDeleted:
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.OldLine, isDeletion: true}
default:
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.NewLine}
}
}
// RevealSelectionAfterAction moves a diff pane's selection to the change that takes the
// place of the one just acted on, once the changed diff has re-rendered. Call it with
// the pane acted in, the pane the work carries on in, and the first line of the
// selection, before triggering the re-render.
//
// The line acted on is gone from the diff, so what is remembered is its place among the
// diff's changes: the next change moves up into it, which is where you want to be to
// carry on. A range collapses to a single line at its start, and hunk mode selects the
// whole block it lands in, so that pressing the key again acts on the next hunk. The
// target pane inherits that select mode, this being the same piece of work continuing
// in another pane — and shows no selection until the restore places one, so that what
// it was left showing the last time it was used doesn't appear for a frame.
//
// advanceBy moves on by that many changes past the place remembered, for an action that
// leaves the diff as it was: lines taken into a custom patch are still in the commit's
// diff, so the place remembered is still the line acted on, and carrying on means going
// past the lines just dealt with rather than staying on them.
//
// done, which may be nil, is called once the selection is where it belongs, or once it
// turns out that no render is coming to put it there — for a caller that must not let
// the user act again in between.
func (self *DiffLineHelper) RevealSelectionAfterAction(
source types.DiffPaneContext, target types.DiffPaneContext, firstLineIdx int, advanceBy int, done func(),
) {
ordinal, ok := self.ChangeLineOrdinal(source.GetView(), firstLineIdx)
if !ok {
if done != nil {
done()
}
return
}
sel := source.DiffSelectState()
if sel.Mode == types.DiffSelectModeRange {
sel.Mode = types.DiffSelectModeLine
sel.RangeIsSticky = false
}
*target.DiffSelectState() = *sel
selectHunk := sel.Mode == types.DiffSelectModeHunk
targetView := target.GetView()
if target != source {
target.SetHasSelectableContent(false)
self.c.Context().UpdateSelectionHighlights()
}
self.RevealChangeLineAtOrdinal(targetView, ordinal+advanceBy, func(viewLine int) {
if selectHunk {
self.SelectChangeBlock(target, viewLine, true)
return
}
targetView.CancelRangeSelect()
self.ShowSelectionAtLine(targetView, viewLine, true)
}, done)
}