From 8580c78cc020e79d4bfd6805f76eeadc973c4df5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:57:07 +0200 Subject: [PATCH] Derive sync vs async refresh from the calling thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a refresh should block or run in the background was controlled by the Mode field, but that always lined up with the calling thread: a UI-thread Refresh must not block the UI, while a RefreshFromWorker runs on a worker where blocking is exactly what we want. Now that Then and BatchUIUpdates work regardless of that choice, drop Mode from the decision and key it off calledFromWorker instead: - Refresh (UI thread) runs its scopes and the finishing step (wait, batch flush, Then) on workers, so the caller returns immediately — what ASYNC used to mean. - RefreshFromWorker runs them on the calling worker, blocking it until everything is done — what SYNC used to mean. Demos keep taking the blocking, inline path so everything still lands in one deterministic frame. In practice this flips the handful of RefreshFromWorker calls that passed ASYNC — they now block their worker until the refresh finishes, keeping the waiting-status spinner up until the UI actually updates — and the many UI-thread refreshes that defaulted to SYNC, which no longer freeze the UI thread while the git work runs. Mode now only feeds the log line; the next commit removes it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index dd6d948f7..2482e6015 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -232,10 +232,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr refresh := func(name string, f func()) { wg.Add(1) - // if we're in a demo we don't want any async refreshes because - // everything happens fast and it's better to have everything update - // in the one frame - if !self.c.InDemo() && options.Mode == types.ASYNC { + // A refresh issued from the UI thread must not block it, so its scopes + // run as their own worker tasks and the caller returns immediately (the + // finishing step below is dispatched to a worker too). A refresh issued + // from a worker blocks that worker instead, running its scopes as plain + // goroutines that it joins. In a demo we always take the blocking path + // so everything updates in a single, deterministic frame. + if !self.c.InDemo() && !calledFromWorker { self.onWorker(env.background, func(t gocui.Task) error { defer wg.Done() f() @@ -436,7 +439,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } } - if options.Mode == types.SYNC { + // waitAndFinalize blocks until every scope is done. Run it inline when we're + // already on a worker (or in a demo, for a deterministic single frame); when + // we're on the UI thread, dispatch it to a worker so it doesn't block the UI. + if calledFromWorker || self.c.InDemo() { waitAndFinalize() } else { self.onWorker(env.background, func(t gocui.Task) error {