From 636832e0450cce28e5aecf11a2d5dfdc89c845be Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 19 Aug 2026 09:55:15 +0200 Subject: [PATCH] Let an emptied main pane forget what it was showing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pane that has been emptied is showing nothing, but it kept the scroll position it was left at and went on claiming the render it used to show, so the next render into it — the same command's output, the file it was showing being selected again — counted as content the view already had, and was revealed partway down. So say what the empty pane is: at the top, and showing nothing that a render can be a re-render of. Co-authored-by: Claude Opus 5 (1M context) --- pkg/gui/main_panels.go | 11 ++++- .../pane_shown_again_starts_at_the_top.go | 3 -- pkg/tasks/tasks.go | 11 +++++ pkg/tasks/tasks_test.go | 44 +++++++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/pkg/gui/main_panels.go b/pkg/gui/main_panels.go index 4a3a42491..566993a0f 100644 --- a/pkg/gui/main_panels.go +++ b/pkg/gui/main_panels.go @@ -162,10 +162,19 @@ func (gui *Gui) clampDiffSelectionToContent(view *gocui.View) { } // clearMainView empties a pane that is being given nothing to show, selection and all. +// +// An emptied pane is showing nothing, so it also goes back to the top and stops +// claiming the render it was showing: whatever it is given next is content the user +// hasn't seen there, and is shown from the top like any other. func (gui *Gui) clearMainView(mainContext types.Context) { - mainContext.GetView().Clear() + view := mainContext.GetView() + view.Clear() + view.SetOrigin(0, 0) mainContext.SetHasSelectableContent(false) gui.State.ContextMgr.UpdateSelectionHighlights() + if manager := gui.getViewBufferManagerForView(view); manager != nil { + manager.ForgetRenderedContent() + } } // updateDiffSelectionVisibility works out whether a main pane holds anything for a diff --git a/pkg/integration/tests/file/pane_shown_again_starts_at_the_top.go b/pkg/integration/tests/file/pane_shown_again_starts_at_the_top.go index 1219543f8..3fc38c584 100644 --- a/pkg/integration/tests/file/pane_shown_again_starts_at_the_top.go +++ b/pkg/integration/tests/file/pane_shown_again_starts_at_the_top.go @@ -56,9 +56,6 @@ var PaneShownAgainStartsAtTheTop = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Secondary(). IsVisible(). Content(Contains("+LINE40")). - /* EXPECTED: OriginY(0) - ACTUAL: */ - OriginYAtLeast(1) }, }) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index b25341678..0a1dad3bc 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -251,6 +251,17 @@ func (self *ViewBufferManager) GetTaskKey() string { return self.taskKey } +// ForgetRenderedContent records that the view no longer shows the render whose key it +// is holding, because it has been emptied. The key says what the view is showing, and +// the next task is compared against it to tell whether that task renders something +// new. A view with nothing in it is showing nothing, so whatever comes next is new. +func (self *ViewBufferManager) ForgetRenderedContent() { + self.taskIDMutex.Lock() + defer self.taskIDMutex.Unlock() + + self.taskKey = "" +} + func NewViewBufferManager( log *logrus.Entry, writer io.Writer, diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index 66160d68a..839b6607e 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -610,6 +610,50 @@ func TestKeepScrollPositionForNextTask(t *testing.T) { assert.EqualValues(t, 0, beforeStartCount.Load()) } +// A view that has been emptied is showing nothing, so the render it was showing is no +// longer the one to compare the next task against: running the same command again is +// putting content into the view that isn't there any more, and starts from the top. +func TestForgetRenderedContent(t *testing.T) { + resetOrigin, getResetOriginCallCount := getCounter() + + manager := NewViewBufferManager( + utils.NewDummyLog(), + io.Discard, + func() {}, // beforeStart + func() {}, // refreshView + func() {}, // onEndOfInput + resetOrigin, + func() {}, // beginRender + func() {}, // swapInRender + func() gocui.Task { return gocui.NewFakeTask() }, + // no UI thread in the test; run the view mutations inline + func(f func()) error { f(); return nil }, + ) + + runTaskToCompletion := func(key string) { + start := func() (Cmd, io.Reader) { + // not actually starting this because it's not necessary + return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 3} + } + done := make(chan struct{}) + _ = manager.NewTask( + manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, func() { close(done) }), key) + <-done + } + + runTaskToCompletion("cmd1") + assert.Equal(t, 1, getResetOriginCallCount()) + + // Rendering the same command's output again leaves the view where it is, that being + // what it already shows. + runTaskToCompletion("cmd1") + assert.Equal(t, 1, getResetOriginCallCount()) + + manager.ForgetRenderedContent() + runTaskToCompletion("cmd1") + assert.Equal(t, 2, getResetOriginCallCount(), "an emptied view is shown its content afresh") +} + func TestNewCmdTaskRefresh(t *testing.T) { type scenario struct { name string