mirror of
https://github.com/junegunn/fzf.git
synced 2026-07-10 02:29:14 +08:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1958,6 +1958,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
|
||||
} else {
|
||||
return nil, errors.New("unable to put non-printable character")
|
||||
}
|
||||
case "wait":
|
||||
appendAction(actWait)
|
||||
case "bell":
|
||||
appendAction(actBell)
|
||||
case "exclude":
|
||||
|
||||
+176
-17
@@ -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()}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user