From 8f25e160e8ca07f5adb84bba8d6ed7b9cf626b2d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 15 Aug 2026 21:59:21 +0200 Subject: [PATCH] Keep both ends of a selected range across a re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A range or hunk selection covers a stretch of the diff, and restoring only the cursor left the other end pointing at whatever line the new rendering happened to put there — with more context lines above, a selected hunk would grow a tail of context it never covered. So the other end is remembered by identity too, and put back before the cursor. If it is a line the re-render dropped, the selection is left as the single line we landed on: there is no telling which line inherits a selection's edge, and a wrong guess acts on lines the user never chose. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gocui/view.go | 7 + .../controllers/helpers/diff_line_helper.go | 83 +++++----- .../controllers/helpers/diff_line_restore.go | 153 +++++++++++++++--- pkg/integration/components/view_driver.go | 15 ++ ..._wrapped_line_covered_across_a_rerender.go | 60 +++++++ .../keep_both_halves_of_a_change_selected.go | 68 ++++++++ ...ition_by_the_visible_end_of_a_selection.go | 102 ++++++++++++ ...lected_range_when_changing_context_size.go | 123 ++++++++++++++ pkg/integration/tests/test_list.go | 4 + 9 files changed, 552 insertions(+), 63 deletions(-) create mode 100644 pkg/integration/tests/main_view/keep_a_wrapped_line_covered_across_a_rerender.go create mode 100644 pkg/integration/tests/main_view/keep_both_halves_of_a_change_selected.go create mode 100644 pkg/integration/tests/main_view/keep_position_by_the_visible_end_of_a_selection.go create mode 100644 pkg/integration/tests/main_view/keep_selected_range_when_changing_context_size.go diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 337826b2b..e0fdb4357 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -637,6 +637,13 @@ func (v *View) CancelRangeSelect() { v.rangeSelectStartY = -1 } +// HasRangeSelect reports whether a range selection is anchored, as opposed to the +// view showing a plain cursor. A range whose ends are on the same view line is still +// one, which SelectedLineRange alone can't tell you. +func (v *View) HasRangeSelect() bool { + return v.rangeSelectStartY != -1 +} + func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeight int) int { if viewHeight >= lineCount { return 0 diff --git a/pkg/gui/controllers/helpers/diff_line_helper.go b/pkg/gui/controllers/helpers/diff_line_helper.go index 533b63127..b648ce207 100644 --- a/pkg/gui/controllers/helpers/diff_line_helper.go +++ b/pkg/gui/controllers/helpers/diff_line_helper.go @@ -32,53 +32,56 @@ func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper { // 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) + identities, ok := self.diffLineIdentitiesAt(view, viewLineIdx) if !ok { return types.DiffLineInfo{}, false } - - contents := view.DiffLineContents() - if bufferLineIdx >= len(contents) { - return types.DiffLineInfo{}, false - } - - if renderingStatesDiffLines(contents) { - if info, ok := self.diffLineInfoFromRecords(contents[bufferLineIdx].Metadata); ok { - return info, true - } - return types.DiffLineInfo{}, false - } - - parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx) - if !ok { - return types.DiffLineInfo{}, false - } - - return self.diffLineInfo(parsed), true -} - -// diffLineInfoFromRecords recovers a row's identity from the records the diff -// renderer stated for it. ok is false when the row carries no record we understand. -// -// A row can carry more than one record, when the rendering puts two diff lines on it -// (a side-by-side row shows a deletion and the addition replacing it); the leftmost -// is the one a reader would call the row's own, so it is the row's identity. -func (self *DiffLineHelper) diffLineInfoFromRecords(metadata []string) (types.DiffLineInfo, bool) { - identities := self.diffLineIdentitiesFromRecords(metadata) - if len(identities) == 0 { - return types.DiffLineInfo{}, false - } return identities[0], true } +// diffLineIdentitiesAt recovers every diff line the row at the given (wrapped) view +// line shows, left to right. It is GetDiffLineInfo's form for a reader that can't +// settle for the line the row leads with: an end of a selection covers its whole +// row, so where a rendering puts a modification's two halves side by side it covers +// both of them. ok is false when the row's identity can't be recovered at all. +func (self *DiffLineHelper) diffLineIdentitiesAt( + 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 nil, false + } + + contents := view.DiffLineContents() + if bufferLineIdx >= len(contents) { + return nil, false + } + + if renderingStatesDiffLines(contents) { + if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 { + return identities, true + } + return nil, false + } + + parsed, ok := parseDiffLineFromBuffer(diffLineTexts(contents), bufferLineIdx) + if !ok { + return nil, false + } + + return []types.DiffLineInfo{self.diffLineInfo(parsed)}, true +} + // diffLineIdentitiesFromRecords recovers the identity of every diff line the row's -// records state, left to right. Which of them a reader is after depends on the -// reader: the one the row leads with is the row's own identity (see -// diffLineInfoFromRecords), while a reader looking for a particular line has to -// consider them all, since which of a modification's two halves leads a row is up to -// the rendering. +// records state, left to right. A row can carry more than one record, when the +// rendering puts two diff lines on it (a side-by-side row shows a deletion and the +// addition replacing it). Which of them a reader is after depends on the reader: the +// one the row leads with is the row's own identity (see GetDiffLineInfo and +// resolveDiffLines), while a reader looking for a particular line has to consider +// them all, since which of a modification's two halves leads a row is up to the +// rendering. func (self *DiffLineHelper) diffLineIdentitiesFromRecords(metadata []string) []types.DiffLineInfo { return self.diffLineInfos(parseDiffLineRecords(metadata)) } diff --git a/pkg/gui/controllers/helpers/diff_line_restore.go b/pkg/gui/controllers/helpers/diff_line_restore.go index 3770ffcea..7a35d7638 100644 --- a/pkg/gui/controllers/helpers/diff_line_restore.go +++ b/pkg/gui/controllers/helpers/diff_line_restore.go @@ -26,19 +26,22 @@ type diffLineAnchor struct { // 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 selected one, and the middle visible line when there is no -// selection or 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. +// 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 @@ -57,17 +60,20 @@ func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) { } showSelection := view.Highlight - selectionOnScreen := showSelection && view.IsLineVisible(view.SelectedLineIdx()) anchorViewLine := view.MiddleVisibleLineIdx() - if selectionOnScreen { - anchorViewLine = view.SelectedLineIdx() - } - // A selection that has been scrolled away from is put back by its own lines rather + 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 selectionCandidates []diffLineAnchor - if showSelection && !selectionOnScreen { - selectionCandidates = self.nearbyDiffLines(view, view.SelectedLineIdx()) + 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), @@ -77,16 +83,63 @@ func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) { row := lo.Clamp(anchor.row, 0, max(0, view.InnerHeight()-1)) view.SetOrigin(0, max(0, viewLine-row)) if showSelection { - // A range's other end is a view line, which the new rendering has made - // mean something else, so the selection collapses to the line we landed - // on. The origin is already where it should be, so moving the cursor - // there mustn't scroll. + // 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() - view.FocusPoint(0, self.selectionLine(view, selectionCandidates, viewLine), false) + 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 — @@ -108,6 +161,60 @@ func (self *DiffLineHelper) selectionLine( 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 diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 332856eb6..3a354c777 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -300,6 +300,21 @@ func (self *ViewDriver) SelectedLines(matchers ...*TextMatcher) *ViewDriver { return self } +// SelectedViewLineRange asserts which view lines the selection covers. View lines +// count the wrapped segments a line is drawn as, so this can say whether a selection +// covers a wrapped line to its end; SelectedLines, which reports the lines of the +// content, cannot. +func (self *ViewDriver) SelectedViewLineRange(first int, last int) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + actualFirst, actualLast := self.getSelectedRange() + return actualFirst == first && actualLast == last, + fmt.Sprintf("%s: Expected view lines %d-%d to be selected, but %d-%d were.", + self.context, first, last, actualFirst, actualLast) + }) + + return self +} + func (self *ViewDriver) validateMatchersPassed(matchers []*TextMatcher) { if len(matchers) < 1 { self.t.fail("'Lines' methods require at least one matcher to be passed as an argument. If you are trying to assert that there are no lines, use .IsEmpty()") diff --git a/pkg/integration/tests/main_view/keep_a_wrapped_line_covered_across_a_rerender.go b/pkg/integration/tests/main_view/keep_a_wrapped_line_covered_across_a_rerender.go new file mode 100644 index 000000000..9040b0e62 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_a_wrapped_line_covered_across_a_rerender.go @@ -0,0 +1,60 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepAWrappedLineCoveredAcrossARerender = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A selection over a line too long for the view still covers all of it after a re-render", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 80, + Height: 20, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true + }, + SetupRepo: func(shell *Shell) { + long := strings.Repeat("word ", 40) + lines := make([]string, 20) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + before := strings.Join(lines[:10], "\n") + "\n" + after := strings.Join(lines[10:], "\n") + "\n" + + shell.CreateFileAndAdd("file1", before+long+"\n"+after) + shell.Commit("one") + + shell.UpdateFile("file1", before+"CHANGED "+long+"\n"+after) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // The changed line is far too long for the view, so each half of the change + // is drawn as several view lines, and hunk mode selects all of them. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-word word"), + Contains("+CHANGED word"), + ). + SelectedViewLineRange(8, 16). + // The same two lines of the diff, wrapped the same way, are still covered + // to their ends once the diff has been rendered again. + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + SelectedLines( + Contains("-word word"), + Contains("+CHANGED word"), + ). + SelectedViewLineRange(9, 17) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_both_halves_of_a_change_selected.go b/pkg/integration/tests/main_view/keep_both_halves_of_a_change_selected.go new file mode 100644 index 000000000..87df911a2 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_both_halves_of_a_change_selected.go @@ -0,0 +1,68 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepBothHalvesOfAChangeSelected = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A change selected on the one row a renderer draws it as is selected on both rows of a renderer that splits it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + // Git's own diff, which has a row for each half of a change. It announces + // the metadata protocol, so lazygit acts on its output rather than + // replacing it; it states no records of its own, so the rows are located + // by parsing the text, which still looks like a diff. + {Name: "unified", Command: `printf '\033]1717;1\007'; cat`}, + // A renderer that puts the two halves of a change beside each other on one + // row. Only the records it states can say where those halves are; parsing + // the text could not. It ignores its input and prints this one. + {Name: "columns", Command: `printf '\033]1717;1\007'; ` + + `printf '\033]1717;1;f;;;file1\007file1\n'; ` + + `printf '\033]1717;1;h;1;;file1\007@@\n'; ` + + `printf '\033]1717;1;c;1;;file1\007one one\n'; ` + + `printf '\033]1717;1;d;2;2;file1\007two \033]1717;1;a;2;;file1\007TWO\n'; ` + + `printf '\033]1717;1;c;3;;file1\007three three\n'; ` + + `cat >/dev/null`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\nTWO\nthree\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-two"), + Contains("+TWO"), + ). + // The change is one row here, and selecting it selects that row: both + // halves are on it. + Press(keys.Universal.CycleDiffRenderers). + Tap(func() { + t.ExpectToast(Equals("Diff renderer: columns (2 of 2)")) + }). + SelectedLines( + Contains("two TWO"), + ). + // Split apart again, the same change is the same two lines it was. + Press(keys.Universal.CycleDiffRenderers). + Tap(func() { + t.ExpectToast(Equals("Diff renderer: unified (1 of 2)")) + }). + SelectedLines( + Contains("-two"), + Contains("+TWO"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_position_by_the_visible_end_of_a_selection.go b/pkg/integration/tests/main_view/keep_position_by_the_visible_end_of_a_selection.go new file mode 100644 index 000000000..0ac9e1f54 --- /dev/null +++ b/pkg/integration/tests/main_view/keep_position_by_the_visible_end_of_a_selection.go @@ -0,0 +1,102 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepPositionByTheVisibleEndOfASelection = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A re-render keeps the place by the end of a selected hunk that is on screen when its other end isn't", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = true + // One line per scroll, so that the test can put the top of the view exactly + // where it wants it. + cfg.GetUserConfig().Gui.ScrollHeight = 1 + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 60) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + // A first change tall enough to be scrolled halfway out of the view, and more + // of them below it, so that a context-size change moves the lines further down + // the diff by more than it moves the first change. + for _, i := range []int{10, 11, 12, 13, 14, 15, 30, 45} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-line10"), + Contains("-line11"), + Contains("-line12"), + Contains("-line13"), + Contains("-line14"), + Contains("-line15"), + Contains("+LINE10"), + Contains("+LINE11"), + Contains("+LINE12"), + Contains("+LINE13"), + Contains("+LINE14"), + Contains("+LINE15"), + ). + SelectedLineIdx(8). + // Scroll past the start of the selected block, leaving its last lines on + // screen and the cursor above the top of the view. + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + OriginY(14). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + // The block's last line was the fifth row of the screen, and one context + // line more above the block puts it a line further down the diff: the view + // follows it, rather than the middle visible line, which the hunks below + // have pushed further still. + OriginY(15). + SelectedLines( + Contains("-line10"), + Contains("-line11"), + Contains("-line12"), + Contains("-line13"), + Contains("-line14"), + Contains("-line15"), + Contains("+LINE10"), + Contains("+LINE11"), + Contains("+LINE12"), + Contains("+LINE13"), + Contains("+LINE14"), + Contains("+LINE15"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/keep_selected_range_when_changing_context_size.go b/pkg/integration/tests/main_view/keep_selected_range_when_changing_context_size.go new file mode 100644 index 000000000..8a64b8dab --- /dev/null +++ b/pkg/integration/tests/main_view/keep_selected_range_when_changing_context_size.go @@ -0,0 +1,123 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepSelectedRangeWhenChangingContextSize = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A range selection still covers the same lines of the diff after the context size changes", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + for _, i := range []int{5, 15, 25, 35} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + // A range from a change down into the context below it, so that the cursor is + // on the last line of the selection and the other end is three lines above. + t.Views().Main(). + IsFocused(). + Press(keys.Main.NextHunk). + Press(keys.Main.NextHunk). + Press(keys.Universal.ToggleRangeSelect). + Press(keys.Universal.NextItem). + Press(keys.Universal.NextItem). + Press(keys.Universal.NextItem). + SelectedLines( + Contains("-line25"), + Contains("+LINE25"), + Contains(" line26"), + Contains(" line27"), + ). + // Both ends are still lines of the diff with more context around the + // change, so the selection still covers the same four. + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 4")) + }). + SelectedLines( + Contains("-line25"), + Contains("+LINE25"), + Contains(" line26"), + Contains(" line27"), + ). + // With a single line of context, the line the cursor was on is no longer in + // the diff. The end that survived stays put and the cursor lands on the + // nearest line that is left, so the selection shrinks with the diff. + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 3")) + }). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 2")) + }). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 1")) + }). + SelectedLines( + Contains("-line25"), + Contains("+LINE25"), + Contains(" line26"), + ). + // The other way round: a range extended upwards, so that it is the far end + // that the shrinking context takes away. There is no guessing which line + // inherits it, so what is left is the line the cursor is on. + PressEscape(). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 2")) + }). + Press(keys.Universal.IncreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 3")) + }). + SelectedLines( + Contains(" line26"), + ). + Press(keys.Universal.NextItem). + Press(keys.Universal.RangeSelectUp). + Press(keys.Universal.RangeSelectUp). + Press(keys.Universal.RangeSelectUp). + SelectedLines( + Contains("-line25"), + Contains("+LINE25"), + Contains(" line26"), + Contains(" line27"), + ). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 2")) + }). + Press(keys.Universal.DecreaseContextInDiffView). + Tap(func() { + t.ExpectToast(Equals("Changed diff context size to 1")) + }). + SelectedLines( + Contains("-line25"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d71ce25c2..ed89e9a03 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -371,6 +371,9 @@ var tests = []*components.IntegrationTest{ main_view.EscapeDismissesSelection, main_view.FileNavigationScrollsToTheTop, main_view.HideSelectionWhenChangesVanish, + main_view.KeepAWrappedLineCoveredAcrossARerender, + main_view.KeepBothHalvesOfAChangeSelected, + main_view.KeepPositionByTheVisibleEndOfASelection, main_view.KeepPositionInBothPanesWhenChangingContextSize, main_view.KeepPositionInBothPanesWhenIgnoringWhitespace, main_view.KeepPositionInBothPanesWhenSwitchingDiffRenderers, @@ -380,6 +383,7 @@ var tests = []*components.IntegrationTest{ main_view.KeepPositionWhenSwitchingDiffRenderers, main_view.KeepPositionWhenTheSelectionIsOffScreen, main_view.KeepScrollWhenTheDiffCantBeRead, + main_view.KeepSelectedRangeWhenChangingContextSize, main_view.KeepSelectionVisibleWhenDiffShrinks, main_view.NavigateByHunkAndFile, main_view.NoSelectionOverABinaryDiff,