Compare commits

...

1 Commits

Author SHA1 Message Date
Junegunn Choi eb487035f1 Add 'wait' action to block fzf until search completion
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
The 'wait' action blocks subsequent action execution until the current
search completes. This is essential for chaining actions after
query-changing actions (e.g. transform-search(...)+wait+best), ensuring
motion actions operate on complete results rather than stale data.

- Blocks all user input except ESC/CTRL-C while waiting
- Shows visual feedback: dimmed query, (..) indicator, hidden cursor
- Clears pending actions if user cancels with ESC/CTRL-C

Closes: #4825
2026-06-21 22:13:20 +09:00
6 changed files with 102 additions and 5 deletions
+6
View File
@@ -11,6 +11,12 @@ CHANGELOG
(seq 100; sleep 1; seq 100) | fzf --query 1 \ (seq 100; sleep 1; seq 100) | fzf --query 1 \
--bind 'result:transform-header(echo result: $FZF_MATCH_COUNT),result-final:transform-footer(echo final: $FZF_MATCH_COUNT)' --bind 'result:transform-header(echo result: $FZF_MATCH_COUNT),result-final:transform-footer(echo final: $FZF_MATCH_COUNT)'
``` ```
- Added `wait` action to block subsequent actions until search completes (#4825)
- Useful for chaining transform actions with motion actions to ensure operations on complete results
```sh
# Wait for search to complete before moving to the best match
fzf --bind 'start:search(foo)+wait+best'
```
- Bound `alt-left` to `backward-word` and `alt-right` to `forward-word` by default (#4833) - Bound `alt-left` to `backward-word` and `alt-right` to `forward-word` by default (#4833)
0.73.1 0.73.1
+19
View File
@@ -2146,6 +2146,7 @@ A key or an event can be bound to one or more of the following actions.
\fBunix\-line\-discard\fR \fIctrl\-u\fR \fBunix\-line\-discard\fR \fIctrl\-u\fR
\fBunix\-word\-rubout\fR \fIctrl\-w\fR \fBunix\-word\-rubout\fR \fIctrl\-w\fR
\fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled) \fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled)
\fBwait\fR (block action execution until search completes)
\fBup\fR \fIctrl\-k up\fR \fBup\fR \fIctrl\-k up\fR
\fBup\-match\fR \fIctrl\-p\fR \fIalt\-up\fR (move to the match above the cursor) \fBup\-match\fR \fIctrl\-p\fR \fIalt\-up\fR (move to the match above the cursor)
\fBup\-selected\fR (move to the selected item above the cursor) \fBup\-selected\fR (move to the selected item above the cursor)
@@ -2298,6 +2299,24 @@ chain multiple transform actions where later ones depend on earlier results,
prefer using the \fBbg\fR variant. To cancel currently running background prefer using the \fBbg\fR variant. To cancel currently running background
transform processes, use \fBbg\-cancel\fR action. transform processes, use \fBbg\-cancel\fR action.
.SS WAITING FOR SEARCH COMPLETION
The \fBwait\fR action blocks the execution of subsequent actions until the
current search completes. This is useful when chaining transform actions with
motion actions like \fBbest\fR or \fBfirst\fR, ensuring that the motion action
operates on the complete search results rather than stale data.
e.g.
\fBfzf \-\-bind 'start:search(foo)+wait+best'\fR
In this example, \fBsearch(foo)\fR starts an asynchronous search,
\fBwait\fR blocks until the search completes, and \fBbest\fR then moves the
cursor to the best match in the complete result set.
While waiting, the UI is dimmed and user input is ignored (except for Escape or
Ctrl-C to cancel the wait and discard pending actions). The info line shows
\fB(..)\fR to indicate that fzf is waiting for the search to complete.
.SS PREVIEW BINDING .SS PREVIEW BINDING
With \fBpreview(...)\fR action, you can specify multiple different preview With \fBpreview(...)\fR action, you can specify multiple different preview
+3 -2
View File
@@ -186,11 +186,12 @@ func _() {
_ = x[actExclude-175] _ = x[actExclude-175]
_ = x[actExcludeMulti-176] _ = x[actExcludeMulti-176]
_ = x[actAsync-177] _ = x[actAsync-177]
_ = x[actWait-178]
} }
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync" const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsyncactWait"
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 336, 351, 371, 391, 410, 428, 442, 454, 470, 486, 502, 523, 545, 560, 574, 588, 601, 618, 626, 639, 655, 667, 675, 689, 703, 720, 731, 742, 756, 774, 791, 798, 817, 839, 851, 865, 874, 889, 901, 914, 925, 936, 948, 962, 983, 998, 1011, 1028, 1046, 1062, 1074, 1086, 1099, 1114, 1128, 1140, 1152, 1169, 1176, 1188, 1193, 1203, 1212, 1223, 1234, 1247, 1262, 1273, 1286, 1301, 1308, 1321, 1334, 1351, 1366, 1379, 1393, 1407, 1423, 1443, 1467, 1479, 1502, 1519, 1537, 1560, 1578, 1601, 1624, 1646, 1667, 1682, 1701, 1720, 1744, 1762, 1779, 1797, 1807, 1821, 1846, 1865, 1885, 1910, 1930, 1955, 1980, 2004, 2027, 2044, 2065, 2086, 2112, 2132, 2151, 2171, 2182, 2191, 2201, 2214, 2230, 2242, 2256, 2272, 2290, 2310, 2332, 2346, 2361, 2369, 2375, 2389, 2404, 2414, 2430, 2445, 2455, 2462, 2470, 2477, 2486, 2499, 2515, 2530, 2539, 2550, 2559, 2568, 2581, 2590, 2603, 2616, 2623, 2633, 2648, 2656} var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 336, 351, 371, 391, 410, 428, 442, 454, 470, 486, 502, 523, 545, 560, 574, 588, 601, 618, 626, 639, 655, 667, 675, 689, 703, 720, 731, 742, 756, 774, 791, 798, 817, 839, 851, 865, 874, 889, 901, 914, 925, 936, 948, 962, 983, 998, 1011, 1028, 1046, 1062, 1074, 1086, 1099, 1114, 1128, 1140, 1152, 1169, 1176, 1188, 1193, 1203, 1212, 1223, 1234, 1247, 1262, 1273, 1286, 1301, 1308, 1321, 1334, 1351, 1366, 1379, 1393, 1407, 1423, 1443, 1467, 1479, 1502, 1519, 1537, 1560, 1578, 1601, 1624, 1646, 1667, 1682, 1701, 1720, 1744, 1762, 1779, 1797, 1807, 1821, 1846, 1865, 1885, 1910, 1930, 1955, 1980, 2004, 2027, 2044, 2065, 2086, 2112, 2132, 2151, 2171, 2182, 2191, 2201, 2214, 2230, 2242, 2256, 2272, 2290, 2310, 2332, 2346, 2361, 2369, 2375, 2389, 2404, 2414, 2430, 2445, 2455, 2462, 2470, 2477, 2486, 2499, 2515, 2530, 2539, 2550, 2559, 2568, 2581, 2590, 2603, 2616, 2623, 2633, 2648, 2656, 2663}
func (i actionType) String() string { func (i actionType) String() string {
if i < 0 || i >= actionType(len(_actionType_index)-1) { if i < 0 || i >= actionType(len(_actionType_index)-1) {
+2
View File
@@ -1958,6 +1958,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
} else { } else {
return nil, errors.New("unable to put non-printable character") return nil, errors.New("unable to put non-printable character")
} }
case "wait":
appendAction(actWait)
case "bell": case "bell":
appendAction(actBell) appendAction(actBell)
case "exclude": case "exclude":
+64 -3
View File
@@ -321,6 +321,9 @@ type Terminal struct {
trackBlocked bool trackBlocked bool
trackSync bool trackSync bool
trackKeyCache map[int32]bool trackKeyCache map[int32]bool
waitBlocked bool
pendingActions []*action
searchInProgress bool
pendingSelections map[string]selectedItem pendingSelections map[string]selectedItem
targetIndex int32 targetIndex int32
delimiter Delimiter delimiter Delimiter
@@ -720,6 +723,7 @@ const (
actExclude actExclude
actExcludeMulti actExcludeMulti
actAsync actAsync
actWait
) )
func (a actionType) Name() string { func (a actionType) Name() string {
@@ -1876,6 +1880,17 @@ func (t *Terminal) UpdateProgress(progress float32) {
func (t *Terminal) UpdateList(result MatchResult) { func (t *Terminal) UpdateList(result MatchResult) {
merger := result.merger merger := result.merger
t.mutex.Lock() t.mutex.Lock()
waitWasBlocked := t.waitBlocked
if result.final() {
t.searchInProgress = false
// If waiting, unblock so main loop can execute pending actions
if t.waitBlocked {
t.unblockWait()
if len(t.pendingActions) > 0 {
t.serverInputChan <- []*action{{t: actIgnore}}
}
}
}
prevIndex := minItem.Index() prevIndex := minItem.Index()
newRevision := merger.Revision() newRevision := merger.Revision()
if t.revision.compatible(newRevision) && t.track != trackDisabled { if t.revision.compatible(newRevision) && t.track != trackDisabled {
@@ -2041,7 +2056,7 @@ func (t *Terminal) UpdateList(result MatchResult) {
} }
} }
updateList := !t.trackBlocked && !t.pendingReqList updateList := !t.trackBlocked && !t.pendingReqList
updatePrompt := trackWasBlocked && !t.trackBlocked updatePrompt := (trackWasBlocked && !t.trackBlocked) || (waitWasBlocked && !t.waitBlocked)
t.mutex.Unlock() t.mutex.Unlock()
t.reqBox.Set(reqInfo, nil) t.reqBox.Set(reqInfo, nil)
@@ -3259,7 +3274,7 @@ func (t *Terminal) printPrompt() {
color := tui.ColInput color := tui.ColInput
if t.paused { if t.paused {
color = tui.ColDisabled color = tui.ColDisabled
} else if t.trackBlocked { } else if t.trackBlocked || t.waitBlocked {
color = color.WithAttr(tui.Dim) color = color.WithAttr(tui.Dim)
} }
w.CPrint(color, string(before)) w.CPrint(color, string(before))
@@ -3374,6 +3389,9 @@ func (t *Terminal) printInfoImpl() {
output += " +t" output += " +t"
} }
} }
if t.waitBlocked {
output += " (..)"
}
if t.failed != nil && t.count == 0 { if t.failed != nil && t.count == 0 {
output = fmt.Sprintf("[Command failed: %s]", *t.failed) output = fmt.Sprintf("[Command failed: %s]", *t.failed)
} }
@@ -5786,6 +5804,14 @@ func (t *Terminal) unblockTrack() {
} }
} }
func (t *Terminal) unblockWait() {
t.waitBlocked = false
// Only show cursor if not blocked by tracking
if !t.inputless && !t.trackBlocked {
t.tui.ShowCursor()
}
}
func (t *Terminal) addClickHeaderWord(env []string) []string { func (t *Terminal) addClickHeaderWord(env []string) []string {
/* /*
* echo $'HL1\nHL2' | fzf --header-lines 3 --header $'H1\nH2' --header-lines-border --bind 'click-header:preview:env | grep FZF_CLICK' * echo $'HL1\nHL2' | fzf --header-lines 3 --header $'H1\nH2' --header-lines-border --bind 'click-header:preview:env | grep FZF_CLICK'
@@ -6615,7 +6641,21 @@ func (t *Terminal) Loop() error {
doActions := func(actions []*action) bool { doActions := func(actions []*action) bool {
for iter := 0; iter <= maxFocusEvents; iter++ { for iter := 0; iter <= maxFocusEvents; iter++ {
currentIndex := t.currentIndex() currentIndex := t.currentIndex()
for _, action := range actions { for i, action := range actions {
if action.t == actWait {
// Block if search is in progress or will be triggered
if changed || newCommand != nil || t.searchInProgress {
t.waitBlocked = true
t.pendingActions = actions[i+1:]
if !t.inputless {
t.tui.HideCursor()
}
req(reqPrompt, reqInfo)
return true
}
// No search, wait is a no-op; continue to next action
continue
}
if !doAction(action) { if !doAction(action) {
return false return false
} }
@@ -6664,6 +6704,15 @@ func (t *Terminal) Loop() error {
callback(a.a) callback(a.a)
} }
} }
// When wait-blocked, only allow abort/cancel
if t.waitBlocked {
if a.t == actAbort || a.t == actCancel {
t.unblockWait()
t.pendingActions = nil
req(reqPrompt, reqInfo)
}
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 && a.t != actToggleTrack && a.t != actToggleTrackCurrent && a.t != actUntrackCurrent {
if a.t == actAbort || a.t == actCancel { if a.t == actAbort || a.t == actCancel {
@@ -8071,6 +8120,15 @@ func (t *Terminal) Loop() error {
return true return true
} }
// Execute pending actions if wait just unblocked
if len(t.pendingActions) > 0 && !t.waitBlocked {
pendingActions := t.pendingActions
t.pendingActions = nil
if !doActions(pendingActions) {
continue
}
}
if t.jumping == jumpDisabled || len(actions) > 0 { if t.jumping == jumpDisabled || len(actions) > 0 {
// Break out of jump mode if any action is submitted to the server // Break out of jump mode if any action is submitted to the server
if t.jumping != jumpDisabled { if t.jumping != jumpDisabled {
@@ -8132,6 +8190,9 @@ func (t *Terminal) Loop() error {
} }
reload := changed || newCommand != nil reload := changed || newCommand != nil
if reload {
t.searchInProgress = true
}
var reloadRequest *searchRequest var reloadRequest *searchRequest
if reload { if reload {
reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, withNth: newWithNth, headerLines: newHeaderLines, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.resultMerger.Revision()} reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, withNth: newWithNth, headerLines: newHeaderLines, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.resultMerger.Revision()}
+8
View File
@@ -1162,6 +1162,14 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal 10, lines.match_count } tmux.until { |lines| assert_equal 10, lines.match_count }
end end
def test_wait_action
tmux.send_keys %((seq 100; sleep 60) | #{FZF} --bind 'start:search(1)+wait+best'), :Enter
tmux.until { |lines| assert_equal 20, lines.match_count }
tmux.until { |lines| assert lines.any_include?('20/100 (..)') }
tmux.send_keys 'C-c'
tmux.until { |lines| refute lines.any_include?('20/100 (..)') }
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 }