mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 23:56:24 -04:00
When re-rendering content the user was already scrolled into, we want the saved scroll position applied exactly when the real content first paints — not before. Setting the origin up front instead paints it onto whatever placeholder is currently in the view (e.g. the shorter buffer CopyContent left there), which flickers: either a blank frame past the placeholder's end, or a jump to the top when the task resets the origin at startup. Add ViewBufferManager.ScrollToOriginYForNextTask: the next cmd/pty task then (a) does not reset the view to the top at startup even though the command key changed, so the placeholder stays put, (b) sizes its initial read to the saved position so enough content is loaded to fill the view there, and (c) scrolls to it as part of the first refresh, in the same paint that shows the real content. This is the cmd/pty analogue of RenderStringWithScrollTask. No caller sets it yet, so this is behaviour-preserving on its own. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
6.9 KiB
Go
273 lines
6.9 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()
|
|
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) 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"},
|
|
{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) 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
|
|
}{
|
|
{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
|
|
}
|
|
|
|
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() {},
|
|
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)
|
|
}
|
|
}
|
|
}
|