diff --git a/CHANGELOG.md b/CHANGELOG.md index f5edb30e..f544a374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ 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 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) 0.73.1 diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 9ea726dd..5645dc83 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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\-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) @@ -2298,6 +2299,38 @@ 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 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. + +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. + +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 49994522..70078aa3 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -321,6 +321,10 @@ type Terminal struct { trackBlocked bool trackSync bool trackKeyCache map[int32]bool + waitBlocked bool + waitBlockedAt time.Time + pendingActions []*action + searchInProgress bool pendingSelections map[string]selectedItem targetIndex int32 delimiter Delimiter @@ -720,6 +724,7 @@ const ( actExclude actExcludeMulti actAsync + actWait ) func (a actionType) Name() string { @@ -1164,7 +1169,11 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor lastAction: actStart, lastFocus: minItem.Index(), lastActivity: time.Now(), - numLinesCache: make(map[int32]numLinesCacheValue)} + // Consider the initial load a search in progress so 'start:wait' blocks + // until the first result set is complete. Set here, before the reader + // starts, so the first final() result correctly clears it. + searchInProgress: true, + numLinesCache: make(map[int32]numLinesCacheValue)} if opts.AcceptNth != nil { t.acceptNth = opts.AcceptNth(t.delimiter) } @@ -1876,6 +1885,23 @@ func (t *Terminal) UpdateProgress(progress float32) { func (t *Terminal) UpdateList(result MatchResult) { merger := result.merger t.mutex.Lock() + waitWasBlocked := t.waitBlocked + wakeUp := false + if result.final() { + t.searchInProgress = false + // If waiting, unblock so main loop can execute pending actions. + // Note: we unblock on any final result, not the specific search that + // armed the wait. A stale final from an earlier search can race in + // when searches are dispatched back-to-back: via --listen, or + // 'reload(...)+wait' racing the previous input's final. The worst + // case is an early unblock running the pending actions on the + // previous result set; we accept it rather than thread a per-search + // generation token through the matcher. + if t.waitBlocked { + t.unblockWait() + wakeUp = len(t.pendingActions) > 0 + } + } prevIndex := minItem.Index() newRevision := merger.Revision() if t.revision.compatible(newRevision) && t.track != trackDisabled { @@ -2041,9 +2067,19 @@ func (t *Terminal) UpdateList(result MatchResult) { } } updateList := !t.trackBlocked && !t.pendingReqList - updatePrompt := trackWasBlocked && !t.trackBlocked + updatePrompt := (trackWasBlocked && !t.trackBlocked) || (waitWasBlocked && !t.waitBlocked) 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) @@ -3259,7 +3295,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)) @@ -3351,9 +3387,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" @@ -3374,6 +3407,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) } @@ -5780,12 +5821,26 @@ 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() } } } +// Debounce visual feedback so quick searches don't cause flashing +func (t *Terminal) waitFeedback() bool { + return t.waitBlocked && time.Since(t.waitBlockedAt) > progressMinDuration +} + +func (t *Terminal) unblockWait() { + t.waitBlocked = false + // Restore the cursor unless it's still hidden for another reason + if !t.inputless && !t.trackBlocked { + t.tui.ShowCursor() + } +} + 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' @@ -6411,6 +6466,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() @@ -6613,9 +6672,42 @@ func (t *Terminal) Loop() error { var doAction func(*action) bool doActions := func(actions []*action) bool { + // Snapshot the query to detect query-editing actions in this batch + // (e.g. change-query, transform-query, put) that will trigger a + // search at the end of the iteration without setting 'changed'. + // Copied because query edits can 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.waitBlocked { + return true + } + // Block if search is in progress or will be triggered + if changed || newCommand != nil || t.searchInProgress || queryBefore != string(t.input) { + t.waitBlocked = true + t.waitBlockedAt = time.Now() + t.pendingActions = actions[i+1:] + // 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.waitBlocked + t.mutex.Unlock() + if blocked { + t.reqBox.Set(reqPrompt, nil) + t.reqBox.Set(reqInfo, nil) + } + }() + return true + } + // No search, wait is a no-op; continue to next action + continue + } if !doAction(action) { return false } @@ -6664,6 +6756,15 @@ func (t *Terminal) Loop() error { 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 if t.trackBlocked && a.t != actToggleTrack && a.t != actToggleTrackCurrent && a.t != actUntrackCurrent { if a.t == actAbort || a.t == actCancel { @@ -8071,9 +8172,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.pendingActions) > 0 && !t.waitBlocked { + pendingActions := t.pendingActions + t.pendingActions = nil + if !doActions(pendingActions) { + 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 @@ -8132,6 +8245,9 @@ func (t *Terminal) Loop() error { } reload := changed || newCommand != nil + if reload { + t.searchInProgress = 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 56fd1f64..41f79e71 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -1162,6 +1162,46 @@ class TestCore < TestInteractive tmux.until { |lines| assert_equal 10, lines.match_count } 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 (..)') } + # 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 60) | #{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_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_clear_selection tmux.send_keys %(seq 100 | #{FZF} --multi --bind space:clear-selection), :Enter tmux.until { |lines| assert_equal 100, lines.match_count }