Run bg-transform callbacks while wait/track-blocked

Background transform results are unrelated to the block; dropping them
would silently lose completed work. Applies both the dedicated
bg-transform-* setters and actions parsed from generic bg-transform
output (inBgCallback). Searches initiated by a bg result are not
waited for.
This commit is contained in:
Junegunn Choi
2026-07-03 00:52:58 +09:00
parent 4111b51527
commit a5d00368a7
3 changed files with 52 additions and 7 deletions
+6
View File
@@ -2322,6 +2322,12 @@ While waiting, user input is ignored, except for keys bound to \fBabort\fR or
default), which cancel the wait and discard the pending actions instead of
performing their usual role.
Asynchronous \fBbg\-transform\-*\fR actions are not affected; their results are
applied as soon as they arrive, even while waiting. Note that \fBwait\fR only
considers searches already in progress or triggered by the preceding actions in
the same binding; a search initiated later by a \fBbg\-transform\fR result is
not waited for.
If the search takes long enough, fzf indicates that it is waiting by dimming the
input, hiding the cursor, and showing \fB(..)\fR on the info line. This visual
feedback is debounced so that quick searches do not cause flickering.
+21 -7
View File
@@ -1892,11 +1892,12 @@ func (t *Terminal) UpdateList(result MatchResult) {
// If waiting, unblock so main loop can execute pending actions.
// Note: we unblock on any final result, not the specific search that
// armed the wait. A stale final from an earlier search can race in
// when searches are dispatched back-to-back: via --listen, or
// 'reload(...)+wait' racing the previous input's final. The worst
// case is an early unblock running the pending actions on the
// previous result set; we accept it rather than thread a per-search
// generation token through the matcher.
// when searches are dispatched back-to-back: via --listen, a
// bg-transform callback applying mid-wait, or 'reload(...)+wait'
// racing the previous input's final. The worst case is an early
// unblock running the pending actions on the previous result set;
// we accept it rather than thread a per-search generation token
// through the matcher.
if t.waitBlocked {
t.unblockWait()
wakeUp = len(t.pendingActions) > 0
@@ -6526,6 +6527,12 @@ func (t *Terminal) Loop() error {
var newCommand *commandSpec
var reloadSync bool
var denylist []int32
// Whether we're running background transform callbacks. Actions parsed
// from their results are allowed to run even while wait/track-blocked,
// because they're the results of processes started before the block.
// Declared here because the callbacks invoke the doActions closure of the
// iteration that scheduled them, not the one executing actAsync.
inBgCallback := false
req := func(evts ...util.EventType) {
for _, event := range evts {
events = append(events, event)
@@ -6756,8 +6763,13 @@ func (t *Terminal) Loop() error {
callback(a.a)
}
}
// Actions that must execute even while wait/track-blocked:
// background transform callbacks (and actions parsed from their
// results) since they're results of processes started before the
// block.
passthrough := inBgCallback || a.t == actAsync
// When wait-blocked, only allow abort/cancel
if t.waitBlocked {
if t.waitBlocked && !passthrough {
if a.t == actAbort || a.t == actCancel {
t.unblockWait()
t.pendingActions = nil
@@ -6766,7 +6778,7 @@ func (t *Terminal) Loop() error {
return true
}
// When track-blocked, only allow abort/cancel and track-disabling actions
if t.trackBlocked && a.t != actToggleTrack && a.t != actToggleTrackCurrent && a.t != actUntrackCurrent {
if t.trackBlocked && !passthrough && a.t != actToggleTrack && a.t != actToggleTrackCurrent && a.t != actUntrackCurrent {
if a.t == actAbort || a.t == actCancel {
t.unblockTrack()
req(reqPrompt, reqInfo)
@@ -6777,11 +6789,13 @@ func (t *Terminal) Loop() error {
switch a.t {
case actIgnore, actStart, actClick:
case actAsync:
inBgCallback = true
for _, callback := range callbacks {
if t.bgVersion == callback.version {
callback.callback()
}
}
inBgCallback = false
case actBecome:
valid, list := t.buildPlusList(a.a, false)
if valid {
+25
View File
@@ -1185,6 +1185,22 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal 1, lines.match_count }
end
def test_wait_action_bg_transform
# A bg-transform result is unrelated to the wait, so it's applied while the
# wait is still blocking rather than dropped. The long read keeps the wait
# blocked so the (instant) bg-transform completes during the block; the
# header must show while '(..)' is still displayed.
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:bg-transform-header(echo hello)+search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && lines.any_include?('hello')) }
end
def test_wait_action_bg_transform_actions
# Actions parsed from a generic bg-transform result are also applied
# while wait-blocked
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:bg-transform(echo change-header:hello)+search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && lines.any_include?('hello')) }
end
def test_wait_action_query_change
# Query-editing actions must also make wait block; accept must run on the
# results of the new query, not the stale ones
@@ -1202,6 +1218,15 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal '> 5', lines[-3] }
end
def test_track_blocked_bg_transform
# A bg-transform result completing while track-blocked is applied, not
# dropped. The header must show while '+T*' is still displayed.
tmux.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:bg-transform-header(echo hello)+reload(sleep 2; seq 100)'", :Enter
tmux.until { |lines| assert_includes lines[-2], '+T' }
tmux.send_keys 'C-r'
tmux.until { |lines| assert(lines.any_include?('+T*') && lines.any_include?('hello')) }
end
def test_clear_selection
tmux.send_keys %(seq 100 | #{FZF} --multi --bind space:clear-selection), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }