From dd68e5644a9c87d01ba16705153ce317e7b5a4e6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 08:56:58 +0200 Subject: [PATCH] Scroll a view sideways to show the search match it goes to If a view doesn't wrap, a search match can be past its right edge, or left of it once the view is scrolled sideways. Going to such a match with /, n or N only scrolls the view vertically, so the match stays out of sight. This matters most for a diff with gui.wrapLinesInDiffView set to false, where a change is often at the end of a long line. Scroll the view sideways if the match isn't on screen. Put the match a third of the way across the view, so that some of the text before it shows too. If the view can show the match without scrolling, go back to the left edge instead. This applies to every view that doesn't wrap, including the side panels. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gocui/view.go | 30 ++++++++++++- pkg/gocui/view_test.go | 32 +++++++++++++ .../search_scrolls_to_a_match_off_the_edge.go | 45 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index b10edd387..fb22ca13c 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -534,9 +534,14 @@ func (v *View) SelectSearchResult(index int) { index = itemCount - 1 } - y := v.searcher.searchPositions[index].Y + pos := v.searcher.searchPositions[index] + y := pos.Y v.FocusPoint(v.ox, y, true) + // A match without a position in the line has nothing to scroll to. + if pos.XStart >= 0 { + v.scrollColumnsIntoView(pos.XStart, pos.XEnd) + } v.renderSearchStatus(index, itemCount) if v.searcher.onSelectItem != nil { v.searcher.onSelectItem(v, y) @@ -2693,6 +2698,29 @@ func (v *View) ScrollRight(amount int) { v.clearHover() } +// scrollColumnsIntoView scrolls the view sideways if the columns from xStart up to +// xEnd aren't all on screen. If they fit in the view without any scrolling, the view +// goes back to the left edge; otherwise xStart ends up a third of the way across, so +// that some of what comes before it is on screen too. Only a view that doesn't wrap +// has columns off screen. +func (v *View) scrollColumnsIntoView(xStart, xEnd int) { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + width := v.InnerWidth() - v.inclusionGutterWidth() + if xStart >= v.ox && xEnd <= v.ox+width { + return + } + + if xEnd <= width { + v.SetOriginX(0) + } else { + v.SetOriginX(xStart - width/3) + } + + v.clearHover() +} + // ClampOriginXToContent scrolls the view back to the left if it is scrolled further // than its content needs, so that the end of the widest line is at the right edge. // A view that doesn't wrap keeps its horizontal scroll position when its content diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index b123ea3ad..bd0aabd40 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1351,6 +1351,38 @@ func TestInclusionGutterWithHorizontallyScrolledContent(t *testing.T) { assert.Equal(t, " 3456789a", row(2)) } +// Selecting a search match that is off screen to the side scrolls the view so that +// the match starts a third of the way across it, or back to the left edge if that +// is enough to show the match. A match on screen leaves the view where it is. +func TestSelectSearchResultScrollsTheMatchIntoView(t *testing.T) { + v := NewView("name", 0, 0, 13, 5, OutputNormal) // InnerWidth 12 + + v.writeString("0123456789abcdefghijklmnopqrstuvwxyz\n") + + v.UpdateSearchResults("uv", nil) + v.SelectSearchResult(0) + assert.Equal(t, 26, v.OriginX(), "u is at 30, and a third of the width is 4") + + v.UpdateSearchResults("yz", nil) + v.SelectSearchResult(0) + assert.Equal(t, 26, v.OriginX(), "the match is on screen") + + v.UpdateSearchResults("mn", nil) + v.SelectSearchResult(0) + assert.Equal(t, 18, v.OriginX()) + + v.UpdateSearchResults("ab", nil) + v.SelectSearchResult(0) + assert.Equal(t, 0, v.OriginX(), "the match fits without scrolling") + + // The gutter leaves the content ten columns, of which a third is 3. + v.InclusionGutterMarker = "✓" + v.SetInclusionGutter(true, []bool{false}) + v.UpdateSearchResults("ab", nil) + v.SelectSearchResult(0) + assert.Equal(t, 7, v.OriginX(), "a is at 10, and doesn't fit in the first ten columns") +} + // A view scrolled further right than its content needs goes back as far as it takes // to put the end of the widest line at the right edge, and no further. func TestClampOriginXToContent(t *testing.T) { diff --git a/pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go b/pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go new file mode 100644 index 000000000..c9a2e52f0 --- /dev/null +++ b/pkg/integration/tests/main_view/search_scrolls_to_a_match_off_the_edge.go @@ -0,0 +1,45 @@ +package main_view + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SearchScrollsToAMatchOffTheEdge = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With wrapping turned off for diffs, searching the focused diff scrolls it sideways to show a match past its edge", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.WrapLinesInDiffView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one "+strings.Repeat("word ", 60)+"needle\ntwo needle\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + FilterOrSearch("needle"). + SelectedLines( + Contains("+one word"), + ). + // The match is at column 305 of the diff; it ends up a third of the way + // across the pane, which is 78 columns wide. + OriginX(279). + Press(keys.Universal.NextMatch). + SelectedLines( + Contains("+two needle"), + ). + OriginX(0) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 2381fa1a2..496789e0c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -475,6 +475,7 @@ var tests = []*components.IntegrationTest{ main_view.ScrollDiffHorizontally, main_view.SearchCollapsesTheSelection, main_view.SearchFollowsTheSelection, + main_view.SearchScrollsToAMatchOffTheEdge, main_view.SelectBelowALongCommitMessage, main_view.SelectBelowALongDiffstat, main_view.SelectDiffLines,