diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da66aef..66e6e665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 2b675330..4f0b4876 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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 diff --git a/src/actiontype_string.go b/src/actiontype_string.go index 2b8a085d..c18e23bc 100644 --- a/src/actiontype_string.go +++ b/src/actiontype_string.go @@ -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) { diff --git a/src/options.go b/src/options.go index 6e2c933a..550c84cf 100644 --- a/src/options.go +++ b/src/options.go @@ -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": diff --git a/src/terminal.go b/src/terminal.go index d4ced8e6..c146a375 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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()} diff --git a/test/test_core.rb b/test/test_core.rb index 2dcd0ce5..ef3872f1 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -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 }