From 6da4f658d7aed822f132b1e815b2c8b86492a282 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 9 Oct 2026 08:46:03 +0200 Subject: [PATCH] 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)