Add 'wait' action to block until search completion (#4825)

Blocks execution of subsequent actions until the current search
completes, so motion actions operate on the complete result set.

  # Move to the best match only after the search is done
  fzf --bind 'start:change-query(foo)+wait+best'

- Blocks when a search is in progress, will be triggered by preceding
  actions, or the query was edited in the same binding; no-op otherwise
- Initial input load counts as a search in progress, so 'start:wait'
  blocks until the input is fully loaded and searched
- User input ignored while blocked, including --expect keys; keys bound
  to abort/cancel cancel the wait and discard pending actions instead
- Actions deferred by the wait survive across nested lists (trigger
  chords) and cancel/re-arm chains
- Debounced feedback after 200ms: dimmed prompt, hidden cursor, and
  (..) on the info line
This commit is contained in:
Junegunn Choi
2026-07-05 11:05:57 +09:00
parent 9e2856559d
commit 8ec443ba40
6 changed files with 303 additions and 19 deletions
+11
View File
@@ -11,6 +11,17 @@ CHANGELOG
(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)'
```
- Added `wait` action to block subsequent actions until search completes (#4825)
- Useful for chaining query-changing 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:change-query(foo)+wait+best'
```
- The initial loading of the input is also considered a search in progress, so `start:wait` can be used to wait until the input is fully loaded
```sh
# Move to the last item after the input is fully loaded
(seq 1000; sleep 1; seq 1001 2000) | fzf --bind 'start:wait+last'
```
- Bound `alt-left` to `backward-word` and `alt-right` to `forward-word` by default (#4833)
- Skip `$FZF_CURRENT_ITEM` export when the item is larger than 64 KB; a huge item can overflow `ARG_MAX` and break preview and other child commands with `E2BIG` (#4806)
+34
View File
@@ -2149,6 +2149,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\-word\-rubout\fR \fIctrl\-w\fR
\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\-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)
@@ -2301,6 +2302,39 @@ chain multiple transform actions where later ones depend on earlier results,
prefer using the \fBbg\fR variant. To cancel currently running background
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 query\-changing 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:change\-query(foo)+wait+best'\fR
In this example, \fBchange\-query(foo)\fR starts an asynchronous search for
the new query, \fBwait\fR blocks until the search completes, and \fBbest\fR
then moves the cursor to the best match in the complete result set.
The initial loading of the input is also considered a search in progress, so
\fBstart:wait\fR can be used to block until the input is fully loaded and
searched.
While waiting, user input is ignored, except for keys bound to \fBabort\fR or
\fBcancel\fR (\fIctrl\-c\fR, \fIctrl\-g\fR, \fIctrl\-q\fR, and \fIesc\fR by
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.
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.
Note that when searches are triggered in rapid succession (e.g. via
\fB\-\-listen\fR), \fBwait\fR may unblock on the completion of an earlier
search. Also, if the input source never completes, \fBwait\fR will block until
cancelled.
.SS PREVIEW BINDING
With \fBpreview(...)\fR action, you can specify multiple different preview
+3 -2
View File
@@ -186,11 +186,12 @@ func _() {
_ = x[actExclude-175]
_ = x[actExcludeMulti-176]
_ = 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 {
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 {
return nil, errors.New("unable to put non-printable character")
}
case "wait":
appendAction(actWait)
case "bell":
appendAction(actBell)
case "exclude":
+176 -17
View File
@@ -144,6 +144,13 @@ type quitSignal struct {
err error
}
type waitState struct {
blocked bool
blockedAt time.Time
pending []*action
searching bool // a search is in progress or the input is still loading
}
type previewer struct {
version int64
lines []string
@@ -325,6 +332,7 @@ type Terminal struct {
trackBlocked bool
trackSync bool
trackKeyCache map[int32]bool
wait waitState
pendingSelections map[string]selectedItem
targetIndex int32
delimiter Delimiter
@@ -724,6 +732,7 @@ const (
actExclude
actExcludeMulti
actAsync
actWait
)
func (a actionType) Name() string {
@@ -1168,7 +1177,10 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
lastAction: actStart,
lastFocus: minItem.Index(),
lastActivity: time.Now(),
numLinesCache: make(map[int32]numLinesCacheValue)}
numLinesCache: make(map[int32]numLinesCacheValue),
// The initial load counts as a search in progress ('start:wait').
// Set before the reader starts so the first final result clears it.
wait: waitState{searching: true}}
if opts.AcceptNth != nil {
t.acceptNth = opts.AcceptNth(t.delimiter)
}
@@ -1882,6 +1894,21 @@ func (t *Terminal) UpdateProgress(progress float32) {
func (t *Terminal) UpdateList(result MatchResult) {
merger := result.merger
t.mutex.Lock()
waitWasBlocked := t.wait.blocked
wakeUp := false
if result.final() {
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.
if t.wait.blocked {
t.unblockWait()
wakeUp = len(t.wait.pending) > 0
}
}
prevIndex := minItem.Index()
newRevision := merger.Revision()
if t.revision.compatible(newRevision) && t.track != trackDisabled {
@@ -2047,9 +2074,19 @@ func (t *Terminal) UpdateList(result MatchResult) {
}
}
updateList := !t.trackBlocked && !t.pendingReqList
updatePrompt := trackWasBlocked && !t.trackBlocked
updatePrompt := (trackWasBlocked && !t.trackBlocked) || (waitWasBlocked && !t.wait.blocked)
t.mutex.Unlock()
// Wake up the main loop to execute pending actions after wait unblocks.
// Send from a goroutine; UpdateList runs inside the event box callback,
// and an inline send on a full channel would deadlock with the main loop
// blocking on eventBox.Set while trying to drain the channel.
if wakeUp {
go func() {
t.serverInputChan <- []*action{{t: actIgnore}}
}()
}
t.reqBox.Set(reqInfo, nil)
if updateList {
t.reqBox.Set(reqList, nil)
@@ -3265,7 +3302,7 @@ func (t *Terminal) printPrompt() {
color := tui.ColInput
if t.paused {
color = tui.ColDisabled
} else if t.trackBlocked {
} else if t.trackBlocked || t.waitFeedback() {
color = color.WithAttr(tui.Dim)
}
w.CPrint(color, string(before))
@@ -3357,9 +3394,6 @@ func (t *Terminal) printInfoImpl() {
output += fmt.Sprintf(" (%d/%d)", len(t.selected), t.multi)
}
}
if t.progress > 0 && t.progress < 100 {
output += fmt.Sprintf(" (%d%%)", t.progress)
}
if t.toggleSort {
if t.sort {
output += " +S"
@@ -3380,6 +3414,14 @@ func (t *Terminal) printInfoImpl() {
output += " +t"
}
}
if t.waitFeedback() {
output += " (..)"
}
// Keep the search progress at the end so the other indicators don't shift
// as it appears and disappears.
if t.progress > 0 && t.progress < 100 {
output += fmt.Sprintf(" (%d%%)", t.progress)
}
if t.failed != nil && t.count == 0 {
output = fmt.Sprintf("[Command failed: %s]", *t.failed)
}
@@ -5786,12 +5828,61 @@ func (t *Terminal) unblockTrack() {
t.trackBlocked = false
t.trackKey = ""
t.trackKeyCache = nil
if !t.inputless {
// Keep the cursor hidden if the wait feedback is still showing it
if !t.inputless && !t.waitFeedback() {
t.tui.ShowCursor()
}
}
}
// 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.
// blockWait blocks action execution and defers the given actions until the
// current search completes (see UpdateList)
func (t *Terminal) blockWait(pending []*action) {
t.wait.blocked = true
t.wait.blockedAt = time.Now()
// Clone so that later joins don't append into the backing array of the
// bound action list
t.wait.pending = slices.Clone(pending)
// Show the waiting feedback only if the search takes long enough,
// so that quick searches don't cause flickering
go func() {
timer := time.NewTimer(progressMinDuration)
<-timer.C
t.mutex.Lock()
blocked := t.wait.blocked
t.mutex.Unlock()
if blocked {
t.reqBox.Set(reqPrompt, nil)
t.reqBox.Set(reqInfo, nil)
}
}()
}
// unblockWait lifts the block, leaving the pending actions to the caller:
// UpdateList keeps them for the main loop to replay, cancelWait discards them
func (t *Terminal) unblockWait() {
t.wait.blocked = false
// Restore the cursor unless it's still hidden for another reason
if !t.inputless && !t.trackBlocked {
t.tui.ShowCursor()
}
}
// cancelWait unblocks and discards the pending actions (user abort)
func (t *Terminal) cancelWait() {
t.unblockWait()
t.wait.pending = nil
}
// Debounce visual feedback so quick searches don't cause flashing
func (t *Terminal) waitFeedback() bool {
return t.wait.blocked && time.Since(t.wait.blockedAt) > progressMinDuration
}
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'
@@ -6417,6 +6508,10 @@ func (t *Terminal) Loop() error {
t.printFooter()
}
}
// Hide the cursor while the debounced waiting feedback is shown
if !t.inputless && !t.trackBlocked && t.waitFeedback() {
t.tui.HideCursor()
}
t.flush()
t.mutex.Unlock()
t.uiMutex.Unlock()
@@ -6564,12 +6659,15 @@ func (t *Terminal) Loop() error {
}
t.mutex.Lock()
for key, ret := range t.expect {
if keyMatch(key, event) {
t.pressed = ret
t.mutex.Unlock()
t.reqBox.Set(reqClose, nil)
return nil
// Ignore --expect keys while wait-blocked like the rest of the input
if !t.wait.blocked {
for key, ret := range t.expect {
if keyMatch(key, event) {
t.pressed = ret
t.mutex.Unlock()
t.reqBox.Set(reqClose, nil)
return nil
}
}
}
triggering := map[tui.Event]struct{}{}
@@ -6619,12 +6717,36 @@ func (t *Terminal) Loop() error {
var doAction func(*action) bool
doActions := func(actions []*action) bool {
// Snapshot to detect query edits in this batch that trigger a
// search at loop end without setting 'changed'. String copy:
// edits mutate t.input in place.
queryBefore := string(t.input)
for iter := 0; iter <= maxFocusEvents; iter++ {
currentIndex := t.currentIndex()
for _, action := range actions {
for i, action := range actions {
if action.t == actWait {
// Already waiting; ignore so input can't reset the blocked state
if t.wait.blocked {
return true
}
// Block if search is in progress or will be triggered
if changed || newCommand != nil || t.wait.searching || queryBefore != string(t.input) {
t.blockWait(actions[i+1:])
return true
}
// No search, wait is a no-op; continue to next action
continue
}
blockedBefore := t.wait.blocked
if !doAction(action) {
return false
}
// If this action armed the wait through a nested list
// (e.g. via trigger), defer the rest of this list too
if !blockedBefore && t.wait.blocked {
t.wait.pending = append(t.wait.pending, actions[i+1:]...)
return true
}
// A terminal action performed. We should stop processing more.
if !looping {
break
@@ -6670,6 +6792,14 @@ func (t *Terminal) Loop() error {
callback(a.a)
}
}
// When wait-blocked, only allow abort/cancel
if t.wait.blocked {
if a.t == actAbort || a.t == actCancel {
t.cancelWait()
req(reqPrompt, reqInfo)
}
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 a.t == actAbort || a.t == actCancel {
@@ -7554,7 +7684,8 @@ func (t *Terminal) Loop() error {
req(reqPrompt)
case actTrigger:
if _, chords, err := parseKeyChords(a.a, ""); err == nil {
for _, chord := range chords {
blockedBefore := t.wait.blocked
for ci, chord := range chords {
if _, prs := triggering[chord]; prs {
// Avoid recursive triggering
continue
@@ -7564,6 +7695,19 @@ func (t *Terminal) Loop() error {
doActions(acts)
delete(triggering, chord)
}
// If this chord armed the wait, defer the remaining chords
if !blockedBefore && t.wait.blocked {
for _, rest := range chords[ci+1:] {
if _, prs := triggering[rest]; prs {
// Avoid recursive triggering
continue
}
if acts, prs := t.keymap[rest]; prs {
t.wait.pending = append(t.wait.pending, acts...)
}
}
break
}
}
}
case actSigStop:
@@ -8077,9 +8221,21 @@ func (t *Terminal) Loop() error {
return true
}
if t.jumping == jumpDisabled || len(actions) > 0 {
// Execute pending actions if wait just unblocked. Capture the jump
// state first so that a pending 'jump' action isn't cancelled right
// away by the wake-up event below.
jumpingBefore := t.jumping
if len(t.wait.pending) > 0 && !t.wait.blocked {
pending := t.wait.pending
t.wait.pending = nil
if !doActions(pending) {
continue
}
}
if jumpingBefore == jumpDisabled || len(actions) > 0 {
// Break out of jump mode if any action is submitted to the server
if t.jumping != jumpDisabled {
if jumpingBefore != jumpDisabled {
t.jumping = jumpDisabled
if acts, prs := t.keymap[tui.JumpCancel.AsEvent()]; prs && !doActions(acts) {
continue
@@ -8138,6 +8294,9 @@ func (t *Terminal) Loop() error {
}
reload := changed || newCommand != nil
if reload {
t.wait.searching = true
}
var reloadRequest *searchRequest
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()}
+77
View File
@@ -1162,6 +1162,83 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal 10, lines.match_count }
end
def test_wait_action
tmux.send_keys %((seq 100; sleep 15) | #{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 (..)') }
# Ctrl-C cancels the wait; fzf keeps running and accepts input again
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
end
def test_wait_action_start
# 'start:wait' blocks on the initial load until reading completes
tmux.send_keys %((seq 100; sleep 15) | #{FZF} --bind 'start:wait'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.until { |lines| assert lines.any_include?('(..)') }
tmux.send_keys 'C-c'
tmux.until { |lines| refute lines.any_include?('(..)') }
# Ctrl-C cancels the wait; fzf keeps running and accepts input again
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
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
tmux.send_keys %(seq 100 | #{fzf("--bind 'space:change-query(55)+wait+accept'")}), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.send_keys :Space
assert_equal '55', fzf_output
end
def test_wait_action_trigger_join
# A wait armed inside a triggered chord defers the remaining actions of
# the outer binding as well
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:trigger(x)+change-footer(world)' --bind 'x:search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && !lines.any_include?('world')) }
tmux.until { |lines| assert(lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_trigger_siblings
# Chords after a wait-arming chord are deferred, not dropped
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:trigger(x,y)+change-footer(world)' --bind 'x:search(5)+wait' --bind 'y:change-header(hello)'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && !lines.any_include?('hello') && !lines.any_include?('world')) }
tmux.until { |lines| assert(lines.any_include?('hello') && lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_cancel_rearm
# Deferral works even when the wait is cancelled and re-armed within a
# single action list. --query keeps 'cancel' non-fatal in case the Space
# arrives after the initial wait has already unblocked.
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --query 5 --bind 'start:wait' --bind 'space:cancel+trigger(x)+change-footer(world)' --bind 'x:search(5)+wait'), :Enter
tmux.until { |lines| assert lines.any_include?('(..)') }
tmux.send_keys :Space
tmux.until { |lines| assert(lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_jump
# A pending jump action must survive the wake-up event after unblock
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --jump-labels abc --bind 'start:search(5)+wait+jump'), :Enter
tmux.until { |lines| assert_equal 'a 5', lines[-3] }
tmux.send_keys 'a'
tmux.until { |lines| assert_equal '> 5', lines[-3] }
end
def test_wait_action_expect
# --expect keys are ignored while wait-blocked, like the rest of the input
tmux.send_keys %((seq 100; sleep 2) | #{fzf('--expect ctrl-t --bind start:wait')}), :Enter
tmux.until { |lines| assert lines.any_include?('(..)') }
tmux.send_keys 'C-t'
# fzf must still be running; the wait unblocks when loading completes
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.until { |lines| refute lines.any_include?('(..)') }
tmux.send_keys 'C-t'
assert_equal %w[ctrl-t 1], fzf_output_lines
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 }