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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-10-10 13:44:43 +02:00
co-authored by Claude Opus 5.5
parent 75083c179e
commit dd68e5644a
4 changed files with 107 additions and 1 deletions
+29 -1
View File
@@ -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
+32
View File
@@ -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) {
@@ -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)
},
})
+1
View File
@@ -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,