From abc16e904c5fd81554d4a6e4fd3ac651e340486d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 08:32:52 +0200 Subject: [PATCH 1/6] Keep horizontally scrolled content out of the inclusion gutter If a view that shows the inclusion gutter is scrolled horizontally, its content is drawn over the gutter. The marker of a marked line is overwritten, and the content starts the gutter's width too far to the left. The draw loop skips the cells scrolled past the left edge only until x reaches 0, but the content area starts at the gutter's width. Nothing scrolls such a view horizontally yet. A later commit lets the focused main view scroll sideways, and the gutter shows there while building a custom patch. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gocui/view.go | 11 ++++++----- pkg/gocui/view_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index f48a4704e..695ab0f4e 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -1878,7 +1878,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 +1891,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 +1902,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 diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index edc800990..3a5ba5814 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1326,6 +1326,31 @@ 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)) +} + // 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. From 4f678b7d0d5184e90ded1d866729a61fb1886203 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 08:44:26 +0200 Subject: [PATCH 2/6] Keep the horizontal scroll position when a diff is rendered again Two places that position the main view after a re-render of the same diff call SetOrigin(0, y), although they only mean to move the view vertically. One is the restore after a change of the context size, of whitespace handling or of the diff renderer. The other is the clamp for content that ends above the scroll position, for example after staging a large part of the diff. Use SetOriginY in both, so that they keep the horizontal scroll position. Nothing scrolls the main view horizontally yet, so this changes nothing on its own. It prepares for scrolling the focused main view sideways with H and L. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/helpers/diff_line_restore.go | 2 +- pkg/gui/tasks_adapter.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/diff_line_restore.go b/pkg/gui/controllers/helpers/diff_line_restore.go index c51bbe31b..5cd924cc9 100644 --- a/pkg/gui/controllers/helpers/diff_line_restore.go +++ b/pkg/gui/controllers/helpers/diff_line_restore.go @@ -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 diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index c2db5f1ec..5b87a9d59 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -233,7 +233,7 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { if linesHeight < originY { newOriginY := linesHeight - view.SetOrigin(0, newOriginY) + view.SetOriginY(newOriginY) } gui.updateDiffPaneDecorations(view, true) From 6da4f658d7aed822f132b1e815b2c8b86492a282 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 08:46:03 +0200 Subject: [PATCH 3/6] Don't leave a re-rendered diff scrolled further right than it reaches The main view keeps its horizontal scroll position when a diff is rendered again. If the new rendering is narrower, for example because the only long line in it was just staged, the view can end up scrolled past the end of every line and look empty. If one line still reaches into the view, the view shows only a sliver of it, although the whole diff would fit without scrolling. Once a render has been read to the end, scroll the view back as far as it takes to put the end of the widest line at the right edge. A long diff that is read only in part is checked once the rest of it has been read. Nothing scrolls the main view horizontally yet, so this changes nothing on its own. It prepares for scrolling the focused main view sideways with H and L. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gocui/view.go | 31 +++++++++++++++++++++++++++++++ pkg/gocui/view_test.go | 30 ++++++++++++++++++++++++++++++ pkg/gui/tasks_adapter.go | 1 + 3 files changed, 62 insertions(+) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 695ab0f4e..b10edd387 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -2693,6 +2693,37 @@ func (v *View) ScrollRight(amount int) { 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 diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 3a5ba5814..b123ea3ad 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -1351,6 +1351,36 @@ func TestInclusionGutterWithHorizontallyScrolledContent(t *testing.T) { assert.Equal(t, " 3456789a", row(2)) } +// 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. diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 5b87a9d59..114c27705 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -235,6 +235,7 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { view.SetOriginY(newOriginY) } + view.ClampOriginXToContent() gui.updateDiffPaneDecorations(view, true) gui.clampDiffSelectionToContent(view) From 2abe4848f32d532d818211e47c6210a62785193d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 08:50:53 +0200 Subject: [PATCH 4/6] Scroll back to the left edge when jumping to another file of a diff Going to the next or previous file of a diff, or picking one from the jump-to-file menu or the diffstat, brings that file to the top of the view. The user goes there to read the file from its start, so scroll the view back to the left edge as well. Jumps between the hunks of a diff keep the horizontal scroll position, as moving the selection does. Nothing scrolls the main view horizontally yet, so this changes nothing on its own. It prepares for scrolling the focused main view sideways with H and L. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/gui/controllers/helpers/diff_line_selection.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/gui/controllers/helpers/diff_line_selection.go b/pkg/gui/controllers/helpers/diff_line_selection.go index e80147a1e..4d6b26b51 100644 --- a/pkg/gui/controllers/helpers/diff_line_selection.go +++ b/pkg/gui/controllers/helpers/diff_line_selection.go @@ -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 From 75083c179e78cf391827240811969fae19c4e965 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 08:53:56 +0200 Subject: [PATCH 5/6] Scroll the focused diff sideways with H and L If gui.wrapLinesInDiffView is false, long lines of a diff run off the right edge of the main view, and there is no way to see the rest of them. This makes 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 has 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. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs-master/Config.md | 2 + pkg/config/user_config.go | 1 + pkg/gui/controllers/main_view_controller.go | 12 +++ pkg/integration/components/view_driver.go | 9 +++ ...o_another_file_scrolls_to_the_left_edge.go | 64 ++++++++++++++++ ...scroll_back_when_the_diff_gets_narrower.go | 53 +++++++++++++ .../main_view/scroll_diff_horizontally.go | 75 +++++++++++++++++++ pkg/integration/tests/test_list.go | 3 + schema-master/config.json | 2 +- 9 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/main_view/jump_to_another_file_scrolls_to_the_left_edge.go create mode 100644 pkg/integration/tests/main_view/scroll_back_when_the_diff_gets_narrower.go create mode 100644 pkg/integration/tests/main_view/scroll_diff_horizontally.go diff --git a/docs-master/Config.md b/docs-master/Config.md index d35455c84..c8bb8c5f6 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -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. diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index eedde41e0..140defcf3 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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"` diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 7129e1c43..9135f363f 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -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 { diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index b5168487a..d1b65a0d9 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -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) { diff --git a/pkg/integration/tests/main_view/jump_to_another_file_scrolls_to_the_left_edge.go b/pkg/integration/tests/main_view/jump_to_another_file_scrolls_to_the_left_edge.go new file mode 100644 index 000000000..507668d73 --- /dev/null +++ b/pkg/integration/tests/main_view/jump_to_another_file_scrolls_to_the_left_edge.go @@ -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) + }, +}) diff --git a/pkg/integration/tests/main_view/scroll_back_when_the_diff_gets_narrower.go b/pkg/integration/tests/main_view/scroll_back_when_the_diff_gets_narrower.go new file mode 100644 index 000000000..57c301ddc --- /dev/null +++ b/pkg/integration/tests/main_view/scroll_back_when_the_diff_gets_narrower.go @@ -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) + }, +}) diff --git a/pkg/integration/tests/main_view/scroll_diff_horizontally.go b/pkg/integration/tests/main_view/scroll_diff_horizontally.go new file mode 100644 index 000000000..ba9f29a5b --- /dev/null +++ b/pkg/integration/tests/main_view/scroll_diff_horizontally.go @@ -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) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 566baf0dc..2381fa1a2 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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,6 +471,8 @@ 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.SelectBelowALongCommitMessage, diff --git a/schema-master/config.json b/schema-master/config.json index 2607715b2..869b8fb60 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -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": { From dd68e5644a9c87d01ba16705153ce317e7b5a4e6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 08:56:58 +0200 Subject: [PATCH 6/6] 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,