jesseduffield.lazygit/pkg/tasks/tasks_test.go
Stefan Haller 87b30d9581 Don't take the view over for "loading..." when the content isn't changing
A render that takes more than 200ms to produce its first line takes the view
over to say "loading...", which clears the buffer it was showing. That is
worth doing when the content coming is different — the view is showing
something the user has moved on from, and saying so beats leaving it there
silently. It is pure flicker when the content isn't changing: the view is
already showing exactly what the render is about to put back, and a slow
re-render of unchanged content is common (a background refresh over a repo
with submodules that have uncommitted changes, say).

So track whether the render in flight has content the view isn't already
showing, and only let the indicator take over when it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:30:27 +02:00

425 lines
11 KiB
Go

package tasks
import (
"bytes"
"io"
"os/exec"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
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()
onDone, getOnDoneCallCount := getCounter()
task := gocui.NewFakeTask()
newTask := func() gocui.Task {
return task
}
manager := NewViewBufferManager(
utils.NewDummyLog(),
writer,
beforeStart,
refreshView,
onEndOfInput,
onNewKey,
newTask,
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
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{20, -1, nil}, 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"},
{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()
onDone, getOnDoneCallCount := getCounter()
task := gocui.NewFakeTask()
newTask := func() gocui.Task {
return task
}
manager := NewViewBufferManager(
utils.NewDummyLog(),
writer,
beforeStart,
refreshView,
onEndOfInput,
onNewKey,
newTask,
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
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{20, -1, nil}, 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
}{
{1, getBeforeStartCallCount(), "beforeStart"},
{1, getRefreshViewCallCount(), "refreshView"},
{1, getOnEndOfInputCallCount(), "onEndOfInput"},
{0, getOnNewKeyCallCount(), "onNewKey"},
{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
}
// A dummy reader that yields the given number of blank lines and then blocks
// until unblock is closed, at which point it reports EOF. This lets a test hold
// a task in its "still loading" state for as long as it needs to.
type BlockingLineReader struct {
linesToYield int
linesYielded int
reachedEnd bool
blocked chan struct{}
unblock chan struct{}
}
func (d *BlockingLineReader) Read(p []byte) (n int, err error) {
if d.linesYielded == d.linesToYield {
if !d.reachedEnd {
d.reachedEnd = true
close(d.blocked)
}
<-d.unblock
return 0, io.EOF
}
d.linesYielded++
p[0] = '\n'
return 1, nil
}
func TestNewCmdTaskQueuedReadAtEndOfInput(t *testing.T) {
writer := bytes.NewBuffer(nil)
task := gocui.NewFakeTask()
manager := NewViewBufferManager(
utils.NewDummyLog(),
writer,
func() {},
func() {},
func() {},
func() {},
func() gocui.Task { return task },
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
reader := BlockingLineReader{
linesToYield: 5,
blocked: make(chan struct{}),
unblock: make(chan struct{}),
}
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
return ExecCmd{Cmd: exec.Command("blah")}, &reader
}
// The initial request asks for far more lines than the reader has, so the
// task reaches EOF while that request is still the one being served.
fn := manager.NewCmdTask(start, "", LinesToRead{100, -1, nil}, func() {})
thenCalled := false
wg := sync.WaitGroup{}
wg.Go(func() {
_ = fn(TaskOpts{Stop: make(chan struct{}), InitialContentLoaded: func() { task.Done() }})
})
<-reader.blocked
manager.ReadToEnd(func() { thenCalled = true })
// ReadToEnd queues its request from a goroutine; wait for it to land so that
// it is definitely outstanding by the time we let the task reach EOF.
for len(*manager.readLines.Load()) == 0 {
time.Sleep(time.Millisecond)
}
close(reader.unblock)
wg.Wait()
assert.True(t, thenCalled)
}
// A writer that records whether the loading indicator was ever written to it.
type LoadingIndicatorSpy struct {
sawLoadingIndicator atomic.Bool
}
func (self *LoadingIndicatorSpy) Write(p []byte) (n int, err error) {
if bytes.Contains(p, []byte("loading...")) {
self.sawLoadingIndicator.Store(true)
}
return len(p), nil
}
// A render that takes long enough to produce its first line takes the view over
// to say "loading...", which means clearing whatever it was showing. That is only
// worth doing when the content coming is different from what's on screen:
// re-rendering the same content would otherwise clear the view and render the
// same thing straight back, a visible flicker for nothing.
func TestLoadingIndicatorOnlyTakesOverForNewContent(t *testing.T) {
writer := &LoadingIndicatorSpy{}
manager := NewViewBufferManager(
utils.NewDummyLog(),
writer,
func() {},
func() {},
func() {},
func() {},
func() gocui.Task { return gocui.NewFakeTask() },
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
startTask := func(key string, reader io.Reader, onDone func()) {
start := func() (Cmd, io.Reader) {
// not actually starting this because it's not necessary
return ExecCmd{Cmd: exec.Command("blah")}, reader
}
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), key)
}
// Starts a task whose command produces nothing at all, so that it is still
// waiting for its first line when the loading indicator falls due. Returns
// the reader so the caller can let it finish.
startStalledTask := func(key string) *BlockingLineReader {
reader := &BlockingLineReader{
blocked: make(chan struct{}),
unblock: make(chan struct{}),
}
startTask(key, reader, nil)
<-reader.blocked
return reader
}
// Get some content on screen first: the indicator is only due when a render
// is slow, and this one isn't.
done := make(chan struct{})
startTask("cmd1", &BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
<-done
assert.False(t, writer.sawLoadingIndicator.Load())
// A slow re-render of that same content must leave the view alone however
// long it takes. The indicator is due 200ms in, so give it well past that.
sameContent := startStalledTask("cmd1")
defer close(sameContent.unblock)
time.Sleep(500 * time.Millisecond)
assert.False(t, writer.sawLoadingIndicator.Load())
// Different content, though, is worth taking the view over for.
newContent := startStalledTask("cmd2")
defer close(newContent.unblock)
assert.Eventually(t,
writer.sawLoadingIndicator.Load,
2*time.Second, 10*time.Millisecond)
}
func TestNewCmdTaskRefresh(t *testing.T) {
type scenario struct {
name string
totalTaskLines int
linesToRead LinesToRead
expectedLineCountsOnRefresh []int
}
scenarios := []scenario{
{
"total < initialRefreshAfter",
150,
LinesToRead{100, 120, nil},
[]int{100},
},
{
"total == initialRefreshAfter",
150,
LinesToRead{100, 100, nil},
[]int{100},
},
{
"total > initialRefreshAfter",
150,
LinesToRead{100, 50, nil},
[]int{50, 100},
},
{
"initialRefreshAfter == -1",
150,
LinesToRead{100, -1, nil},
[]int{100},
},
{
"totalTaskLines < initialRefreshAfter",
25,
LinesToRead{100, 50, nil},
[]int{25},
},
{
"totalTaskLines between total and initialRefreshAfter",
75,
LinesToRead{100, 50, nil},
[]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() {},
newTask,
// no UI thread in the test; run the view mutations inline
func(f func()) error { f(); return nil },
)
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)
}
}
}