mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Escaping to a focused main view restored the selection by scheduling, on the next UI tick, a ReadToEnd whose callback re-selected the saved line. But ReadToEnd fires its callback synchronously when the manager has no live read channel, and the re-render task triggered by the push creates that channel later, inside its own goroutine (after stopping the previous task). If the UI tick won that race, the restore ran before any content was loaded, FocusPoint no-oped against the unloaded line, and the selection was silently dropped — intermittently, and more often under load. Thread the restore through the task instead: a thenForNextTask hook on the buffer manager, folded into the next cmd/pty task's initial-read Then, mirroring scrollToOriginYForNextTask. It runs once the task has read enough to place the selection, and can't fire before the task exists. The scroll restore already applies at the task's first paint, which precedes the initial read's end, so the origin is in place when the selection is restored. This needs interactive verification (LAZYGIT_SLOW_RENDER + a real pager); see focused-main-view-notes.md §13. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
699 lines
23 KiB
Go
699 lines
23 KiB
Go
package tasks
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"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
|
|
// Terminate makes the process stop early, as gracefully as the platform
|
|
// allows. It doesn't wait for the process to exit.
|
|
Terminate() error
|
|
}
|
|
|
|
// ExecCmd adapts *exec.Cmd to Cmd.
|
|
type ExecCmd struct {
|
|
*exec.Cmd
|
|
}
|
|
|
|
// Terminate sends SIGTERM on Unix. On Windows it does nothing, so a stopped
|
|
// command keeps running until it next writes to its (by then closed) output
|
|
// pipe.
|
|
func (c ExecCmd) Terminate() error {
|
|
return oscommands.TerminateProcessGracefully(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
|
|
// The channel by which the currently-running task is told to read more
|
|
// lines (e.g. as the user scrolls). Held in an atomic because it's swapped
|
|
// out as tasks come and go while ReadLines/ReadToEnd read it from the UI
|
|
// thread; nil when no task is running.
|
|
readLines atomic.Pointer[chan LinesToRead]
|
|
taskKey string
|
|
onNewKey func()
|
|
|
|
// When non-nil, the next cmd/pty task restores this scroll position. Used
|
|
// when re-rendering content the user was already scrolled into (e.g. when
|
|
// returning to a focused main view on escape). It has two effects:
|
|
//
|
|
// - The task does not reset the view's origin to the top at start, even if
|
|
// its command key differs from the previous task's. This keeps the
|
|
// placeholder that CopyContent left in the view showing at its current
|
|
// scroll position (i.e. "as if nothing changed") until the real content
|
|
// is ready, rather than flicking it to the top.
|
|
// - The task sizes its initial read to this position and scrolls there as
|
|
// part of its first refresh, so the saved scroll is applied in the same
|
|
// paint that shows the real content.
|
|
//
|
|
// Cleared once the next task has started.
|
|
scrollToOriginYForNextTask *int
|
|
|
|
// When non-nil, run once after the next cmd/pty task has read enough of its
|
|
// content (the end of its initial read). Used to restore state that depends
|
|
// on the re-rendered content being loaded — e.g. the selection in a focused
|
|
// main view we're returning to on escape — by riding the task's own
|
|
// lifecycle rather than a separate post-hoc ReadToEnd. A ReadToEnd issued
|
|
// right after triggering the re-render can fire synchronously, because the
|
|
// freshly-created task's read channel isn't live yet (it is created inside
|
|
// the task goroutine, after the previous task has been stopped); that would
|
|
// run the restore before any content is loaded and silently drop it.
|
|
//
|
|
// Cleared once the next task has started.
|
|
thenForNextTask func()
|
|
|
|
// Whether a command task is currently reading content into the view. While
|
|
// this is true the content is still growing, so callers (e.g. the layout)
|
|
// must not clamp the view's scroll position to the amount loaded so far.
|
|
loading atomic.Bool
|
|
|
|
// 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
|
|
|
|
// Runs f on the UI thread and blocks until it has completed. All mutations
|
|
// of the view happen through this, so that the view is only ever touched on
|
|
// the UI thread (where it is also laid out and drawn), never on the task's
|
|
// own goroutine.
|
|
onUIThread func(f func() error) error
|
|
|
|
// 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. Atomic because it's set
|
|
// from one task's stop goroutine and read when the next task starts.
|
|
throttle atomic.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
|
|
|
|
// When set, called once, just before the view is first refreshed, to scroll
|
|
// it to a saved position. Used so that content the user was scrolled into is
|
|
// painted at the saved scroll position the first time it appears, rather than
|
|
// at the top. Only set for the initial read request.
|
|
ApplyInitialScroll func()
|
|
|
|
// 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,
|
|
onUIThread func(f func() error) error,
|
|
) *ViewBufferManager {
|
|
return &ViewBufferManager{
|
|
Log: log,
|
|
writer: writer,
|
|
beforeStart: beforeStart,
|
|
refreshView: refreshView,
|
|
onEndOfInput: onEndOfInput,
|
|
onNewKey: onNewKey,
|
|
newGocuiTask: newGocuiTask,
|
|
onUIThread: onUIThread,
|
|
}
|
|
}
|
|
|
|
// 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 ch := self.readLines.Load(); ch != nil {
|
|
readLines := *ch
|
|
go utils.Safe(func() {
|
|
readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ScrollToOriginYForNextTask makes the next cmd/pty task restore the given
|
|
// scroll position instead of rendering at the top. Call this right before
|
|
// triggering a re-render of content the view is already scrolled into (e.g.
|
|
// when returning to a focused main view on escape). See the field doc for the
|
|
// two effects this has. It is cleared once the next task starts.
|
|
func (self *ViewBufferManager) ScrollToOriginYForNextTask(originY int) {
|
|
self.scrollToOriginYForNextTask = &originY
|
|
}
|
|
|
|
// GetScrollToOriginYForNextTask returns the scroll position requested by a
|
|
// preceding ScrollToOriginYForNextTask call, or nil if none. It does not clear
|
|
// it; the task clears it when it starts.
|
|
func (self *ViewBufferManager) GetScrollToOriginYForNextTask() *int {
|
|
return self.scrollToOriginYForNextTask
|
|
}
|
|
|
|
// ThenForNextTask makes the next cmd/pty task run the given function once it has
|
|
// read enough of its content (the end of its initial read). Call this right
|
|
// before triggering a re-render whose loaded content the function depends on.
|
|
// See the field doc for why this is preferable to a separate ReadToEnd. It is
|
|
// cleared once the next task starts.
|
|
func (self *ViewBufferManager) ThenForNextTask(then func()) {
|
|
self.thenForNextTask = then
|
|
}
|
|
|
|
// GetThenForNextTask returns the function requested by a preceding
|
|
// ThenForNextTask call, or nil if none. It does not clear it; the task clears it
|
|
// when it starts.
|
|
func (self *ViewBufferManager) GetThenForNextTask() func() {
|
|
return self.thenForNextTask
|
|
}
|
|
|
|
// IsLoading reports whether a command task is currently reading content into the
|
|
// view, meaning the content is still growing.
|
|
func (self *ViewBufferManager) IsLoading() bool {
|
|
return self.loading.Load()
|
|
}
|
|
|
|
// StartLoading marks the view as loading content. It must be called
|
|
// synchronously when a command/pty task is started, before the task's goroutine
|
|
// runs, so that a layout pass happening in between doesn't clamp the scroll
|
|
// position to the not-yet-loaded content. It is cleared when the task reaches
|
|
// the end of its input.
|
|
func (self *ViewBufferManager) StartLoading() {
|
|
self.loading.Store(true)
|
|
}
|
|
|
|
func (self *ViewBufferManager) ReadToEnd(then func()) {
|
|
if ch := self.readLines.Load(); ch != nil {
|
|
readLines := *ch
|
|
go utils.Safe(func() {
|
|
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.Load() {
|
|
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.Store(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.Store(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.
|
|
if err := cmd.Terminate(); 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{}
|
|
|
|
readLines := make(chan LinesToRead, 1024)
|
|
self.readLines.Store(&readLines)
|
|
|
|
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
|
|
|
|
// The initial read request carries an optional scroll-to function (see
|
|
// LinesToRead.ApplyInitialScroll). We apply it exactly once, right before
|
|
// the view is first refreshed, so that content the user was scrolled into
|
|
// is painted at the saved position the first time it appears.
|
|
initialScroll := linesToRead.ApplyInitialScroll
|
|
var applyInitialScrollOnce sync.Once
|
|
applyInitialScroll := func() {
|
|
if initialScroll != nil {
|
|
applyInitialScrollOnce.Do(initialScroll)
|
|
}
|
|
}
|
|
|
|
// Set LAZYGIT_SLOW_RENDER=<milliseconds> to sleep that long after each
|
|
// line is written to the view, stretching async loads out so the frames
|
|
// of a re-render become visible. Useful for debugging scroll/flicker
|
|
// behaviour; has no effect when the variable is unset.
|
|
var slowRenderPerLine time.Duration
|
|
if v := os.Getenv("LAZYGIT_SLOW_RENDER"); v != "" {
|
|
if ms, err := strconv.Atoi(v); err == nil {
|
|
slowRenderPerLine = time.Duration(ms) * time.Millisecond
|
|
}
|
|
}
|
|
|
|
outer:
|
|
for {
|
|
if stopped() {
|
|
break outer
|
|
}
|
|
select {
|
|
case <-opts.Stop:
|
|
break outer
|
|
case linesToRead := <-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. Apply the
|
|
// saved scroll first (if any) so that onEndOfInput clamps it back
|
|
// into range when the new content turned out shorter than expected.
|
|
// onEndOfInput reads the view's dimensions (to decide
|
|
// whether to scroll) and sets the origin, both of which
|
|
// are UI-thread-only, so run it there.
|
|
_ = self.onUIThread(func() error {
|
|
applyInitialScroll()
|
|
self.onEndOfInput()
|
|
return nil
|
|
})
|
|
// The content is fully loaded now, so it's safe again for the
|
|
// layout to clamp the scroll position to it. We deliberately
|
|
// don't clear this when stopped (rather than EOF'd), because that
|
|
// means a newer task is taking over and is still loading.
|
|
self.loading.Store(false)
|
|
callThen()
|
|
// Any read requests that were queued while we were reading are
|
|
// now trivially satisfied, since we've read everything. Fire
|
|
// their callbacks instead of dropping them when we break out of
|
|
// the loop below (and nil out readLines).
|
|
drain:
|
|
for {
|
|
select {
|
|
case queued := <-*self.readLines.Load():
|
|
if queued.Then != nil {
|
|
queued.Then()
|
|
}
|
|
default:
|
|
break drain
|
|
}
|
|
}
|
|
break outer
|
|
}
|
|
writeToView(append(line, '\n'))
|
|
lineWrittenChan <- struct{}{}
|
|
linesRead++
|
|
|
|
if slowRenderPerLine > 0 {
|
|
time.Sleep(slowRenderPerLine)
|
|
}
|
|
|
|
if linesRead == linesToRead.InitialRefreshAfter {
|
|
// We have read enough lines to fill the view, so do a first refresh
|
|
// here to show what we have. Apply the saved scroll first (if any)
|
|
// so the first paint already lands at it. Continue reading and
|
|
// refresh again at the end to make sure the scrollbar has the right
|
|
// size.
|
|
applyInitialScroll()
|
|
refreshViewIfStale()
|
|
}
|
|
}
|
|
refreshViewIfStale()
|
|
onFirstPageShown()
|
|
callThen()
|
|
}
|
|
}
|
|
|
|
self.readLines.Store(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)
|
|
})
|
|
|
|
readLines <- linesToRead
|
|
|
|
<-done
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Close closes the task manager, killing whatever task may currently be running
|
|
func (self *ViewBufferManager) Close() {
|
|
// stopCurrentTask is written by NewTask's goroutine under waitingMutex (and
|
|
// so is the sync.Once it closes over), so read it under the lock and call
|
|
// the captured value; a task starting on shutdown must not race us here.
|
|
self.waitingMutex.Lock()
|
|
stopCurrentTask := self.stopCurrentTask
|
|
self.waitingMutex.Unlock()
|
|
|
|
if stopCurrentTask == nil {
|
|
return
|
|
}
|
|
|
|
c := make(chan struct{})
|
|
|
|
go utils.Safe(func() {
|
|
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 reset the view here we'd do it 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
|
|
}
|
|
|
|
// Reset the origin to the top when the command changed, unless a caller
|
|
// asked us to restore a scroll position: in that case we keep the
|
|
// placeholder showing at its current scroll until the task scrolls to the
|
|
// saved position as part of its first paint (see scrollToOriginYForNextTask).
|
|
resetOrigin := self.GetTaskKey() != key && self.onNewKey != nil && self.scrollToOriginYForNextTask == nil
|
|
self.scrollToOriginYForNextTask = nil
|
|
self.thenForNextTask = nil
|
|
self.taskKey = key
|
|
|
|
self.taskIDMutex.Unlock()
|
|
|
|
if resetOrigin {
|
|
// onNewKey resets the view's scroll origin, which is view state the
|
|
// UI thread reads while laying out and drawing, so do it there. This
|
|
// must happen after releasing taskIDMutex: it blocks until the UI
|
|
// thread runs it, and a NewTask call on the UI thread takes
|
|
// taskIDMutex, so holding it here would deadlock.
|
|
_ = self.onUIThread(func() error {
|
|
self.onNewKey()
|
|
return nil
|
|
})
|
|
}
|
|
|
|
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.Store(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
|
|
}
|