From a5d00368a7db9d8035bac25b692057f79e3b9ddb Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Fri, 3 Jul 2026 00:52:58 +0900 Subject: [PATCH] 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. --- man/man1/fzf.1 | 6 ++++++ src/terminal.go | 28 +++++++++++++++++++++------- test/test_core.rb | 25 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 5645dc83..5855ba5c 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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. diff --git a/src/terminal.go b/src/terminal.go index 70078aa3..0b5a0154 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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 { diff --git a/test/test_core.rb b/test/test_core.rb index 41f79e71..00051ab1 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -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 }