mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 23:56:24 -04:00
Operations that show an inline status ("Pushing", "Fast-forwarding",
"Fetching", …) removed it by relying on the async refresh they trigger
to redraw the view after the item operation had been cleared. That
ordering was never guaranteed: the item operation is cleared on the
worker once the operation's function returns, while the refresh redraws
the item from the UI thread whenever its (asynchronous) git work
happens to finish. If the refresh redrew before the clear, the status
was left on screen with no later redraw to remove it, so the branch (or
tag/remote) stayed stuck showing e.g. "Pushing" indefinitely even though
the operation had completed. This is timing-dependent, which is why it
surfaced as rare, hard-to-reproduce reports and as flaky CI failures.
Fix it by re-rendering in stop() right after clearing the operation,
and by making these refreshes synchronous rather than async. Because a
synchronous refresh has already updated the model and queued its own
redraw by the time stop() runs, and UI-thread callbacks run in order,
the redraw we queue here runs last and draws the up-to-date model with
the status removed. An async refresh couldn't give that guarantee: its
model update might not have landed yet, so the redraw could briefly
flash the pre-operation status.
Pull refreshes through the shared CheckMergeOrRebaseAndSelectHeadCommit,
so that helper becomes synchronous too; its only other caller,
RegularMerge, thereby also refreshes synchronously, which is fine: a
synchronous on-worker refresh is what we want anyway.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
4.9 KiB
Go
161 lines
4.9 KiB
Go
package helpers
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/sasha-s/go-deadlock"
|
|
)
|
|
|
|
type InlineStatusHelper struct {
|
|
c *HelperCommon
|
|
|
|
windowHelper *WindowHelper
|
|
contextsWithInlineStatus map[types.ContextKey]*inlineStatusInfo
|
|
mutex deadlock.Mutex
|
|
}
|
|
|
|
func NewInlineStatusHelper(c *HelperCommon, windowHelper *WindowHelper) *InlineStatusHelper {
|
|
return &InlineStatusHelper{
|
|
c: c,
|
|
windowHelper: windowHelper,
|
|
contextsWithInlineStatus: make(map[types.ContextKey]*inlineStatusInfo),
|
|
}
|
|
}
|
|
|
|
type InlineStatusOpts struct {
|
|
Item types.HasUrn
|
|
Operation types.ItemOperation
|
|
ContextKey types.ContextKey
|
|
}
|
|
|
|
type inlineStatusInfo struct {
|
|
refCount int
|
|
stop chan struct{}
|
|
}
|
|
|
|
// A custom task for WithInlineStatus calls; it wraps the original one and
|
|
// hides the status whenever the task is paused, and shows it again when
|
|
// continued.
|
|
type inlineStatusHelperTask struct {
|
|
gocui.Task
|
|
|
|
inlineStatusHelper *InlineStatusHelper
|
|
opts InlineStatusOpts
|
|
}
|
|
|
|
// poor man's version of explicitly saying that struct X implements interface Y
|
|
var _ gocui.Task = inlineStatusHelperTask{}
|
|
|
|
func (self inlineStatusHelperTask) Pause() {
|
|
self.inlineStatusHelper.stop(self.opts)
|
|
self.Task.Pause()
|
|
|
|
self.inlineStatusHelper.renderContext(self.opts.ContextKey)
|
|
}
|
|
|
|
func (self inlineStatusHelperTask) Continue() {
|
|
self.Task.Continue()
|
|
self.inlineStatusHelper.start(self.opts)
|
|
}
|
|
|
|
func (self *InlineStatusHelper) WithInlineStatus(opts InlineStatusOpts, f func(gocui.Task) error) {
|
|
context := self.c.ContextForKey(opts.ContextKey).(types.IListContext)
|
|
view := context.GetView()
|
|
visible := view.Visible && self.windowHelper.TopViewInWindow(context.GetWindowName(), false) == view
|
|
if visible && context.IsItemVisible(opts.Item) {
|
|
self.c.OnWorker(func(task gocui.Task) error {
|
|
// An inline status is just a waiting status rendered on the item
|
|
// rather than in the bottom line, so it gets the same treatment:
|
|
// pause the background routines while we drive the operation. (The
|
|
// off-screen branch below goes through WithWaitingStatus, which
|
|
// already does this.)
|
|
self.c.PauseBackgroundRefreshes(true)
|
|
defer self.c.PauseBackgroundRefreshes(false)
|
|
|
|
self.start(opts)
|
|
defer self.stop(opts)
|
|
|
|
return f(inlineStatusHelperTask{task, self, opts})
|
|
})
|
|
} else {
|
|
message := presentation.ItemOperationToString(opts.Operation, self.c.Tr)
|
|
_ = self.c.WithWaitingStatus(message, func(t gocui.Task) error {
|
|
// We still need to set the item operation, because it might be used
|
|
// for other (non-presentation) purposes
|
|
self.c.State().SetItemOperation(opts.Item, opts.Operation)
|
|
defer self.c.State().ClearItemOperation(opts.Item)
|
|
|
|
return f(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (self *InlineStatusHelper) start(opts InlineStatusOpts) {
|
|
self.c.State().SetItemOperation(opts.Item, opts.Operation)
|
|
|
|
self.mutex.Lock()
|
|
defer self.mutex.Unlock()
|
|
|
|
info := self.contextsWithInlineStatus[opts.ContextKey]
|
|
if info == nil {
|
|
info = &inlineStatusInfo{refCount: 0, stop: make(chan struct{})}
|
|
self.contextsWithInlineStatus[opts.ContextKey] = info
|
|
|
|
go utils.Safe(func() {
|
|
ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate))
|
|
defer ticker.Stop()
|
|
outer:
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
self.renderContext(opts.ContextKey)
|
|
case <-info.stop:
|
|
break outer
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
info.refCount++
|
|
}
|
|
|
|
func (self *InlineStatusHelper) stop(opts InlineStatusOpts) {
|
|
self.mutex.Lock()
|
|
|
|
if info := self.contextsWithInlineStatus[opts.ContextKey]; info != nil {
|
|
info.refCount--
|
|
if info.refCount <= 0 {
|
|
info.stop <- struct{}{}
|
|
delete(self.contextsWithInlineStatus, opts.ContextKey)
|
|
}
|
|
}
|
|
|
|
self.mutex.Unlock()
|
|
|
|
self.c.State().ClearItemOperation(opts.Item)
|
|
|
|
// Re-render the context to remove the inline status now that the operation
|
|
// finished. Any refresh it triggered must be synchronous, not async: by the
|
|
// time we get here a synchronous refresh has already updated the model and
|
|
// queued its own re-render, and since UI-thread callbacks run in order, the
|
|
// render we queue here runs after it and draws the up-to-date model without
|
|
// the inline status. An async refresh might not have updated the model yet,
|
|
// so this render could briefly show the stale, pre-operation model: when
|
|
// pushing a branch, for example, it would flash the old ↑3↓7 ahead/behind
|
|
// counts for a moment before the refresh replaced them with a green
|
|
// checkmark. (Operations that don't refresh at all are fine too: there's
|
|
// nothing stale to show, so this just drops the status.)
|
|
self.renderContext(opts.ContextKey)
|
|
}
|
|
|
|
func (self *InlineStatusHelper) renderContext(contextKey types.ContextKey) {
|
|
self.c.OnUIThreadContentOnly(func() error {
|
|
self.c.ContextForKey(contextKey).HandleRender()
|
|
return nil
|
|
})
|
|
}
|