jesseduffield.lazygit/pkg/tasks/tasks_test.go
Stefan Haller b4fb49acce Render async content into an off-screen buffer and swap it in
A cmd/pty re-render used to overwrite the displayed buffer from the top
down as lines arrived, relying on keeping the previous render's view-line
tail to avoid a blank frame. That left the view showing a mixture of old
and new content while loading, and any reader (draw, the diff-line
mapping, clicks) could observe a half-written buffer at the wrong scroll —
the §11 Race A flicker and the §8 stale-tail mapping both came from this.

Instead, build the new content in a second, off-screen viewBuffer: until
the task has read enough to paint, writes go there and the displayed
buffer — and so everything every reader sees — is left untouched. Once the
task reaches its first-paint point (InitialRefreshAfter, or EOF for short
content) it swaps the off-screen buffer in atomically and applies the
saved scroll in the same step, so the view jumps straight from the
previous render to the new one with no intermediate frame. Subsequent
lines append to the now-displayed buffer.

Swapping at the first-paint point means the displayed buffer is only a
viewport tall when it appears and then grows as the rest streams in toward
the count needed for an accurate scrollbar. The scrollbar is sized from the
displayed buffer's height, so left to itself the thumb would shrink and
snap back during that growth (most visibly: the files panel's periodic
refresh making the thumb jump while scrolled down). The total height the
scrollbar needs is a strictly later quantity than the viewport-fill paint,
so no single early swap can have both right. FreezeScrollbarHeight therefore
records the view's height when a load begins and the scrollbar is held there
— growing only if the new content turns out taller — until the load ends; a
synchronous render superseding the load releases it. This mirrors the layout
clamp, which already ignores the partial content height while a view loads.

With the swap doing a wholesale replace, refreshViewLinesIfNeeded can
truncate the view lines to the current buffer: there is no longer a
half-loaded shorter buffer whose tail we must keep showing, so the stale
tail (§8) never forms. clear()/Reset() abandon any in-progress off-screen
render so a synchronous SetContent after a stopped task writes to the
display.

This replaces the holdViewLines and freshViewLineCount patches reverted in
the previous two commits with one mechanism. The swap holds writeMutex for
now; it could later move to the main thread. Flicker behaviour still needs
interactive verification (LAZYGIT_SLOW_RENDER + a real pager).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 12:58:59 +02:00

287 lines
7.4 KiB
Go

package tasks
import (
"bytes"
"io"
"os/exec"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/utils"
)
func getCounter() (func(), func() int) {
counter := 0
return func() { counter++ }, func() int { return counter }
}
func TestNewCmdTaskInstantStop(t *testing.T) {
writer := bytes.NewBuffer(nil)
beforeStart, getBeforeStartCallCount := getCounter()
refreshView, getRefreshViewCallCount := getCounter()
onEndOfInput, getOnEndOfInputCallCount := getCounter()
onNewKey, getOnNewKeyCallCount := getCounter()
beginRender, getBeginRenderCallCount := getCounter()
swapInRender, getSwapInRenderCallCount := getCounter()
onDone, getOnDoneCallCount := getCounter()
task := gocui.NewFakeTask()
newTask := func() gocui.Task {
return task
}
manager := NewViewBufferManager(
utils.NewDummyLog(),
writer,
beforeStart,
refreshView,
onEndOfInput,
onNewKey,
beginRender,
swapInRender,
newTask,
// no UI thread in the test; run the view mutations inline
func(f func() error) error { return f() },
)
stop := make(chan struct{})
reader := bytes.NewBufferString("test")
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
cmd := exec.Command("blah")
close(stop)
return ExecCmd{Cmd: cmd}, reader
}
fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{Total: 20, InitialRefreshAfter: -1}, onDone)
_ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }})
callCountExpectations := []struct {
expected int
actual int
name string
}{
{0, getBeforeStartCallCount(), "beforeStart"},
{1, getRefreshViewCallCount(), "refreshView"},
{0, getOnEndOfInputCallCount(), "onEndOfInput"},
{0, getOnNewKeyCallCount(), "onNewKey"},
{0, getBeginRenderCallCount(), "beginRender"},
{0, getSwapInRenderCallCount(), "swapInRender"},
{1, getOnDoneCallCount(), "onDone"},
}
for _, expectation := range callCountExpectations {
if expectation.actual != expectation.expected {
t.Errorf("expected %s to be called %d times, got %d", expectation.name, expectation.expected, expectation.actual)
}
}
if task.Status() != gocui.TaskStatusDone {
t.Errorf("expected task status to be 'done', got '%s'", task.FormatStatus())
}
expectedContent := ""
actualContent := writer.String()
if actualContent != expectedContent {
t.Errorf("expected writer to receive the following content: \n%s\n. But instead it received: %s", expectedContent, actualContent)
}
}
func TestNewCmdTask(t *testing.T) {
writer := bytes.NewBuffer(nil)
beforeStart, getBeforeStartCallCount := getCounter()
refreshView, getRefreshViewCallCount := getCounter()
onEndOfInput, getOnEndOfInputCallCount := getCounter()
onNewKey, getOnNewKeyCallCount := getCounter()
beginRender, getBeginRenderCallCount := getCounter()
swapInRender, getSwapInRenderCallCount := getCounter()
onDone, getOnDoneCallCount := getCounter()
task := gocui.NewFakeTask()
newTask := func() gocui.Task {
return task
}
manager := NewViewBufferManager(
utils.NewDummyLog(),
writer,
beforeStart,
refreshView,
onEndOfInput,
onNewKey,
beginRender,
swapInRender,
newTask,
// no UI thread in the test; run the view mutations inline
func(f func() error) error { return f() },
)
stop := make(chan struct{})
reader := bytes.NewBufferString("test")
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
cmd := exec.Command("blah")
return ExecCmd{Cmd: cmd}, reader
}
fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{Total: 20, InitialRefreshAfter: -1}, onDone)
wg := sync.WaitGroup{}
wg.Go(func() {
time.Sleep(100 * time.Millisecond)
close(stop)
})
_ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }})
wg.Wait()
callCountExpectations := []struct {
expected int
actual int
name string
}{
{0, getBeforeStartCallCount(), "beforeStart"},
{1, getRefreshViewCallCount(), "refreshView"},
{1, getOnEndOfInputCallCount(), "onEndOfInput"},
{0, getOnNewKeyCallCount(), "onNewKey"},
{1, getBeginRenderCallCount(), "beginRender"},
{1, getSwapInRenderCallCount(), "swapInRender"},
{1, getOnDoneCallCount(), "onDone"},
}
for _, expectation := range callCountExpectations {
if expectation.actual != expectation.expected {
t.Errorf("expected %s to be called %d times, got %d", expectation.name, expectation.expected, expectation.actual)
}
}
if task.Status() != gocui.TaskStatusDone {
t.Errorf("expected task status to be 'done', got '%s'", task.FormatStatus())
}
expectedContent := "prefix\ntest\n"
actualContent := writer.String()
if actualContent != expectedContent {
t.Errorf("expected writer to receive the following content: \n%s\n. But instead it received: %s", expectedContent, actualContent)
}
}
// A dummy reader that simply yields as many blank lines as requested. The only
// thing we want to do with the output is count the number of lines.
type BlankLineReader struct {
totalLinesToYield int
linesYielded int
}
func (d *BlankLineReader) Read(p []byte) (n int, err error) {
if d.totalLinesToYield == d.linesYielded {
return 0, io.EOF
}
d.linesYielded++
p[0] = '\n'
return 1, nil
}
func TestNewCmdTaskRefresh(t *testing.T) {
type scenario struct {
name string
totalTaskLines int
linesToRead LinesToRead
expectedLineCountsOnRefresh []int
}
scenarios := []scenario{
{
"total < initialRefreshAfter",
150,
LinesToRead{Total: 100, InitialRefreshAfter: 120},
[]int{100},
},
{
"total == initialRefreshAfter",
150,
LinesToRead{Total: 100, InitialRefreshAfter: 100},
[]int{100},
},
{
"total > initialRefreshAfter",
150,
LinesToRead{Total: 100, InitialRefreshAfter: 50},
[]int{50, 100},
},
{
"initialRefreshAfter == -1",
150,
LinesToRead{Total: 100, InitialRefreshAfter: -1},
[]int{100},
},
{
"totalTaskLines < initialRefreshAfter",
25,
LinesToRead{Total: 100, InitialRefreshAfter: 50},
[]int{25},
},
{
"totalTaskLines between total and initialRefreshAfter",
75,
LinesToRead{Total: 100, InitialRefreshAfter: 50},
[]int{50, 75},
},
}
for _, s := range scenarios {
writer := bytes.NewBuffer(nil)
lineCountsOnRefresh := []int{}
refreshView := func() {
lineCountsOnRefresh = append(lineCountsOnRefresh, strings.Count(writer.String(), "\n"))
}
task := gocui.NewFakeTask()
newTask := func() gocui.Task {
return task
}
manager := NewViewBufferManager(
utils.NewDummyLog(),
writer,
func() {},
refreshView,
func() {},
func() {},
func() {},
func() {},
newTask,
// no UI thread in the test; run the view mutations inline
func(f func() error) error { return f() },
)
stop := make(chan struct{})
reader := BlankLineReader{totalLinesToYield: s.totalTaskLines}
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
cmd := exec.Command("blah")
return ExecCmd{Cmd: cmd}, &reader
}
fn := manager.NewCmdTask(start, "", s.linesToRead, func() {})
wg := sync.WaitGroup{}
wg.Go(func() {
time.Sleep(100 * time.Millisecond)
close(stop)
})
_ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }})
wg.Wait()
if !reflect.DeepEqual(lineCountsOnRefresh, s.expectedLineCountsOnRefresh) {
t.Errorf("%s: expected line counts on refresh: %v, got %v",
s.name, s.expectedLineCountsOnRefresh, lineCountsOnRefresh)
}
}
}