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 23:54:51 +09:00
parent 47a16612f1
commit 3b211826e4
3 changed files with 63 additions and 7 deletions
+8
View File
@@ -2323,6 +2323,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, performing their usual role. The remaining actions of such a binding still run,
so a binding like \fBesc:cancel+first\fR is possible. 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 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 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. feedback is debounced so that quick searches do not cause flickering.
+22 -7
View File
@@ -1890,10 +1890,10 @@ func (t *Terminal) UpdateList(result MatchResult) {
t.searchInProgress = false t.searchInProgress = false
// If waiting, unblock so main loop can execute pending actions. // If waiting, unblock so main loop can execute pending actions.
// Note: any final result unblocks the wait, not just the one for the // Note: any final result unblocks the wait, not just the one for the
// search that armed it. Back-to-back searches (--listen, reload+wait) // search that armed it. Back-to-back searches (--listen, bg-transform
// can therefore unblock early and run the pending actions on the // callbacks, reload+wait) can therefore unblock early and run the
// previous result set. Accepted; a per-search generation token // pending actions on the previous result set. Accepted; a per-search
// through the matcher isn't worth the complexity. // generation token through the matcher isn't worth the complexity.
if t.waitBlocked { if t.waitBlocked {
t.unblockWait() t.unblockWait()
wakeUp = len(t.pendingActions) > 0 wakeUp = len(t.pendingActions) > 0
@@ -6523,6 +6523,10 @@ func (t *Terminal) Loop() error {
var newCommand *commandSpec var newCommand *commandSpec
var reloadSync bool var reloadSync bool
var denylist []int32 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) { req := func(evts ...util.EventType) {
for _, event := range evts { for _, event := range evts {
events = append(events, event) events = append(events, event)
@@ -6677,8 +6681,13 @@ func (t *Terminal) Loop() error {
currentIndex := t.currentIndex() currentIndex := t.currentIndex()
for i, action := range actions { for i, action := range actions {
if action.t == actWait { 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.waitBlocked { if t.waitBlocked {
if inBgCallback {
t.pendingActions = append(t.pendingActions, actions[i+1:]...)
}
return true return true
} }
// Block if search is in progress or will be triggered // Block if search is in progress or will be triggered
@@ -6761,8 +6770,12 @@ func (t *Terminal) Loop() error {
callback(a.a) 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 // When wait-blocked, only allow abort/cancel
if t.waitBlocked { if t.waitBlocked && !passthrough {
if a.t == actAbort || a.t == actCancel { if a.t == actAbort || a.t == actCancel {
t.unblockWait() t.unblockWait()
t.pendingActions = nil t.pendingActions = nil
@@ -6771,7 +6784,7 @@ func (t *Terminal) Loop() error {
return true return true
} }
// When track-blocked, only allow abort/cancel and track-disabling actions // 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 { if a.t == actAbort || a.t == actCancel {
t.unblockTrack() t.unblockTrack()
req(reqPrompt, reqInfo) req(reqPrompt, reqInfo)
@@ -6782,11 +6795,13 @@ func (t *Terminal) Loop() error {
switch a.t { switch a.t {
case actIgnore, actStart, actClick: case actIgnore, actStart, actClick:
case actAsync: case actAsync:
inBgCallback = true
for _, callback := range callbacks { for _, callback := range callbacks {
if t.bgVersion == callback.version { if t.bgVersion == callback.version {
callback.callback() callback.callback()
} }
} }
inBgCallback = false
case actBecome: case actBecome:
valid, list := t.buildPlusList(a.a, false) valid, list := t.buildPlusList(a.a, false)
if valid { if valid {
+33
View File
@@ -1185,6 +1185,30 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal 1, lines.match_count } tmux.until { |lines| assert_equal 1, lines.match_count }
end 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 def test_wait_action_query_change
# Query-editing actions must also make wait block; accept must run on the # Query-editing actions must also make wait block; accept must run on the
# results of the new query, not the stale ones # results of the new query, not the stale ones
@@ -1226,6 +1250,15 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal '> 5', lines[-3] } tmux.until { |lines| assert_equal '> 5', lines[-3] }
end 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 def test_clear_selection
tmux.send_keys %(seq 100 | #{FZF} --multi --bind space:clear-selection), :Enter tmux.send_keys %(seq 100 | #{FZF} --multi --bind space:clear-selection), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count } tmux.until { |lines| assert_equal 100, lines.match_count }