jesseduffield.lazygit/pkg/tasks/tasks.go
Stefan Haller cbf220c497 Read lines based on scroll position instead of a fixed per-notch delta
When scrolling a lazy-loaded view (a diff in the main view, the command
log, etc.), we top up the view's line buffer by reading more lines from
the still-running task. This was driven by asking the task to read a
fixed number of *additional* lines on every scroll event, which had two
problems:

- It was decoupled from the scroll position. Scrolling down, back up,
  and down again re-read lines that had already been read, so the buffer
  crept towards the end of the input regardless of where the user
  actually scrolled.

- A single wheel notch only bought a single notch worth of runway, so
  fast scrolling constantly outran the reader and had to wait for the
  next read (and re-render) on every notch.

Make ReadLines take an absolute target total instead of a delta: the
task tracks how many lines it has read and only reads the shortfall, so
requests are idempotent. Callers now ask to fill the viewport at the
current scroll position plus a few screenfuls of read-ahead, which gives
scrolling enough runway to stay smooth.

The four call sites all wanted the same "fill this view" computation, so
consolidate them into a single ReadLinesToFillView helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:32:35 +02:00

511 lines
14 KiB
Go

package tasks
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"sync"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
// Cmd abstracts over a started external process. *exec.Cmd satisfies the bulk
// of it via ExecCmd, but pty implementations can supply their own types — on
// Windows, ConPTY has to spawn via CreateProcess directly and can't use
// *exec.Cmd (see golang/go#62708).
type Cmd interface {
Wait() error
String() string
GetProcess() *os.Process
}
// ExecCmd adapts *exec.Cmd to Cmd.
type ExecCmd struct {
*exec.Cmd
}
func (c ExecCmd) GetProcess() *os.Process {
return c.Process
}
// This file revolves around running commands that will be output to the main panel
// in the gui. If we're flicking through the commits panel, we want to invoke a
// `git show` command for each commit, but we don't want to read the entire output
// at once (because that would slow things down); we just want to fill the panel
// and then read more as the user scrolls down. We also want to ensure that we're only
// ever running one `git show` command at time, and that we only have one command
// writing its output to the main panel at a time.
const THROTTLE_TIME = time.Millisecond * 30
// we use this to check if the system is under stress right now. Hopefully this makes sense on other machines
const COMMAND_START_THRESHOLD = time.Millisecond * 10
type ViewBufferManager struct {
// this blocks until the task has been properly stopped
stopCurrentTask func()
// this is what we write the output of the task to. It's typically a view
writer io.Writer
waitingMutex deadlock.Mutex
taskIDMutex deadlock.Mutex
Log *logrus.Entry
newTaskID int
readLines chan LinesToRead
taskKey string
onNewKey func()
// beforeStart is the function that is called before starting a new task
beforeStart func()
refreshView func()
onEndOfInput func()
// see docs/dev/Busy.md
// A gocui task is not the same thing as the tasks defined in this file.
// A gocui task simply represents the fact that lazygit is busy doing something,
// whereas the tasks in this file are about rendering content to a view.
newGocuiTask func() gocui.Task
// if the user flicks through a heap of items, with each one
// spawning a process to render something to the main view,
// it can slow things down quite a bit. In these situations we
// want to throttle the spawning of processes.
throttle bool
}
type LinesToRead struct {
// The total number of lines the task should have read once this request is
// satisfied. This is an absolute count from the start of the task, not a
// delta: the task keeps track of how many lines it has already read and only
// reads the shortfall, so a request for a total at or below what has already
// been read reads nothing. -1 means read all the way to the end.
Total int
// Number of lines after which we have read enough to fill the view, and can
// do an initial refresh. Only set for the initial read request; -1 for
// subsequent requests.
InitialRefreshAfter int
// Function to call after reading the lines is done
Then func()
}
func (self *ViewBufferManager) GetTaskKey() string {
return self.taskKey
}
func NewViewBufferManager(
log *logrus.Entry,
writer io.Writer,
beforeStart func(),
refreshView func(),
onEndOfInput func(),
onNewKey func(),
newGocuiTask func() gocui.Task,
) *ViewBufferManager {
return &ViewBufferManager{
Log: log,
writer: writer,
beforeStart: beforeStart,
refreshView: refreshView,
onEndOfInput: onEndOfInput,
readLines: nil,
onNewKey: onNewKey,
newGocuiTask: newGocuiTask,
}
}
// ReadLines asks the task to ensure it has read at least totalLines lines in
// total. Because the count is absolute rather than a delta, repeated requests
// (e.g. as the user scrolls down, back up, and down again) don't re-read lines
// that have already been read: the task only ever reads the shortfall.
func (self *ViewBufferManager) ReadLines(totalLines int) {
if self.readLines != nil {
go utils.Safe(func() {
self.readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1}
})
}
}
func (self *ViewBufferManager) ReadToEnd(then func()) {
if self.readLines != nil {
go utils.Safe(func() {
self.readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then}
})
} else if then != nil {
then()
}
}
func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix string, linesToRead LinesToRead, onDoneFn func()) func(TaskOpts) error {
return func(opts TaskOpts) error {
var onDoneOnce sync.Once
var onFirstPageShownOnce sync.Once
onFirstPageShown := func() {
onFirstPageShownOnce.Do(func() {
opts.InitialContentLoaded()
})
}
onDone := func() {
if onDoneFn != nil {
onDoneOnce.Do(onDoneFn)
}
onFirstPageShown()
}
if self.throttle {
self.Log.Info("throttling task")
time.Sleep(THROTTLE_TIME)
}
select {
case <-opts.Stop:
onDone()
return nil
default:
}
startTime := time.Now()
cmd, r := start()
timeToStart := time.Since(startTime)
done := make(chan struct{})
go utils.Safe(func() {
select {
case <-done:
// The command finished and did not have to be preemptively stopped before the next command.
// No need to throttle.
self.throttle = false
case <-opts.Stop:
// we use the time it took to start the program as a way of checking if things
// are running slow at the moment. This is admittedly a crude estimate, but
// the point is that we only want to throttle when things are running slow
// and the user is flicking through a bunch of items.
self.throttle = time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD
// Kill the still-running command. The only reason to do this is to save CPU usage
// when flicking through several very long diffs when diff.algorithm = histogram is
// being used, in which case multiple git processes continue to calculate expensive
// diffs in the background even though they have been stopped already.
//
// Unfortunately this will do nothing on Windows, so Windows users will have to live
// with the higher CPU usage.
if err := oscommands.TerminateProcessGracefully(cmd.GetProcess()); err != nil {
self.Log.Errorf("error when trying to terminate cmd task: %v; Command: %v", err, cmd.String())
}
// close the task's stdout pipe (or the pty if we're using one) to make the command terminate
onDone()
}
})
loadingMutex := deadlock.Mutex{}
self.readLines = make(chan LinesToRead, 1024)
scanner := bufio.NewScanner(r)
scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize))
lineChan := make(chan []byte)
lineWrittenChan := make(chan struct{})
// We're reading from the scanner in a separate goroutine because on windows
// if running git through a shim, we sometimes kill the parent process without
// killing its children, meaning the scanner blocks forever. This solution
// leaves us with a dead goroutine, but it's better than blocking all
// rendering to main views.
go utils.Safe(func() {
defer close(lineChan)
for scanner.Scan() {
select {
case <-opts.Stop:
return
case lineChan <- scanner.Bytes():
// We need to confirm the data has been fed into the view before we
// pull more from the scanner because the scanner uses the same backing
// array and we don't want to be mutating that while it's being written
<-lineWrittenChan
}
}
if err := scanner.Err(); err != nil {
self.Log.Error(err)
}
})
loaded := false
go utils.Safe(func() {
ticker := time.NewTicker(time.Millisecond * 200)
defer ticker.Stop()
select {
case <-opts.Stop:
return
case <-ticker.C:
loadingMutex.Lock()
if !loaded {
self.beforeStart()
_, _ = self.writer.Write([]byte("loading..."))
self.refreshView()
}
loadingMutex.Unlock()
}
})
go utils.Safe(func() {
isViewStale := true
writeToView := func(content []byte) {
isViewStale = true
_, _ = self.writer.Write(content)
}
refreshViewIfStale := func() {
if isViewStale {
self.refreshView()
isViewStale = false
}
}
// Go's select picks randomly among ready cases, so once opts.Stop is
// closed the selects below could still service a ready data channel
// instead of bailing. Check stop explicitly first to give it priority:
// a task that's been stopped (it's being replaced by a newer one) must
// not touch the view here — beforeStart clears it and the prefix gets
// written, clobbering what the incoming task is about to render.
stopped := func() bool {
select {
case <-opts.Stop:
return true
default:
return false
}
}
// The total number of lines we have read so far. Requests specify an
// absolute target total (see LinesToRead.Total), so we compare against
// this to work out how many more lines, if any, we still need to read.
linesRead := 0
outer:
for {
if stopped() {
break outer
}
select {
case <-opts.Stop:
break outer
case linesToRead := <-self.readLines:
callThen := func() {
if linesToRead.Then != nil {
linesToRead.Then()
}
}
for linesToRead.Total == -1 || linesRead < linesToRead.Total {
if stopped() {
callThen()
break outer
}
var ok bool
var line []byte
select {
case <-opts.Stop:
callThen()
break outer
case line, ok = <-lineChan:
// process line below
}
loadingMutex.Lock()
if !loaded {
self.beforeStart()
if prefix != "" {
writeToView([]byte(prefix))
}
loaded = true
}
loadingMutex.Unlock()
if !ok {
// if we're here then there's nothing left to scan from the source
// so we're at the EOF and can flush the stale content
self.onEndOfInput()
callThen()
break outer
}
writeToView(append(line, '\n'))
lineWrittenChan <- struct{}{}
linesRead++
if linesRead == linesToRead.InitialRefreshAfter {
// We have read enough lines to fill the view, so do a first refresh
// here to show what we have. Continue reading and refresh again at
// the end to make sure the scrollbar has the right size.
refreshViewIfStale()
}
}
refreshViewIfStale()
onFirstPageShown()
callThen()
}
}
self.readLines = nil
refreshViewIfStale()
select {
case <-opts.Stop:
// If we stopped the task, don't block waiting for it; this could cause a delay if
// the process takes a while until it actually terminates. We still want to call
// Wait to reclaim any resources, but do it on a background goroutine, and ignore
// any errors.
go func() { _ = cmd.Wait() }()
default:
if err := cmd.Wait(); err != nil {
self.Log.Errorf("Unexpected error when running cmd task: %v; Failed command: %v", err, cmd.String())
}
}
// calling this here again in case the program ended on its own accord
onDone()
close(done)
close(lineWrittenChan)
})
self.readLines <- linesToRead
<-done
return nil
}
}
// Close closes the task manager, killing whatever task may currently be running
func (self *ViewBufferManager) Close() {
if self.stopCurrentTask == nil {
return
}
c := make(chan struct{})
go utils.Safe(func() {
self.stopCurrentTask()
c <- struct{}{}
})
select {
case <-c:
return
case <-time.After(3 * time.Second):
fmt.Println("cannot kill child process")
}
}
// different kinds of tasks:
// 1) command based, where the manager can be asked to read more lines, but the command can be killed
// 2) string based, where the manager can also be asked to read more lines
type TaskOpts struct {
// Channel that tells the task to stop, because another task wants to run.
Stop chan struct{}
// Only for tasks which are long-running, where we read more lines sporadically.
// We use this to keep track of when a user's action is complete (i.e. all views
// have been refreshed to display the results of their action)
InitialContentLoaded func()
}
func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error {
gocuiTask := self.newGocuiTask()
var completeTaskOnce sync.Once
completeGocuiTask := func() {
completeTaskOnce.Do(func() {
gocuiTask.Done()
})
}
// Assign the taskID synchronously so it reflects NewTask call order
// rather than the order in which the spawned goroutines happen to be
// scheduled. Otherwise two NewTask calls in quick succession can have
// their goroutines race, with the later-called task ending up with the
// lower taskID and losing the staleness check below.
self.taskIDMutex.Lock()
self.newTaskID++
taskID := self.newTaskID
self.taskIDMutex.Unlock()
go utils.Safe(func() {
defer completeGocuiTask()
self.taskIDMutex.Lock()
// Bail out before touching shared view state if a newer task has
// already been queued: if we ran onNewKey here we'd reset the view
// for a task that's about to exit, potentially wiping output the
// winning task has already written.
if taskID < self.newTaskID {
self.taskIDMutex.Unlock()
return
}
if self.GetTaskKey() != key && self.onNewKey != nil {
self.onNewKey()
}
self.taskKey = key
self.taskIDMutex.Unlock()
self.waitingMutex.Lock()
// Re-check staleness after acquiring waitingMutex: a newer task
// may have arrived while we were blocked here.
self.taskIDMutex.Lock()
if taskID < self.newTaskID {
self.waitingMutex.Unlock()
self.taskIDMutex.Unlock()
return
}
self.taskIDMutex.Unlock()
if self.stopCurrentTask != nil {
self.stopCurrentTask()
}
self.readLines = nil
stop := make(chan struct{})
notifyStopped := make(chan struct{})
var once sync.Once
onStop := func() {
close(stop)
<-notifyStopped
}
self.stopCurrentTask = func() { once.Do(onStop) }
self.waitingMutex.Unlock()
if err := f(TaskOpts{Stop: stop, InitialContentLoaded: completeGocuiTask}); err != nil {
self.Log.Error(err) // might need an onError callback
}
close(notifyStopped)
})
return nil
}