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); a 'wait' in such output joins the ongoing wait.
Searches initiated by a bg result are not waited for.
This commit is contained in:
Junegunn Choi
2026-07-05 11:07:44 +09:00
parent 8ec443ba40
commit 743930a38d
3 changed files with 66 additions and 9 deletions
+8
View File
@@ -2326,6 +2326,14 @@ default), which cancel the wait and discard the pending actions instead of
performing their usual role. The remaining actions of such a binding still run,
so a binding like \fBesc:cancel+first\fR is possible.
Asynchronous \fBbg\-transform\-*\fR actions are not affected; their results are
applied as soon as they arrive, even while waiting. For the same reason,
\fBwait\fR does not pair with them: in
\fBbg\-transform\-query(...)+wait\fR, the background command completes only
after \fBwait\fR has already been evaluated, so the search its result
eventually triggers is not waited for. Use the synchronous
\fBtransform\-query(...)\fR variant instead when chaining with \fBwait\fR.
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.
+25 -9
View File
@@ -1900,10 +1900,10 @@ func (t *Terminal) UpdateList(result MatchResult) {
t.wait.searching = false
// If waiting, unblock so main loop can execute pending actions.
// Note: any final result unblocks the wait, not just the one for the
// search that armed it. Back-to-back searches (--listen, reload+wait)
// can therefore unblock early and run the pending actions on the
// previous result set. Accepted; a per-search generation token
// through the matcher isn't worth the complexity.
// search that armed it. Back-to-back searches (--listen, bg-transform
// callbacks, reload+wait) can therefore unblock early and run the
// pending actions on the previous result set. Accepted; a per-search
// generation token through the matcher isn't worth the complexity.
if t.wait.blocked {
t.unblockWait()
wakeUp = len(t.wait.pending) > 0
@@ -5836,8 +5836,9 @@ func (t *Terminal) unblockTrack() {
}
// The wait state machine. Invariant: arming captures every action after
// 'wait' at any nesting level into wait.pending; while blocked, actions
// are dropped; abort/cancel discards everything.
// 'wait' at any nesting level into wait.pending; while blocked, actions are
// dropped unless they are results of work started before the block
// (bg-transform callbacks); abort/cancel discards everything.
// blockWait blocks action execution and defers the given actions until the
// current search completes (see UpdateList)
@@ -6568,6 +6569,10 @@ func (t *Terminal) Loop() error {
var newCommand *commandSpec
var reloadSync bool
var denylist []int32
// True while running bg-transform callbacks. Declared outside the loop
// because 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)
@@ -6725,8 +6730,13 @@ func (t *Terminal) Loop() error {
currentIndex := t.currentIndex()
for i, action := range actions {
if action.t == actWait {
// Already waiting; ignore so input can't reset the blocked state
// Already waiting. Actions parsed from a bg-transform
// result join the current wait; user input can't reset
// the blocked state
if t.wait.blocked {
if inBgCallback {
t.wait.pending = append(t.wait.pending, actions[i+1:]...)
}
return true
}
// Block if search is in progress or will be triggered
@@ -6792,8 +6802,12 @@ func (t *Terminal) Loop() error {
callback(a.a)
}
}
// Actions that run even while wait/track-blocked: bg-transform
// callbacks and their parsed actions (results of processes
// started before the block).
passthrough := inBgCallback || a.t == actAsync
// When wait-blocked, only allow abort/cancel
if t.wait.blocked {
if t.wait.blocked && !passthrough {
if a.t == actAbort || a.t == actCancel {
t.cancelWait()
req(reqPrompt, reqInfo)
@@ -6801,7 +6815,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)
@@ -6812,11 +6826,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 {
+33
View File
@@ -1185,6 +1185,30 @@ 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_bg_transform_join
# A 'wait' in a bg-transform result body joins the ongoing wait; the
# actions after it run when the wait unblocks instead of being dropped
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:bg-transform(echo change-header{hello}+wait+change-footer{world})+search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && lines.any_include?('hello') && !lines.any_include?('world')) }
tmux.until { |lines| assert(lines.any_include?('world') && !lines.any_include?('(..)')) }
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
@@ -1239,6 +1263,15 @@ class TestCore < TestInteractive
assert_equal %w[ctrl-t 1], fzf_output_lines
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 }