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:22:02 +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 \ (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 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) - 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) - 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\-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)
@@ -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 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 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 .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":
+170 -11
View File
@@ -144,6 +144,13 @@ type quitSignal struct {
err error 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 { type previewer struct {
version int64 version int64
lines []string lines []string
@@ -325,6 +332,7 @@ type Terminal struct {
trackBlocked bool trackBlocked bool
trackSync bool trackSync bool
trackKeyCache map[int32]bool trackKeyCache map[int32]bool
wait waitState
pendingSelections map[string]selectedItem pendingSelections map[string]selectedItem
targetIndex int32 targetIndex int32
delimiter Delimiter delimiter Delimiter
@@ -724,6 +732,7 @@ const (
actExclude actExclude
actExcludeMulti actExcludeMulti
actAsync actAsync
actWait
) )
func (a actionType) Name() string { func (a actionType) Name() string {
@@ -1168,7 +1177,10 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
lastAction: actStart, lastAction: actStart,
lastFocus: minItem.Index(), lastFocus: minItem.Index(),
lastActivity: time.Now(), 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 { if opts.AcceptNth != nil {
t.acceptNth = opts.AcceptNth(t.delimiter) t.acceptNth = opts.AcceptNth(t.delimiter)
} }
@@ -1882,6 +1894,21 @@ 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.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() 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 {
@@ -2047,9 +2074,19 @@ 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.wait.blocked)
t.mutex.Unlock() 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) t.reqBox.Set(reqInfo, nil)
if updateList { if updateList {
t.reqBox.Set(reqList, nil) t.reqBox.Set(reqList, nil)
@@ -3265,7 +3302,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.waitFeedback() {
color = color.WithAttr(tui.Dim) color = color.WithAttr(tui.Dim)
} }
w.CPrint(color, string(before)) w.CPrint(color, string(before))
@@ -3357,9 +3394,6 @@ func (t *Terminal) printInfoImpl() {
output += fmt.Sprintf(" (%d/%d)", len(t.selected), t.multi) 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.toggleSort {
if t.sort { if t.sort {
output += " +S" output += " +S"
@@ -3380,6 +3414,14 @@ func (t *Terminal) printInfoImpl() {
output += " +t" 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 { if t.failed != nil && t.count == 0 {
output = fmt.Sprintf("[Command failed: %s]", *t.failed) output = fmt.Sprintf("[Command failed: %s]", *t.failed)
} }
@@ -5786,12 +5828,61 @@ func (t *Terminal) unblockTrack() {
t.trackBlocked = false t.trackBlocked = false
t.trackKey = "" t.trackKey = ""
t.trackKeyCache = nil 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() 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 { 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'
@@ -6417,6 +6508,10 @@ func (t *Terminal) Loop() error {
t.printFooter() 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.flush()
t.mutex.Unlock() t.mutex.Unlock()
t.uiMutex.Unlock() t.uiMutex.Unlock()
@@ -6564,6 +6659,8 @@ func (t *Terminal) Loop() error {
} }
t.mutex.Lock() t.mutex.Lock()
// Ignore --expect keys while wait-blocked like the rest of the input
if !t.wait.blocked {
for key, ret := range t.expect { for key, ret := range t.expect {
if keyMatch(key, event) { if keyMatch(key, event) {
t.pressed = ret t.pressed = ret
@@ -6572,6 +6669,7 @@ func (t *Terminal) Loop() error {
return nil return nil
} }
} }
}
triggering := map[tui.Event]struct{}{} triggering := map[tui.Event]struct{}{}
previousInput := t.input previousInput := t.input
previousCx := t.cx previousCx := t.cx
@@ -6619,12 +6717,36 @@ func (t *Terminal) Loop() error {
var doAction func(*action) bool var doAction func(*action) bool
doActions := func(actions []*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++ { 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 {
// 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) { if !doAction(action) {
return false 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. // A terminal action performed. We should stop processing more.
if !looping { if !looping {
break break
@@ -6670,6 +6792,14 @@ func (t *Terminal) Loop() error {
callback(a.a) 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 // 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 {
@@ -7554,7 +7684,8 @@ func (t *Terminal) Loop() error {
req(reqPrompt) req(reqPrompt)
case actTrigger: case actTrigger:
if _, chords, err := parseKeyChords(a.a, ""); err == nil { 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 { if _, prs := triggering[chord]; prs {
// Avoid recursive triggering // Avoid recursive triggering
continue continue
@@ -7564,6 +7695,19 @@ func (t *Terminal) Loop() error {
doActions(acts) doActions(acts)
delete(triggering, chord) 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: case actSigStop:
@@ -8077,9 +8221,21 @@ func (t *Terminal) Loop() error {
return true 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 // Break out of jump mode if any action is submitted to the server
if t.jumping != jumpDisabled { if jumpingBefore != jumpDisabled {
t.jumping = jumpDisabled t.jumping = jumpDisabled
if acts, prs := t.keymap[tui.JumpCancel.AsEvent()]; prs && !doActions(acts) { if acts, prs := t.keymap[tui.JumpCancel.AsEvent()]; prs && !doActions(acts) {
continue continue
@@ -8138,6 +8294,9 @@ func (t *Terminal) Loop() error {
} }
reload := changed || newCommand != nil reload := changed || newCommand != nil
if reload {
t.wait.searching = 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()}
+77
View File
@@ -1162,6 +1162,83 @@ 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 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 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 }