mirror of
https://github.com/junegunn/fzf.git
synced 2026-03-25 18:16:58 +08:00
Add field-based tracking across reloads (--track=NTH)
Allow --track to accept an optional nth expression for cross-reload tracking. When a reload is triggered, fzf extracts a tracking key from the current item using the nth expression, blocks the UI, and searches for a matching item in the reloaded list. - --track=.. tracks by entire line, --track=1 by first field, etc. - --track without NTH retains existing index-based behavior - UI is blocked during search (dimmed query, hidden cursor, +T*/+t*) - reload unblocks eagerly on match; reload-sync waits for stream end - Escape/Ctrl-C cancels blocked state without quitting - track-current action accepts optional nth: track-current(1) - Validate nth expression at parse time for both --track and track() - Cache trackKeyFor results per item to avoid redundant computation - Rename executeRegexp to argActionRegexp Close #4701 Close #3460
This commit is contained in:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,8 +1,18 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.70.1
|
||||
0.71.0
|
||||
------
|
||||
- Cross-reload tracking
|
||||
- `--track` now accepts an optional nth expression (`--track=NTH`) for field-based tracking across `reload`s
|
||||
- `--track` without `NTH` retains the existing index-based tracking behavior (does not persist across reloads)
|
||||
- `--track=..` tracks by the entire line across reloads
|
||||
- `--track=1` tracks by the first field across reloads
|
||||
- When a `reload` is triggered, fzf searches for the tracked item by its nth field in the new list.
|
||||
- The UI is temporarily blocked (prompt dimmed, input disabled) until the item is found or loading completes.
|
||||
- Press `Escape` or `Ctrl-C` to cancel the blocked state without quitting
|
||||
- Info line shows `+T*` / `+t*` while searching
|
||||
- `track-current` action now also accepts an optional nth argument: `track-current(1)`
|
||||
- Performance improvements
|
||||
- The search performance now scales linearly with the number of CPU cores, as we dropped static partitioning to allow better load balancing across threads.
|
||||
```
|
||||
|
||||
@@ -617,17 +617,37 @@ Disable multi-line display of items when using \fB\-\-read0\fR
|
||||
.B "\-\-raw"
|
||||
Enable raw mode where non-matching items are also displayed in a dimmed color.
|
||||
.TP
|
||||
.B "\-\-track"
|
||||
.BI "\-\-track" "[=NTH]"
|
||||
Make fzf track the current selection when the result list is updated.
|
||||
This can be useful when browsing logs using fzf with sorting disabled. It is
|
||||
not recommended to use this option with \fB\-\-tac\fR as the resulting behavior
|
||||
can be confusing. Also, consider using \fBtrack\fR action instead of this
|
||||
option.
|
||||
can be confusing.
|
||||
|
||||
When an nth expression is explicitly given (e.g. \fB\-\-track=..\fR or
|
||||
\fB\-\-track=1\fR), fzf enables field\-based tracking across \fBreload\fRs.
|
||||
On reload, fzf extracts the tracking key from the current item using the nth
|
||||
expression and searches for a matching item in the reloaded list. While
|
||||
searching, the UI is blocked (query input and cursor movement are disabled, and
|
||||
the prompt is dimmed). With \fBreload\fR, the blocked state clears as soon as
|
||||
the match is found in the stream. With \fBreload\-sync\fR, the blocked state
|
||||
persists until the entire stream is complete. Press \fBEscape\fR or
|
||||
\fBCtrl\-C\fR to cancel the blocked state without quitting fzf.
|
||||
|
||||
Without the nth expression, \fB\-\-track\fR uses index\-based tracking that
|
||||
does not persist across reloads.
|
||||
|
||||
The info line shows \fB+T*\fR (or \fB+t*\fR for one\-off tracking) while
|
||||
the search is in progress.
|
||||
|
||||
.RS
|
||||
e.g.
|
||||
\fBgit log \-\-oneline \-\-graph \-\-color=always | nl |
|
||||
\fB# Index\-based tracking (does not persist across reloads)
|
||||
git log \-\-oneline \-\-graph \-\-color=always | nl |
|
||||
fzf \-\-ansi \-\-track \-\-no\-sort \-\-layout=reverse\-list\fR
|
||||
|
||||
\fB# Track by first field (e.g. pod name) across reloads
|
||||
kubectl get pods | fzf \-\-track=1 \-\-header\-lines=1 \\
|
||||
\-\-bind 'ctrl\-r:reload:kubectl get pods'\fR
|
||||
.RE
|
||||
.TP
|
||||
.B "\-\-tac"
|
||||
@@ -2007,6 +2027,7 @@ A key or an event can be bound to one or more of the following actions.
|
||||
\fBtoggle+down\fR \fIctrl\-i (tab)\fR
|
||||
\fBtoggle+up\fR \fIbtab (shift\-tab)\fR
|
||||
\fBtrack\-current\fR (track the current item; automatically disabled if focus changes)
|
||||
\fBtrack\-current(...)\fR (track the current item using the given nth expression as the tracking key)
|
||||
\fBtransform(...)\fR (transform states using the output of an external command)
|
||||
\fBtransform\-border\-label(...)\fR (transform border label using an external command)
|
||||
\fBtransform\-ghost(...)\fR (transform ghost text using an external command)
|
||||
|
||||
@@ -594,6 +594,7 @@ type Options struct {
|
||||
Sort int
|
||||
Raw bool
|
||||
Track trackOption
|
||||
TrackNth []Range
|
||||
Tac bool
|
||||
Tail int
|
||||
Criteria []criterion
|
||||
@@ -1610,7 +1611,7 @@ func parseWalkerOpts(str string) (walkerOpts, error) {
|
||||
}
|
||||
|
||||
var (
|
||||
executeRegexp *regexp.Regexp
|
||||
argActionRegexp *regexp.Regexp
|
||||
splitRegexp *regexp.Regexp
|
||||
actionNameRegexp *regexp.Regexp
|
||||
)
|
||||
@@ -1629,8 +1630,8 @@ const (
|
||||
)
|
||||
|
||||
func init() {
|
||||
executeRegexp = regexp.MustCompile(
|
||||
`(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|bg-transform|transform)-(?:query|prompt|(?:border|list|preview|input|header|footer)-label|header-lines|header|footer|search|with-nth|nth|pointer|ghost)|bg-transform|transform|change-(?:preview-window|preview|multi)|(?:re|un|toggle-)bind|pos|put|print|search|trigger)`)
|
||||
argActionRegexp = regexp.MustCompile(
|
||||
`(?si)[:+](become|execute(?:-multi|-silent)?|reload(?:-sync)?|preview|(?:change|bg-transform|transform)-(?:query|prompt|(?:border|list|preview|input|header|footer)-label|header-lines|header|footer|search|with-nth|nth|pointer|ghost)|bg-transform|transform|change-(?:preview-window|preview|multi)|(?:re|un|toggle-)bind|pos|put|print|search|trigger|track(?:-current)?)`)
|
||||
splitRegexp = regexp.MustCompile("[,:]+")
|
||||
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
|
||||
}
|
||||
@@ -1639,7 +1640,7 @@ func maskActionContents(action string) string {
|
||||
masked := ""
|
||||
Loop:
|
||||
for len(action) > 0 {
|
||||
loc := executeRegexp.FindStringIndex(action)
|
||||
loc := argActionRegexp.FindStringIndex(action)
|
||||
if loc == nil {
|
||||
masked += action
|
||||
break
|
||||
@@ -1694,7 +1695,7 @@ Loop:
|
||||
}
|
||||
|
||||
func parseSingleActionList(str string) ([]*action, error) {
|
||||
// We prepend a colon to satisfy executeRegexp and remove it later
|
||||
// We prepend a colon to satisfy argActionRegexp and remove it later
|
||||
masked := maskActionContents(":" + str)[1:]
|
||||
return parseActionList(masked, str, []*action{}, false)
|
||||
}
|
||||
@@ -1954,6 +1955,12 @@ func parseActionList(masked string, original string, prevActions []*action, putA
|
||||
if _, _, err := parseKeyChords(actionArg, spec[0:offset]+" target required"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case actTrackCurrent:
|
||||
if len(actionArg) > 0 {
|
||||
if _, err := splitNth(actionArg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case actChangePreviewWindow:
|
||||
opts := previewOpts{}
|
||||
for _, arg := range strings.Split(actionArg, "|") {
|
||||
@@ -2159,6 +2166,8 @@ func isExecuteAction(str string) actionType {
|
||||
return actTrigger
|
||||
case "search":
|
||||
return actSearch
|
||||
case "track", "track-current":
|
||||
return actTrackCurrent
|
||||
}
|
||||
return actIgnore
|
||||
}
|
||||
@@ -2809,8 +2818,18 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
||||
opts.Raw = false
|
||||
case "--track":
|
||||
opts.Track = trackEnabled
|
||||
if ok, str := optionalNextString(); ok {
|
||||
nth, err := splitNth(str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.TrackNth = nth
|
||||
} else {
|
||||
opts.TrackNth = nil
|
||||
}
|
||||
case "--no-track":
|
||||
opts.Track = trackDisabled
|
||||
opts.TrackNth = nil
|
||||
case "--tac":
|
||||
opts.Tac = true
|
||||
case "--no-tac":
|
||||
|
||||
111
src/terminal.go
111
src/terminal.go
@@ -314,6 +314,11 @@ type Terminal struct {
|
||||
sort bool
|
||||
toggleSort bool
|
||||
track trackOption
|
||||
trackNth []Range
|
||||
trackKey string
|
||||
trackBlocked bool
|
||||
trackSync bool
|
||||
trackKeyCache map[int32]bool
|
||||
targetIndex int32
|
||||
delimiter Delimiter
|
||||
expect map[tui.Event]string
|
||||
@@ -1043,6 +1048,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
|
||||
sort: opts.Sort > 0,
|
||||
toggleSort: opts.ToggleSort,
|
||||
track: opts.Track,
|
||||
trackNth: opts.TrackNth,
|
||||
targetIndex: minItem.Index(),
|
||||
delimiter: opts.Delimiter,
|
||||
expect: opts.Expect,
|
||||
@@ -1893,7 +1899,33 @@ func (t *Terminal) UpdateList(result MatchResult) {
|
||||
t.triggerLoad = false
|
||||
t.eventChan <- tui.Load.AsEvent()
|
||||
}
|
||||
if prevIndex >= 0 {
|
||||
// Search for the tracked item by nth key
|
||||
// - reload (async): search eagerly, unblock as soon as match is found
|
||||
// - reload-sync: wait until stream is complete before searching
|
||||
trackWasBlocked := t.trackBlocked
|
||||
if len(t.trackKey) > 0 && (!t.trackSync || !t.reading) {
|
||||
found := false
|
||||
for i := 0; i < t.merger.Length(); i++ {
|
||||
item := t.merger.Get(i).item
|
||||
idx := item.Index()
|
||||
match, ok := t.trackKeyCache[idx]
|
||||
if !ok {
|
||||
match = t.trackKeyFor(item, t.trackNth) == t.trackKey
|
||||
t.trackKeyCache[idx] = match
|
||||
}
|
||||
if match {
|
||||
t.cy = i
|
||||
if t.track.Current() {
|
||||
t.track.index = idx
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found || !t.reading {
|
||||
t.unblockTrack()
|
||||
}
|
||||
} else if prevIndex >= 0 {
|
||||
pos := t.cy - t.offset
|
||||
count := t.merger.Length()
|
||||
i := t.merger.FindIndex(prevIndex)
|
||||
@@ -1929,9 +1961,17 @@ func (t *Terminal) UpdateList(result MatchResult) {
|
||||
if t.hasResultActions {
|
||||
t.eventChan <- tui.Result.AsEvent()
|
||||
}
|
||||
updateList := !t.trackBlocked
|
||||
updatePrompt := trackWasBlocked && !t.trackBlocked
|
||||
t.mutex.Unlock()
|
||||
|
||||
t.reqBox.Set(reqInfo, nil)
|
||||
t.reqBox.Set(reqList, nil)
|
||||
if updateList {
|
||||
t.reqBox.Set(reqList, nil)
|
||||
}
|
||||
if updatePrompt {
|
||||
t.reqBox.Set(reqPrompt, nil)
|
||||
}
|
||||
if needActivation {
|
||||
t.reqBox.Set(reqActivate, nil)
|
||||
}
|
||||
@@ -2880,6 +2920,8 @@ func (t *Terminal) printPrompt() {
|
||||
color := tui.ColInput
|
||||
if t.paused {
|
||||
color = tui.ColDisabled
|
||||
} else if t.trackBlocked {
|
||||
color = color.WithAttr(tui.Dim)
|
||||
}
|
||||
w.CPrint(color, string(before))
|
||||
w.CPrint(color, string(after))
|
||||
@@ -2971,9 +3013,17 @@ func (t *Terminal) printInfoImpl() {
|
||||
}
|
||||
}
|
||||
if t.track.Global() {
|
||||
output += " +T"
|
||||
if t.trackBlocked {
|
||||
output += " +T*"
|
||||
} else {
|
||||
output += " +T"
|
||||
}
|
||||
} else if t.track.Current() {
|
||||
output += " +t"
|
||||
if t.trackBlocked {
|
||||
output += " +t*"
|
||||
} else {
|
||||
output += " +t"
|
||||
}
|
||||
}
|
||||
if t.multi > 0 {
|
||||
if t.multi == maxMulti {
|
||||
@@ -5366,6 +5416,22 @@ func (t *Terminal) currentIndex() int32 {
|
||||
return minItem.Index()
|
||||
}
|
||||
|
||||
func (t *Terminal) trackKeyFor(item *Item, nth []Range) string {
|
||||
tokens := Tokenize(item.AsString(t.ansi), t.delimiter)
|
||||
return StripLastDelimiter(JoinTokens(Transform(tokens, nth)), t.delimiter)
|
||||
}
|
||||
|
||||
func (t *Terminal) unblockTrack() {
|
||||
if t.trackBlocked {
|
||||
t.trackBlocked = false
|
||||
t.trackKey = ""
|
||||
t.trackKeyCache = nil
|
||||
if !t.inputless {
|
||||
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'
|
||||
@@ -6187,6 +6253,14 @@ func (t *Terminal) Loop() error {
|
||||
callback(a.a)
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
t.unblockTrack()
|
||||
req(reqPrompt, reqInfo)
|
||||
}
|
||||
return true
|
||||
}
|
||||
Action:
|
||||
switch a.t {
|
||||
case actIgnore, actStart, actClick:
|
||||
@@ -6958,14 +7032,16 @@ func (t *Terminal) Loop() error {
|
||||
case trackDisabled:
|
||||
t.track = trackEnabled
|
||||
}
|
||||
req(reqInfo)
|
||||
t.unblockTrack()
|
||||
req(reqPrompt, reqInfo)
|
||||
case actToggleTrackCurrent:
|
||||
if t.track.Current() {
|
||||
t.track = trackDisabled
|
||||
} else if t.track.Disabled() {
|
||||
t.track = trackCurrent(t.currentIndex())
|
||||
}
|
||||
req(reqInfo)
|
||||
t.unblockTrack()
|
||||
req(reqPrompt, reqInfo)
|
||||
case actShowHeader:
|
||||
t.headerVisible = true
|
||||
req(reqList, reqInfo, reqPrompt, reqHeader)
|
||||
@@ -7023,12 +7099,19 @@ func (t *Terminal) Loop() error {
|
||||
if !t.track.Global() {
|
||||
t.track = trackCurrent(t.currentIndex())
|
||||
}
|
||||
// Parse optional nth argument: track-current(1) or track-current(1,3)
|
||||
if len(a.a) > 0 {
|
||||
if nth, err := splitNth(a.a); err == nil {
|
||||
t.trackNth = nth
|
||||
}
|
||||
}
|
||||
req(reqInfo)
|
||||
case actUntrackCurrent:
|
||||
if t.track.Current() {
|
||||
t.track = trackDisabled
|
||||
}
|
||||
req(reqInfo)
|
||||
t.unblockTrack()
|
||||
req(reqPrompt, reqInfo)
|
||||
case actSearch:
|
||||
override := []rune(a.a)
|
||||
t.inputOverride = &override
|
||||
@@ -7379,6 +7462,20 @@ func (t *Terminal) Loop() error {
|
||||
newCommand = &commandSpec{command, tempFiles}
|
||||
reloadSync = a.t == actReloadSync
|
||||
t.reading = true
|
||||
|
||||
// Capture tracking key before reload
|
||||
if !t.track.Disabled() && len(t.trackNth) > 0 {
|
||||
if item := t.currentItem(); item != nil {
|
||||
t.trackKey = t.trackKeyFor(item, t.trackNth)
|
||||
t.trackKeyCache = make(map[int32]bool)
|
||||
t.trackBlocked = true
|
||||
t.trackSync = reloadSync
|
||||
if !t.inputless {
|
||||
t.tui.HideCursor()
|
||||
}
|
||||
req(reqPrompt, reqInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
case actUnbind:
|
||||
if keys, _, err := parseKeyChords(a.a, "PANIC"); err == nil {
|
||||
|
||||
@@ -1659,6 +1659,212 @@ class TestCore < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_whole_line
|
||||
# --track=.. should track by entire line across reloads
|
||||
tmux.send_keys "seq 1000 | #{FZF} --track=.. --bind 'ctrl-r:reload:seq 1000 | sort -R'", :Enter
|
||||
tmux.until { |lines| assert_equal 1000, lines.match_count }
|
||||
|
||||
# Move to item 555
|
||||
tmux.send_keys '555'
|
||||
tmux.until do |lines|
|
||||
assert_equal 1, lines.match_count
|
||||
assert_includes lines, '> 555'
|
||||
end
|
||||
tmux.send_keys :BSpace, :BSpace, :BSpace
|
||||
|
||||
# Reload with shuffled order — cursor should track "555"
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 1000, lines.match_count
|
||||
assert_includes lines, '> 555'
|
||||
assert_includes lines[-2], '+T'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_field
|
||||
# --track=1 should track by first field across reloads
|
||||
tmux.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{FZF} --track=1 --bind 'ctrl-r:reload:printf \"1 apricot\\n2 blueberry\\n3 cranberry\\n\"'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> 1 apple'
|
||||
end
|
||||
|
||||
# Move up to "2 banana"
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 banana' }
|
||||
|
||||
# Reload — the second field changes, but first field "2" stays
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> 2 blueberry'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_no_match
|
||||
# When tracked item is not found after reload, cursor stays at current position
|
||||
tmux.send_keys "printf 'alpha\\nbeta\\ngamma\\n' | #{FZF} --track=.. --bind 'ctrl-r:reload:printf \"delta\\nepsilon\\nzeta\\n\"'", :Enter
|
||||
tmux.until { |lines| assert_equal 3, lines.match_count }
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> beta' }
|
||||
|
||||
# Reload with completely different items — no match for "beta"
|
||||
# Cursor stays at the same position (second item)
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> epsilon'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_blocked_indicator
|
||||
# +T* should appear during reload and disappear when match is found
|
||||
tmux.send_keys "seq 100 | #{FZF} --track=.. --bind 'ctrl-r:reload:sleep 1; seq 100 | sort -R'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 100, lines.match_count
|
||||
assert_includes lines[-2], '+T'
|
||||
end
|
||||
|
||||
# Trigger slow reload — should show +T* while blocked
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
|
||||
# After reload completes, +T* should clear back to +T
|
||||
tmux.until do |lines|
|
||||
assert_equal 100, lines.match_count
|
||||
assert_includes lines[-2], '+T'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_abort_unblocks
|
||||
# Escape during track-blocked state should unblock, not quit
|
||||
tmux.send_keys "seq 100 | #{FZF} --track=.. --bind 'ctrl-r:reload:sleep 3; seq 100'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 100, lines.match_count
|
||||
assert_includes lines[-2], '+T'
|
||||
end
|
||||
|
||||
# Trigger slow reload
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
|
||||
# Escape should unblock, not quit fzf
|
||||
tmux.send_keys :Escape
|
||||
tmux.until do |lines|
|
||||
assert_includes lines[-2], '+T'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_async_unblocks_early
|
||||
# With async reload, +T* should clear as soon as the match streams in,
|
||||
# even while loading is still in progress.
|
||||
# sleep 1 first so +T* is observable, then the match arrives, then more items after a delay.
|
||||
tmux.send_keys "seq 5 | #{FZF} --track=.. --bind 'ctrl-r:reload:sleep 1; echo 1; sleep 2; seq 2 10'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 5, lines.match_count
|
||||
assert_includes lines, '> 1'
|
||||
end
|
||||
|
||||
# Trigger reload — blocked during initial sleep
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
# Match "1" arrives, unblocks before the remaining items load
|
||||
tmux.until do |lines|
|
||||
assert_equal 1, lines.match_count
|
||||
assert_includes lines, '> 1'
|
||||
assert_includes lines[-2], '+T'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_sync_blocks_until_complete
|
||||
# With reload-sync, +T* should stay until the entire stream is complete,
|
||||
# even though the match arrives early in the stream.
|
||||
tmux.send_keys "seq 5 | #{FZF} --track=.. --bind 'ctrl-r:reload-sync:sleep 1; echo 1; sleep 2; seq 2 10'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 5, lines.match_count
|
||||
assert_includes lines, '> 1'
|
||||
end
|
||||
|
||||
# Trigger reload-sync — every observable state must be either:
|
||||
# 1. +T* (still blocked), or
|
||||
# 2. final state (count=10, +T without *)
|
||||
# Any other combination (e.g. unblocked while count < 10) is a bug.
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
info = lines[-2]
|
||||
blocked = info&.include?('+T*')
|
||||
unless blocked
|
||||
raise "Unblocked before stream complete (count: #{lines.match_count})" if lines.match_count != 10
|
||||
|
||||
assert_includes info, '+T'
|
||||
assert_includes lines, '> 1'
|
||||
end
|
||||
!blocked
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_toggle_track_unblocks
|
||||
# toggle-track during track-blocked state should unblock and disable tracking
|
||||
tmux.send_keys "seq 100 | #{FZF} --track=.. --bind 'ctrl-r:reload:sleep 5; seq 100' --bind 'ctrl-t:toggle-track'", :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal 100, lines.match_count
|
||||
assert_includes lines[-2], '+T'
|
||||
end
|
||||
|
||||
# Trigger slow reload
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
|
||||
# toggle-track should unblock and disable tracking before reload completes
|
||||
tmux.send_keys 'C-t'
|
||||
tmux.until(timeout: 3) do |lines|
|
||||
refute_includes lines[-2], '+T'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_nth_reload_async_no_match
|
||||
# With async reload, when tracked item is not found, cursor stays at
|
||||
# current position after stream completes
|
||||
tmux.send_keys "printf 'alpha\\nbeta\\ngamma\\n' | #{FZF} --track=.. --bind 'ctrl-r:reload:sleep 1; printf \"delta\\nepsilon\\nzeta\\n\"'", :Enter
|
||||
tmux.until { |lines| assert_equal 3, lines.match_count }
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> beta' }
|
||||
|
||||
# Reload with completely different items — no match for "beta"
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
# After stream completes, unblocks with cursor at same position (second item)
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> epsilon'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
end
|
||||
|
||||
def test_track_action_with_nth
|
||||
# track(1) action should track by first field
|
||||
tmux.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{FZF} --bind 'ctrl-t:track(1),ctrl-r:reload:printf \"1 apricot\\n2 blueberry\\n3 cranberry\\n\"'", :Enter
|
||||
tmux.until { |lines| assert_equal 3, lines.match_count }
|
||||
|
||||
# Move to "2 banana" and activate field tracking
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 banana' }
|
||||
tmux.send_keys 'C-t'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+t' }
|
||||
|
||||
# Reload — should track by field "2"
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until do |lines|
|
||||
assert_equal 3, lines.match_count
|
||||
assert_includes lines, '> 2 blueberry'
|
||||
end
|
||||
end
|
||||
|
||||
def test_one_and_zero
|
||||
tmux.send_keys "seq 10 | #{FZF} --bind 'zero:preview(echo no match),one:preview(echo {} is the only match)'", :Enter
|
||||
tmux.send_keys '1'
|
||||
|
||||
Reference in New Issue
Block a user