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..be91b050 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,24 @@ 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. + +While waiting, the UI is dimmed and user input is ignored (except for Escape or +Ctrl-C to cancel the wait and discard pending actions). The info line shows +\fB(..)\fR to indicate that fzf is waiting for the search to complete. + .SS PREVIEW BINDING 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..1b2c2cfb 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -321,6 +321,9 @@ type Terminal struct { trackBlocked bool trackSync bool trackKeyCache map[int32]bool + waitBlocked bool + pendingActions []*action + searchInProgress bool pendingSelections map[string]selectedItem targetIndex int32 delimiter Delimiter @@ -720,6 +723,7 @@ const ( actExclude actExcludeMulti actAsync + actWait ) func (a actionType) Name() string { @@ -1876,6 +1880,17 @@ func (t *Terminal) UpdateProgress(progress float32) { func (t *Terminal) UpdateList(result MatchResult) { merger := result.merger t.mutex.Lock() + waitWasBlocked := t.waitBlocked + if result.final() { + t.searchInProgress = false + // If waiting, unblock so main loop can execute pending actions + if t.waitBlocked { + t.unblockWait() + if len(t.pendingActions) > 0 { + t.serverInputChan <- []*action{{t: actIgnore}} + } + } + } prevIndex := minItem.Index() newRevision := merger.Revision() if t.revision.compatible(newRevision) && t.track != trackDisabled { @@ -2041,7 +2056,7 @@ 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() t.reqBox.Set(reqInfo, nil) @@ -3259,7 +3274,7 @@ func (t *Terminal) printPrompt() { color := tui.ColInput if t.paused { color = tui.ColDisabled - } else if t.trackBlocked { + } else if t.trackBlocked || t.waitBlocked { color = color.WithAttr(tui.Dim) } w.CPrint(color, string(before)) @@ -3374,6 +3389,9 @@ func (t *Terminal) printInfoImpl() { output += " +t" } } + if t.waitBlocked { + output += " (..)" + } if t.failed != nil && t.count == 0 { output = fmt.Sprintf("[Command failed: %s]", *t.failed) } @@ -5786,6 +5804,14 @@ func (t *Terminal) unblockTrack() { } } +func (t *Terminal) unblockWait() { + t.waitBlocked = false + // Only show cursor if not blocked by tracking + if !t.inputless && !t.trackBlocked { + t.tui.ShowCursor() + } +} + func (t *Terminal) addClickHeaderWord(env []string) []string { /* * echo $'HL1\nHL2' | fzf --header-lines 3 --header $'H1\nH2' --header-lines-border --bind 'click-header:preview:env | grep FZF_CLICK' @@ -6615,7 +6641,21 @@ func (t *Terminal) Loop() error { doActions := func(actions []*action) bool { for iter := 0; iter <= maxFocusEvents; iter++ { currentIndex := t.currentIndex() - for _, action := range actions { + for i, action := range actions { + if action.t == actWait { + // Block if search is in progress or will be triggered + if changed || newCommand != nil || t.searchInProgress { + t.waitBlocked = true + t.pendingActions = actions[i+1:] + if !t.inputless { + t.tui.HideCursor() + } + req(reqPrompt, reqInfo) + return true + } + // No search, wait is a no-op; continue to next action + continue + } if !doAction(action) { return false } @@ -6664,6 +6704,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,6 +8120,15 @@ func (t *Terminal) Loop() error { return true } + // Execute pending actions if wait just unblocked + if len(t.pendingActions) > 0 && !t.waitBlocked { + pendingActions := t.pendingActions + t.pendingActions = nil + if !doActions(pendingActions) { + continue + } + } + if t.jumping == jumpDisabled || len(actions) > 0 { // Break out of jump mode if any action is submitted to the server if t.jumping != jumpDisabled { @@ -8132,6 +8190,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..fa99fa52 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -1162,6 +1162,14 @@ 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 (..)') } + 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 }