mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-06 05:57:38 -04:00
A view that re-renders content the user is already looking at, laid out differently — a different context size, another diff renderer — starts the new render from the top, losing where they were. Where that is can't be carried over as a scroll position, because a different layout of the same content puts the line they were on somewhere else; it can only be found by looking at what the re-render brings in. So let a restore be installed on the buffer manager just before the re-render is triggered. It rides the next command task, which asks it after each line whether enough has arrived to show what it remembers, and then hands it the first paint: the restore searches the off-screen buffer, swaps it in, and places the view, in that order, so that the search happens while the previous content is still displayed and the new content is never drawn at the previous render's scroll position. A restore that placed the view keeps the scroll reset new content would otherwise get; one that couldn't find what it was looking for leaves the render to do what it would have done anyway, and the lines-read count still has the last word on when to paint, so a restore can never hold a render back for ever. Some renderings can't be searched at all: a diff renderer is free to say nothing about which line of which file each row shows, and then no line of the old rendering can be looked for in the new one. There the offset into the content is all that is left to go on, and it is nearer to where the user was than the top is, so a re-render can also ask merely to be left where it is. That request rides the next task the same way, and answers the same question the scroll reset and the loading placeholder are asking: whether what is coming is content the user has not seen. Both outlive the task they were installed for, like the pending scroll reset does and for the same reason: that task can be stopped and replaced by a background refresh before it ever paints, leaving the replacement to honour it. The loading placeholder stays out of the way while either is pending — blanking the view for a message before putting the user back where they were is the flicker they exist to avoid. Nothing installs either of them yet. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
772 lines
24 KiB
Go
772 lines
24 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()
|
|
resetOrigin, getResetOriginCallCount := 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,
|
|
resetOrigin,
|
|
beginRender,
|
|
swapInRender,
|
|
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, getResetOriginCallCount(), "resetOrigin"},
|
|
{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()
|
|
resetOrigin, getResetOriginCallCount := 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,
|
|
resetOrigin,
|
|
beginRender,
|
|
swapInRender,
|
|
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
|
|
}{
|
|
{0, getBeforeStartCallCount(), "beforeStart"},
|
|
{1, getRefreshViewCallCount(), "refreshView"},
|
|
{1, getOnEndOfInputCallCount(), "onEndOfInput"},
|
|
{0, getResetOriginCallCount(), "resetOrigin"},
|
|
{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
|
|
}
|
|
|
|
// 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() {},
|
|
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
|
|
// The request is queued by the time this returns, so it is outstanding when we
|
|
// let the task reach EOF below.
|
|
manager.ReadToEnd(func() { thenCalled = true })
|
|
close(reader.unblock)
|
|
|
|
wg.Wait()
|
|
|
|
assert.True(t, thenCalled)
|
|
}
|
|
|
|
// A task rendering content the view wasn't already showing resets the scroll
|
|
// position to the top, at its first paint. If it is stopped and replaced before
|
|
// it ever paints — a background refresh landing just after the user clicked a
|
|
// different item, say — the replacement renders the same content and so decides
|
|
// on no reset of its own; it has to perform the one the stopped task was owed,
|
|
// or the view keeps the scroll position of the content it showed before.
|
|
func TestResetOriginSurvivesTaskReplacement(t *testing.T) {
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
bytes.NewBuffer(nil),
|
|
func() {},
|
|
func() {},
|
|
func() {},
|
|
resetOrigin,
|
|
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
|
|
}
|
|
// The first-paint point is far beyond what any of these readers yield, so
|
|
// only reaching EOF paints.
|
|
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), key)
|
|
}
|
|
runTaskToCompletion := func(key string) {
|
|
done := make(chan struct{})
|
|
startTask(key, &BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
|
|
<-done
|
|
}
|
|
|
|
// A render of content the view wasn't showing resets the scroll position.
|
|
runTaskToCompletion("cmd1")
|
|
assert.Equal(t, 1, getResetOriginCallCount())
|
|
|
|
// Different content again, but this task stalls before it can paint.
|
|
stalled := BlockingLineReader{
|
|
linesToYield: 3,
|
|
blocked: make(chan struct{}),
|
|
unblock: make(chan struct{}),
|
|
}
|
|
defer close(stalled.unblock)
|
|
startTask("cmd2", &stalled, nil)
|
|
<-stalled.blocked
|
|
|
|
// The replacement shows the same content as the stalled task, so it has no
|
|
// reset of its own to do — but it must still do that task's.
|
|
runTaskToCompletion("cmd2")
|
|
assert.Equal(t, 2, getResetOriginCallCount())
|
|
}
|
|
|
|
// A render that takes long enough to start takes the view over to say
|
|
// "loading...", which means blanking 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 (a background refresh, say) would otherwise blank the view and
|
|
// paint the same thing back, a visible flicker for nothing.
|
|
func TestLoadingIndicatorOnlyTakesOverForNewContent(t *testing.T) {
|
|
var beforeStartCount atomic.Int32
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
io.Discard,
|
|
func() { beforeStartCount.Add(1) },
|
|
func() {},
|
|
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.EqualValues(t, 0, beforeStartCount.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.EqualValues(t, 0, beforeStartCount.Load())
|
|
|
|
// Different content, though, is worth taking the view over for.
|
|
newContent := startStalledTask("cmd2")
|
|
defer close(newContent.unblock)
|
|
assert.Eventually(t,
|
|
func() bool { return beforeStartCount.Load() == 1 },
|
|
2*time.Second, 10*time.Millisecond)
|
|
}
|
|
|
|
// A pending restore takes the first paint over: it says when enough of the new
|
|
// content has arrived to show the position it remembers, and does the swap itself so
|
|
// that it can look for that position while the previous content is still displayed.
|
|
// Having put the view where the user left it, it also keeps the scroll reset that new
|
|
// content would otherwise get.
|
|
func TestNewCmdTaskRestore(t *testing.T) {
|
|
writer := bytes.NewBuffer(nil)
|
|
linesWritten := func() int { return strings.Count(writer.String(), "\n") }
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
|
|
swapped := false
|
|
applyCount := 0
|
|
applyAtLines := -1
|
|
swappedBeforeApply := false
|
|
swappedByApply := false
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
writer,
|
|
func() {}, // beforeStart
|
|
func() {}, // refreshView
|
|
func() {}, // onEndOfInput
|
|
resetOrigin,
|
|
func() {}, // beginRender
|
|
func() { swapped = true }, // 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 },
|
|
)
|
|
|
|
manager.SetRestoreForNextTask(&RenderRestore{
|
|
// Ready once five lines have loaded — well before the view is filled (30).
|
|
FirstPaintReady: func() bool { return linesWritten() >= 5 },
|
|
Apply: func(swapIn func()) bool {
|
|
applyCount++
|
|
applyAtLines = linesWritten()
|
|
swappedBeforeApply = swappedBeforeApply || swapped
|
|
swapIn()
|
|
swappedByApply = swapped
|
|
return true
|
|
},
|
|
})
|
|
|
|
done := make(chan struct{})
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 50}
|
|
}
|
|
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 30, nil}, func() { close(done) }), "cmd")
|
|
<-done
|
|
|
|
assert.Equal(t, 1, applyCount, "Apply should run exactly once")
|
|
assert.False(t, swappedBeforeApply, "the off-screen render should not be swapped in before Apply runs")
|
|
assert.True(t, swappedByApply, "Apply should swap the off-screen render in via swapIn")
|
|
// The first paint was driven by the restore, not by having read enough lines to
|
|
// fill the view.
|
|
assert.GreaterOrEqual(t, applyAtLines, 5)
|
|
assert.Less(t, applyAtLines, 30)
|
|
assert.Equal(t, 0, getResetOriginCallCount(), "a restore that placed the view leaves the scroll alone")
|
|
}
|
|
|
|
// A restore that never finds what it is looking for keeps the task reading to the
|
|
// end of its input, since the line might have been anywhere in it. Once there is no
|
|
// more content to hope for, the render is revealed with the scroll reset that new
|
|
// content is owed.
|
|
func TestNewCmdTaskRestoreThatFindsNothing(t *testing.T) {
|
|
writer := bytes.NewBuffer(nil)
|
|
linesWritten := func() int { return strings.Count(writer.String(), "\n") }
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
|
|
applyCount := 0
|
|
swappedAtLines := -1
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
writer,
|
|
func() {}, // beforeStart
|
|
func() {}, // refreshView
|
|
func() {}, // onEndOfInput
|
|
resetOrigin,
|
|
func() {}, // beginRender
|
|
func() { swappedAtLines = linesWritten() },
|
|
func() gocui.Task { return gocui.NewFakeTask() },
|
|
// no UI thread in the test; run the view mutations inline
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
manager.SetRestoreForNextTask(&RenderRestore{
|
|
FirstPaintReady: func() bool { return false },
|
|
Apply: func(swapIn func()) bool {
|
|
applyCount++
|
|
swapIn()
|
|
return false
|
|
},
|
|
})
|
|
|
|
done := make(chan struct{})
|
|
start := func() (Cmd, io.Reader) {
|
|
// not actually starting this because it's not necessary
|
|
return ExecCmd{Cmd: exec.Command("blah")}, &BlankLineReader{totalLinesToYield: 50}
|
|
}
|
|
_ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 30, nil}, func() { close(done) }), "cmd")
|
|
<-done
|
|
|
|
assert.Equal(t, 1, applyCount, "Apply should still run, to swap the render in")
|
|
assert.Equal(t, 50, swappedAtLines, "the whole input should be read before giving up on the restore")
|
|
assert.Equal(t, 1, getResetOriginCallCount(), "new content the restore couldn't place starts at the top")
|
|
}
|
|
|
|
// The task a restore was installed for can be stopped and replaced before it ever
|
|
// paints — a background refresh landing right after the key was pressed. The
|
|
// replacement renders the same content, so it is the one that owes the user their
|
|
// position.
|
|
func TestRestoreSurvivesTaskReplacement(t *testing.T) {
|
|
var applyCount atomic.Int32
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
io.Discard,
|
|
func() {},
|
|
func() {},
|
|
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 },
|
|
)
|
|
|
|
manager.SetRestoreForNextTask(&RenderRestore{
|
|
FirstPaintReady: func() bool { return false },
|
|
Apply: func(swapIn func()) bool {
|
|
applyCount.Add(1)
|
|
swapIn()
|
|
return true
|
|
},
|
|
})
|
|
|
|
startTask := func(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), "cmd")
|
|
}
|
|
|
|
// The task the restore was installed for stalls before it can paint.
|
|
stalled := BlockingLineReader{
|
|
linesToYield: 3,
|
|
blocked: make(chan struct{}),
|
|
unblock: make(chan struct{}),
|
|
}
|
|
defer close(stalled.unblock)
|
|
startTask(&stalled, nil)
|
|
<-stalled.blocked
|
|
|
|
done := make(chan struct{})
|
|
startTask(&BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
|
|
<-done
|
|
|
|
assert.EqualValues(t, 1, applyCount.Load(), "the replacement should apply the restore the stopped task couldn't")
|
|
}
|
|
|
|
// A task told to keep the scroll position renders the content the view is showing
|
|
// under another command — the same diff with more context around it, say — so it
|
|
// neither resets the scroll nor blanks the view to say "loading...", both of which are
|
|
// for content the user hasn't seen.
|
|
func TestKeepScrollPositionForNextTask(t *testing.T) {
|
|
var beforeStartCount atomic.Int32
|
|
resetOrigin, getResetOriginCallCount := getCounter()
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(),
|
|
io.Discard,
|
|
func() { beforeStartCount.Add(1) },
|
|
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 },
|
|
)
|
|
|
|
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)
|
|
}
|
|
runTaskToCompletion := func(key string) {
|
|
done := make(chan struct{})
|
|
startTask(key, &BlankLineReader{totalLinesToYield: 3}, func() { close(done) })
|
|
<-done
|
|
}
|
|
|
|
// Content the view wasn't showing, to have something to keep the position in.
|
|
runTaskToCompletion("cmd1")
|
|
assert.Equal(t, 1, getResetOriginCallCount())
|
|
|
|
manager.SetKeepScrollPositionForNextTask()
|
|
runTaskToCompletion("cmd2")
|
|
assert.Equal(t, 1, getResetOriginCallCount(), "the same content under another command keeps its position")
|
|
|
|
// And the request rides one task only: the next different command is a different
|
|
// diff as far as anyone knows.
|
|
runTaskToCompletion("cmd3")
|
|
assert.Equal(t, 2, getResetOriginCallCount())
|
|
|
|
// The loading indicator goes by the same question, so it stays out of the way too.
|
|
manager.SetKeepScrollPositionForNextTask()
|
|
stalled := BlockingLineReader{
|
|
blocked: make(chan struct{}),
|
|
unblock: make(chan struct{}),
|
|
}
|
|
defer close(stalled.unblock)
|
|
startTask("cmd4", &stalled, nil)
|
|
<-stalled.blocked
|
|
time.Sleep(500 * time.Millisecond)
|
|
assert.EqualValues(t, 0, beforeStartCount.Load())
|
|
}
|
|
|
|
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() {},
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A read request is answered by the task that serves it calling the request's Then.
|
|
// This checks that requests still waiting when the task is stopped are answered too,
|
|
// which is what happens when a re-render replaces the task.
|
|
func TestQueuedReadRequestsAreAnsweredWhenTheTaskStops(t *testing.T) {
|
|
noop := func() {}
|
|
task := gocui.NewFakeTask()
|
|
|
|
// A pipe the task blocks on, so that the requests are still waiting when it stops.
|
|
pipeReader, pipeWriter := io.Pipe()
|
|
defer pipeWriter.Close()
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(), bytes.NewBuffer(nil), noop, noop, noop, noop, noop, noop,
|
|
func() gocui.Task { return task },
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
stop := make(chan struct{})
|
|
fn := manager.NewCmdTask(
|
|
func() (Cmd, io.Reader) { return ExecCmd{Cmd: exec.Command("true")}, pipeReader },
|
|
"", LinesToRead{Total: 1, InitialRefreshAfter: -1}, noop)
|
|
go func() { _, _ = pipeWriter.Write([]byte("first line\n")) }()
|
|
go func() { _ = fn(TaskOpts{Stop: stop, InitialContentLoaded: noop}) }()
|
|
// Let the task start and read the line it was asked for, so that the requests
|
|
// below are handed to a task that is waiting for them.
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
answered := atomic.Int32{}
|
|
manager.ReadToEnd(func() { answered.Add(1) })
|
|
manager.ReadToEnd(func() { answered.Add(1) })
|
|
|
|
// Let the first request be picked up and block on the pipe, then stop the task.
|
|
time.Sleep(50 * time.Millisecond)
|
|
close(stop)
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
assert.EqualValues(t, 2, answered.Load())
|
|
}
|
|
|
|
// The callers of ReadToEnd hand their follow-up work to the UI thread from then, and
|
|
// that work holds a task of its own from the moment it is enqueued. If the task that
|
|
// ReadToEnd holds were done before then runs, lazygit would count as idle for a moment
|
|
// in between, and an integration test would carry on before the follow-up work is done.
|
|
func TestReadToEndHoldsItsTaskUntilThenReturns(t *testing.T) {
|
|
noop := func() {}
|
|
task := gocui.NewFakeTask()
|
|
|
|
manager := NewViewBufferManager(
|
|
utils.NewDummyLog(), bytes.NewBuffer(nil), noop, noop, noop, noop, noop, noop,
|
|
func() gocui.Task { return task },
|
|
func(f func()) error { f(); return nil },
|
|
)
|
|
|
|
// With no command task serving read requests, ReadToEnd answers right away.
|
|
var statusDuringThen gocui.TaskStatus
|
|
manager.ReadToEnd(func() { statusDuringThen = task.Status() })
|
|
|
|
assert.Equal(t, gocui.TaskStatusBusy, statusDuringThen)
|
|
assert.Equal(t, gocui.TaskStatusDone, task.Status())
|
|
}
|