Allow scrolling the diff view horizontally when gui.wrapLinesInDiffView is off (#6109)

If gui.wrapLinesInDiffView is false, long lines of a diff run off the
right edge of the main view, and there was no way to see the rest of
them. This made it hard to see what has changed in such a line. The old
staging and patch-building views scrolled sideways with `H` and `L`, but
the focused main view that replaced them had no binding for these keys.

Bind them in the focused main view, as in the side panels. Leaving the
view scrolls it back to the left edge, as it did for the staging view.

In addition, when jumping to the next search result, and it is past the
right edge, scroll sideways to show it. This happens both in the new
main view when `gui.wrapLinesInDiffView` is off, but also in side
panels, so it is an improvement over pre-0.66 versions there.
This commit is contained in:
Stefan Haller
2026-10-10 13:57:45 +02:00
committed by GitHub
15 changed files with 427 additions and 9 deletions
+2
View File
@@ -145,6 +145,8 @@ gui:
# If true, wrap lines in focused diffs to the width of the view. This makes it
# much easier to work with diffs that have long lines, e.g. paragraphs of
# markdown text.
# If false, scroll a focused diff sideways with the scrollLeft and scrollRight
# keys (H and L by default).
wrapLinesInDiffView: true
# If true, hunk selection mode will be enabled by default when focusing a diff.
+1
View File
@@ -123,6 +123,7 @@ type GuiConfig struct {
// - 'top': split the window vertically (side panel on top, main view below)
EnlargedSideViewLocation string `yaml:"enlargedSideViewLocation"`
// If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.
// If false, scroll a focused diff sideways with the scrollLeft and scrollRight keys (H and L by default).
WrapLinesInDiffView bool `yaml:"wrapLinesInDiffView"`
// If true, hunk selection mode will be enabled by default when focusing a diff.
UseHunkModeInDiffView bool `yaml:"useHunkModeInDiffView"`
+66 -6
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)
@@ -1878,7 +1883,7 @@ func (v *View) draw(isWindowFocused bool) {
var c cell
for x < maxX {
if x < 0 && cellIdx >= len(vline.line) {
if x < gutterWidth && cellIdx >= len(vline.line) {
// no more characters to write so we're only going to be printing empty cells
// past this point
x = gutterWidth
@@ -1891,8 +1896,9 @@ func (v *View) draw(isWindowFocused bool) {
c = vline.line[cellIdx]
}
// Skip the cells scrolled out to the left.
if x < 0 && x+c.width <= 0 {
// Skip the cells scrolled past the left edge of the content area; the
// content area starts after the gutter.
if x < gutterWidth && x+c.width <= gutterWidth {
x += c.width
cellIdx++
continue
@@ -1901,10 +1907,10 @@ func (v *View) draw(isWindowFocused bool) {
// A terminal can't show half of a double-width character, so one that is cut
// off by the left or right edge shows as a blank in the column of it that is
// on screen.
if x < 0 || x+c.width > maxX {
if x < gutterWidth || x+c.width > maxX {
c.chr = " "
c.width = 1
x = max(x, 0)
x = max(x, gutterWidth)
}
fgColor := c.fgColor
@@ -2692,6 +2698,60 @@ 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
// changes, and the new content may not reach as far.
func (v *View) ClampOriginXToContent() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.ox == 0 {
return
}
v.refreshViewLinesIfNeeded()
widest := 0
for _, vline := range v.viewLines {
width := 0
for _, c := range vline.line {
width += c.width
}
widest = max(widest, width)
}
contentWidth := v.InnerWidth() - v.inclusionGutterWidth()
newOx := min(v.ox, max(0, widest-contentWidth))
if newOx != v.ox {
v.SetOriginX(newOx)
v.clearHover()
}
}
func (v *View) adjustDownwardScrollAmount(scrollHeight int) int {
_, oy := v.Origin()
y := oy
+87
View File
@@ -1326,6 +1326,93 @@ func TestClickHyperlinkBesideTheInclusionGutter(t *testing.T) {
opened)
}
// Content scrolled to the right goes under the gutter, not over it: the gutter
// stays where it is, and the content shows from the scroll position onwards.
func TestInclusionGutterWithHorizontallyScrolledContent(t *testing.T) {
WithSimulationScreen(t, 14, 6)
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10
v.InclusionGutterMarker = "✓"
v.writeString("0123456789abcdef\n0123456789abcdef\n")
v.SetInclusionGutter(true, []bool{true, false})
v.SetOriginX(3)
v.draw(true)
row := func(y int) string {
s := ""
for x := 1; x <= 10; x++ {
chr, _, _ := Screen.Get(x, y)
s += chr
}
return s
}
assert.Equal(t, "✓ 3456789a", row(1))
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) {
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10
v.InclusionGutterMarker = "✓"
// The widest line is 16 wide.
v.writeString("0123456789abcdef\nshort\n")
v.SetOriginX(4)
v.ClampOriginXToContent()
assert.Equal(t, 4, v.OriginX(), "the widest line still reaches the right edge")
v.SetOriginX(10)
v.ClampOriginXToContent()
assert.Equal(t, 6, v.OriginX())
// The gutter leaves the content eight columns.
v.SetInclusionGutter(true, []bool{false, false})
v.SetOriginX(10)
v.ClampOriginXToContent()
assert.Equal(t, 8, v.OriginX())
// Content that fits needs no scrolling at all.
v.Clear()
v.writeString("short\n")
v.ClampOriginXToContent()
assert.Equal(t, 0, v.OriginX())
}
// A marked line the view wraps is marked on every segment it is drawn as, so that
// the mark doesn't look like it belongs to the first part of the line alone. The
// gutter takes its columns out of the width the content wraps in.
@@ -81,7 +81,7 @@ func (self *DiffLineHelper) PreserveDiffPositionOnRerender(view *gocui.View) {
// 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))
view.SetOriginY(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
@@ -75,6 +75,10 @@ func (self *DiffLineHelper) EstablishSelection(mainContext *context.MainContext,
// the first change block at or below the target, which a large context size can put
// further down than a screenful; the selection is then scrolled into view as any other
// jump's is, and the alignment gives way to that.
//
// A jump that aligns its target to the top also scrolls the view back to the left
// edge, whether or not the target is on screen already. Such a jump goes to a file, and
// the user goes there to read the file from its start.
func (self *DiffLineHelper) PlaceNavigationTarget(
pane types.DiffPaneContext, target int, alignTop bool,
) {
@@ -84,6 +88,7 @@ func (self *DiffLineHelper) PlaceNavigationTarget(
return
}
if alignTop {
view.SetOriginX(0)
self.scrollTargetToTop(pane, target)
}
// Jumping to another block or file moves the cursor without shift held, so a
@@ -238,6 +238,8 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), Handler: self.handleScrollLeft, Description: self.c.Tr.ScrollLeft},
{Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollRight), Handler: self.handleScrollRight, Description: self.c.Tr.ScrollRight},
}
}
@@ -1052,6 +1054,16 @@ func (self *MainViewController) handleGotoBottom() error {
return nil
}
func (self *MainViewController) handleScrollLeft() error {
self.context.GetViewTrait().ScrollLeft()
return nil
}
func (self *MainViewController) handleScrollRight() error {
self.context.GetViewTrait().ScrollRight()
return nil
}
func (self *MainViewController) editLine() error {
view := self.context.GetView()
if !view.Highlight {
+2 -1
View File
@@ -233,8 +233,9 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
if linesHeight < originY {
newOriginY := linesHeight
view.SetOrigin(0, newOriginY)
view.SetOriginY(newOriginY)
}
view.ClampOriginXToContent()
gui.updateDiffPaneDecorations(view, true)
gui.clampDiffSelectionToContent(view)
@@ -568,6 +568,15 @@ func (self *ViewDriver) OriginY(expected int) *ViewDriver {
return self
}
func (self *ViewDriver) OriginX(expected int) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
actual := self.getView().OriginX()
return expected == actual, fmt.Sprintf("%s: Expected origin X to be %d, got %d", self.context, expected, actual)
})
return self
}
// asserts that the selected line is inside the visible area of the view
func (self *ViewDriver) SelectedLineIsVisible() *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
@@ -0,0 +1,64 @@
package main_view
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var JumpToAnotherFileScrollsToTheLeftEdge = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Jumping to another file of a diff scrolled sideways shows it from the left edge, while jumping to another hunk doesn't",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.WrapLinesInDiffView = false
cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
long := strings.Repeat("word ", 60)
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n")
shell.CreateFileAndAdd("file2", "one\ntwo\nthree\n")
shell.Commit("one")
shell.UpdateFileAndAdd("file1", "one\ntwo\nthree "+long+"\nfour\nfive\nsix\nseven\neight\nnine "+long+"\nten\n")
shell.UpdateFileAndAdd("file2", "one\ntwo\nthree "+long+"\n")
shell.Commit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("two").IsSelected(),
Contains("one"),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-three"),
).
Press(keys.Universal.ScrollRight).
OriginX(26).
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-nine"),
).
OriginX(26).
Press(keys.Main.NextFile).
SelectedLines(
Contains("diff --git a/file2 b/file2"),
).
OriginX(0).
Press(keys.Universal.ScrollRight).
OriginX(26).
Press(keys.Main.PrevFile).
SelectedLines(
Contains("diff --git a/file1 b/file1"),
).
OriginX(0)
},
})
@@ -0,0 +1,53 @@
package main_view
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ScrollBackWhenTheDiffGetsNarrower = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A diff scrolled sideways scrolls back when it is rendered again narrower than where it was scrolled to",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.WrapLinesInDiffView = false
cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
shell.Commit("one")
shell.UpdateFile("file1", "ONE\ntwo\nthree\nfour\nfive "+strings.Repeat("word ", 60)+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
Press(keys.Main.NextHunk).
SelectedLines(
Contains("-five"),
).
Press(keys.Universal.RangeSelectDown).
SelectedLines(
Contains("-five"),
Contains("+five word"),
).
Press(keys.Universal.ScrollRight).
Press(keys.Universal.ScrollRight).
OriginX(52).
// What is left of the diff fits in the pane, so the pane goes back to the left
// edge.
PressPrimaryAction().
SelectedLines(
Contains("+ONE"),
).
OriginX(0)
},
})
@@ -0,0 +1,75 @@
package main_view
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ScrollDiffHorizontally = NewIntegrationTest(NewIntegrationTestArgs{
Description: "With wrapping turned off for diffs, the focused diff scrolls sideways, and stays scrolled as the selection moves and the diff is rendered again",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Gui.WrapLinesInDiffView = false
cfg.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "one\ntwo\n")
shell.Commit("one")
long := strings.Repeat("word ", 60)
shell.UpdateFile("file1", "one "+long+"\ntwo "+long+"\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(
Contains("-one"),
).
OriginX(0).
// A third of the pane's width at a time.
Press(keys.Universal.ScrollRight).
OriginX(26).
Press(keys.Universal.ScrollLeft).
OriginX(0).
Press(keys.Universal.ScrollLeft).
OriginX(0).
Press(keys.Universal.ScrollRight).
Press(keys.Universal.ScrollRight).
OriginX(52).
SelectNextItem().
SelectedLines(
Contains("-two"),
).
OriginX(52).
Press(keys.Universal.IncreaseContextInDiffView).
Tap(func() {
t.ExpectToast(Equals("Changed diff context size to 4"))
}).
SelectedLines(
Contains("-two"),
).
OriginX(52).
PressPrimaryAction().
SelectedLines(
Contains("+one"),
).
OriginX(52).
// Leaving the view gives up the horizontal scroll position.
PressEscape()
t.Views().Files().
IsFocused()
t.Views().Main().
OriginX(0)
},
})
@@ -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)
},
})
+4
View File
@@ -412,6 +412,7 @@ var tests = []*components.IntegrationTest{
main_view.JumpToAFileOfAStashWithNoPrefixes,
main_view.JumpToAFileOfTheDiff,
main_view.JumpToAFileOnlyOverADiff,
main_view.JumpToAnotherFileScrollsToTheLeftEdge,
main_view.KeepAWrappedLineCoveredAcrossARerender,
main_view.KeepAnEmptiedPaneEmpty,
main_view.KeepBothHalvesOfAChangeSelected,
@@ -470,8 +471,11 @@ var tests = []*components.IntegrationTest{
main_view.ResetAPatchBuiltFromACommitsDiff,
main_view.ResetPatchWithEscape,
main_view.ResetThePatchFromThePaneShowingIt,
main_view.ScrollBackWhenTheDiffGetsNarrower,
main_view.ScrollDiffHorizontally,
main_view.SearchCollapsesTheSelection,
main_view.SearchFollowsTheSelection,
main_view.SearchScrollsToAMatchOffTheEdge,
main_view.SelectBelowALongCommitMessage,
main_view.SelectBelowALongDiffstat,
main_view.SelectDiffLines,
+1 -1
View File
@@ -671,7 +671,7 @@
},
"wrapLinesInDiffView": {
"type": "boolean",
"description": "If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.",
"description": "If true, wrap lines in focused diffs to the width of the view. This makes it much easier to work with diffs that have long lines, e.g. paragraphs of markdown text.\nIf false, scroll a focused diff sideways with the scrollLeft and scrollRight keys (H and L by default).",
"default": true
},
"useHunkModeInDiffView": {