Replace --track=NTH with --id-nth for cross-reload item identity

Separate item identity from cursor tracking:
- Add --id-nth=NTH to define item identity fields for cross-reload ops
- --track reverts to a simple boolean flag
- track-current action no longer accepts nth argument
- With --multi, selections are preserved across reload-sync by matching
  identity keys in the reloaded list

Close #4718
Close #4701
Close #4483
Close #4409
Close #3460
Close #2441
This commit is contained in:
Junegunn Choi
2026-03-14 21:49:16 +09:00
parent e6b9a08699
commit b5f7221580
5 changed files with 118 additions and 72 deletions
+9 -9
View File
@@ -3,16 +3,16 @@ CHANGELOG
0.71.0 0.71.0
------ ------
- Cross-reload tracking - Cross-reload item identity with `--id-nth`
- `--track` now accepts an optional nth expression (`--track=NTH`) for field-based tracking across `reload`s - Added `--id-nth=NTH` to define item identity fields for cross-reload operations
- `--track` without `NTH` retains the existing index-based tracking behavior (does not persist across reloads) - When a `reload` is triggered with tracking enabled, fzf searches for the tracked item by its identity fields in the new list.
- `--track=..` tracks by the entire line across reloads - `--track --id-nth ..` tracks by the entire line
- `--track=1` tracks by the first field across reloads - `--track --id-nth 1` tracks by the first field
- When a `reload` is triggered, fzf searches for the tracked item by its nth field in the new list. - `--track` without `--id-nth` retains the existing index-based tracking behavior
- The UI is temporarily blocked (prompt dimmed, input disabled) until the item is found or loading completes. - 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 - Press `Escape` or `Ctrl-C` to cancel the blocked state without quitting
- Info line shows `+T*` / `+t*` while searching - Info line shows `+T*` / `+t*` while searching
- `track-current` action now also accepts an optional nth argument: `track-current(1)` - With `--multi`, selected items are preserved across `reload-sync` by matching their identity fields
- Performance improvements - 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. - The search performance now scales linearly with the number of CPU cores, as we dropped static partitioning to allow better load balancing across threads.
``` ```
+31 -16
View File
@@ -617,28 +617,18 @@ Disable multi-line display of items when using \fB\-\-read0\fR
.B "\-\-raw" .B "\-\-raw"
Enable raw mode where non-matching items are also displayed in a dimmed color. Enable raw mode where non-matching items are also displayed in a dimmed color.
.TP .TP
.BI "\-\-track" "[=NTH]" .BI "\-\-track"
Make fzf track the current selection when the result list is updated. 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 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 not recommended to use this option with \fB\-\-tac\fR as the resulting behavior
can be confusing. can be confusing.
When an nth expression is explicitly given (e.g. \fB\-\-track=..\fR or When \fB\-\-id\-nth\fR is also set, fzf enables field\-based tracking across
\fB\-\-track=1\fR), fzf enables field\-based tracking across \fBreload\fRs. \fBreload\fRs. See \fB\-\-id\-nth\fR for details.
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 Without \fB\-\-id\-nth\fR, \fB\-\-track\fR uses index\-based tracking that
does not persist across reloads. 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 .RS
e.g. e.g.
\fB# Index\-based tracking (does not persist across reloads) \fB# Index\-based tracking (does not persist across reloads)
@@ -646,10 +636,36 @@ e.g.
fzf \-\-ansi \-\-track \-\-no\-sort \-\-layout=reverse\-list\fR fzf \-\-ansi \-\-track \-\-no\-sort \-\-layout=reverse\-list\fR
\fB# Track by first field (e.g. pod name) across reloads \fB# Track by first field (e.g. pod name) across reloads
kubectl get pods | fzf \-\-track=1 \-\-header\-lines=1 \\ kubectl get pods | fzf \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
\-\-bind 'ctrl\-r:reload:kubectl get pods'\fR \-\-bind 'ctrl\-r:reload:kubectl get pods'\fR
.RE .RE
.TP .TP
.BI "\-\-id\-nth=" "N[,..]"
Define item identity fields for cross\-reload operations. When set, fzf
uses the specified fields to identify items across \fBreload\fR and
\fBreload\-sync\fR.
With \fB\-\-track\fR, 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.
The info line shows \fB+T*\fR (or \fB+t*\fR for one\-off tracking) while
the search is in progress.
With \fB\-\-multi\fR, selected items are preserved across \fBreload\-sync\fR
by matching their identity fields in the reloaded list.
.RS
e.g.
\fB# Track and preserve selections by pod name across reloads
kubectl get pods | fzf \-\-multi \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
\-\-bind 'ctrl\-r:reload\-sync:kubectl get pods'\fR
.RE
.TP
.B "\-\-tac" .B "\-\-tac"
Reverse the order of the input Reverse the order of the input
@@ -2027,7 +2043,6 @@ A key or an event can be bound to one or more of the following actions.
\fBtoggle+down\fR \fIctrl\-i (tab)\fR \fBtoggle+down\fR \fIctrl\-i (tab)\fR
\fBtoggle+up\fR \fIbtab (shift\-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; 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(...)\fR (transform states using the output of an external command)
\fBtransform\-border\-label(...)\fR (transform border label using an external command) \fBtransform\-border\-label(...)\fR (transform border label using an external command)
\fBtransform\-ghost(...)\fR (transform ghost text using an external command) \fBtransform\-ghost(...)\fR (transform ghost text using an external command)
+13 -20
View File
@@ -101,6 +101,7 @@ Usage: fzf [options]
--no-multi-line Disable multi-line display of items when using --read0 --no-multi-line Disable multi-line display of items when using --read0
--raw Enable raw mode (show non-matching items) --raw Enable raw mode (show non-matching items)
--track Track the current selection when the result is updated --track Track the current selection when the result is updated
--id-nth=N[,..] Define item identity fields for cross-reload operations
--tac Reverse the order of the input --tac Reverse the order of the input
--gap[=N] Render empty lines between each item --gap[=N] Render empty lines between each item
--gap-line[=STR] Draw horizontal line on each gap using the string --gap-line[=STR] Draw horizontal line on each gap using the string
@@ -594,7 +595,7 @@ type Options struct {
Sort int Sort int
Raw bool Raw bool
Track trackOption Track trackOption
TrackNth []Range IdNth []Range
Tac bool Tac bool
Tail int Tail int
Criteria []criterion Criteria []criterion
@@ -1631,7 +1632,7 @@ const (
func init() { func init() {
argActionRegexp = regexp.MustCompile( 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)?)`) `(?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)`)
splitRegexp = regexp.MustCompile("[,:]+") splitRegexp = regexp.MustCompile("[,:]+")
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+") actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
} }
@@ -1955,12 +1956,6 @@ func parseActionList(masked string, original string, prevActions []*action, putA
if _, _, err := parseKeyChords(actionArg, spec[0:offset]+" target required"); err != nil { if _, _, err := parseKeyChords(actionArg, spec[0:offset]+" target required"); err != nil {
return nil, err return nil, err
} }
case actTrackCurrent:
if len(actionArg) > 0 {
if _, err := splitNth(actionArg); err != nil {
return nil, err
}
}
case actChangePreviewWindow: case actChangePreviewWindow:
opts := previewOpts{} opts := previewOpts{}
for _, arg := range strings.Split(actionArg, "|") { for _, arg := range strings.Split(actionArg, "|") {
@@ -2166,8 +2161,6 @@ func isExecuteAction(str string) actionType {
return actTrigger return actTrigger
case "search": case "search":
return actSearch return actSearch
case "track", "track-current":
return actTrackCurrent
} }
return actIgnore return actIgnore
} }
@@ -2818,18 +2811,18 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Raw = false opts.Raw = false
case "--track": case "--track":
opts.Track = trackEnabled 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": case "--no-track":
opts.Track = trackDisabled opts.Track = trackDisabled
opts.TrackNth = nil case "--id-nth":
str, err := nextString("nth expression required")
if err != nil {
return err
}
if opts.IdNth, err = splitNth(str); err != nil {
return err
}
case "--no-id-nth":
opts.IdNth = nil
case "--tac": case "--tac":
opts.Tac = true opts.Tac = true
case "--no-tac": case "--no-tac":
+26 -12
View File
@@ -314,11 +314,12 @@ type Terminal struct {
sort bool sort bool
toggleSort bool toggleSort bool
track trackOption track trackOption
trackNth []Range idNth []Range
trackKey string trackKey string
trackBlocked bool trackBlocked bool
trackSync bool trackSync bool
trackKeyCache map[int32]bool trackKeyCache map[int32]bool
pendingSelections map[string]selectedItem
targetIndex int32 targetIndex int32
delimiter Delimiter delimiter Delimiter
expect map[tui.Event]string expect map[tui.Event]string
@@ -1049,7 +1050,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
sort: opts.Sort > 0, sort: opts.Sort > 0,
toggleSort: opts.ToggleSort, toggleSort: opts.ToggleSort,
track: opts.Track, track: opts.Track,
trackNth: opts.TrackNth, idNth: opts.IdNth,
targetIndex: minItem.Index(), targetIndex: minItem.Index(),
delimiter: opts.Delimiter, delimiter: opts.Delimiter,
expect: opts.Expect, expect: opts.Expect,
@@ -1857,7 +1858,14 @@ func (t *Terminal) UpdateList(result MatchResult) {
} }
if t.revision != newRevision { if t.revision != newRevision {
if !t.revision.compatible(newRevision) { if !t.revision.compatible(newRevision) {
// Reloaded: clear selection // Reloaded: capture selection keys for restoration, then clear
if len(t.idNth) > 0 && t.multi > 0 && len(t.selected) > 0 {
t.pendingSelections = make(map[string]selectedItem, len(t.selected))
for _, sel := range t.selected {
key := t.trackKeyFor(sel.item, t.idNth)
t.pendingSelections[key] = sel
}
}
t.selected = make(map[int32]selectedItem) t.selected = make(map[int32]selectedItem)
t.clearNumLinesCache() t.clearNumLinesCache()
} else { } else {
@@ -1912,7 +1920,7 @@ func (t *Terminal) UpdateList(result MatchResult) {
idx := item.Index() idx := item.Index()
match, ok := t.trackKeyCache[idx] match, ok := t.trackKeyCache[idx]
if !ok { if !ok {
match = t.trackKeyFor(item, t.trackNth) == t.trackKey match = t.trackKeyFor(item, t.idNth) == t.trackKey
t.trackKeyCache[idx] = match t.trackKeyCache[idx] = match
} }
if match { if match {
@@ -1943,6 +1951,18 @@ func (t *Terminal) UpdateList(result MatchResult) {
t.cy = count - min(count, t.maxItems()) + pos t.cy = count - min(count, t.maxItems()) + pos
} }
} }
// Restore selections by id-nth key after reload completes
if !t.reading && t.pendingSelections != nil {
for i := 0; i < t.merger.Length() && len(t.pendingSelections) > 0; i++ {
item := t.merger.Get(i).item
key := t.trackKeyFor(item, t.idNth)
if sel, found := t.pendingSelections[key]; found {
t.selected[item.Index()] = selectedItem{sel.at, item}
delete(t.pendingSelections, key)
}
}
t.pendingSelections = nil
}
needActivation := false needActivation := false
if !t.reading { if !t.reading {
switch t.resultMerger.Length() { switch t.resultMerger.Length() {
@@ -7104,12 +7124,6 @@ func (t *Terminal) Loop() error {
if !t.track.Global() { if !t.track.Global() {
t.track = trackCurrent(t.currentIndex()) 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) req(reqInfo)
case actUntrackCurrent: case actUntrackCurrent:
if t.track.Current() { if t.track.Current() {
@@ -7469,9 +7483,9 @@ func (t *Terminal) Loop() error {
t.reading = true t.reading = true
// Capture tracking key before reload // Capture tracking key before reload
if !t.track.Disabled() && len(t.trackNth) > 0 { if !t.track.Disabled() && len(t.idNth) > 0 {
if item := t.currentItem(); item != nil { if item := t.currentItem(); item != nil {
t.trackKey = t.trackKeyFor(item, t.trackNth) t.trackKey = t.trackKeyFor(item, t.idNth)
t.trackKeyCache = make(map[int32]bool) t.trackKeyCache = make(map[int32]bool)
t.trackBlocked = true t.trackBlocked = true
t.trackSync = reloadSync t.trackSync = reloadSync
+39 -15
View File
@@ -1660,8 +1660,8 @@ class TestCore < TestInteractive
end end
def test_track_nth_reload_whole_line def test_track_nth_reload_whole_line
# --track=.. should track by entire line across reloads # --track --id-nth .. should track by entire line across reloads
tmux.send_keys "seq 1000 | #{FZF} --track=.. --bind 'ctrl-r:reload:seq 1000 | sort -R'", :Enter tmux.send_keys "seq 1000 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:seq 1000 | sort -R'", :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count } tmux.until { |lines| assert_equal 1000, lines.match_count }
# Move to item 555 # Move to item 555
@@ -1683,8 +1683,8 @@ class TestCore < TestInteractive
end end
def test_track_nth_reload_field def test_track_nth_reload_field
# --track=1 should track by first field across reloads # --track --id-nth 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.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{FZF} --track --id-nth 1 --bind 'ctrl-r:reload:printf \"1 apricot\\n2 blueberry\\n3 cranberry\\n\"'", :Enter
tmux.until do |lines| tmux.until do |lines|
assert_equal 3, lines.match_count assert_equal 3, lines.match_count
assert_includes lines, '> 1 apple' assert_includes lines, '> 1 apple'
@@ -1704,7 +1704,7 @@ class TestCore < TestInteractive
def test_track_nth_reload_no_match def test_track_nth_reload_no_match
# When tracked item is not found after reload, cursor stays at current position # 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.send_keys "printf 'alpha\\nbeta\\ngamma\\n' | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:printf \"delta\\nepsilon\\nzeta\\n\"'", :Enter
tmux.until { |lines| assert_equal 3, lines.match_count } tmux.until { |lines| assert_equal 3, lines.match_count }
tmux.send_keys :Up tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> beta' } tmux.until { |lines| assert_includes lines, '> beta' }
@@ -1721,7 +1721,7 @@ class TestCore < TestInteractive
def test_track_nth_blocked_indicator def test_track_nth_blocked_indicator
# +T* should appear during reload and disappear when match is found # +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.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 1; seq 100 | sort -R'", :Enter
tmux.until do |lines| tmux.until do |lines|
assert_equal 100, lines.match_count assert_equal 100, lines.match_count
assert_includes lines[-2], '+T' assert_includes lines[-2], '+T'
@@ -1741,7 +1741,7 @@ class TestCore < TestInteractive
def test_track_nth_abort_unblocks def test_track_nth_abort_unblocks
# Escape during track-blocked state should unblock, not quit # 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.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 3; seq 100'", :Enter
tmux.until do |lines| tmux.until do |lines|
assert_equal 100, lines.match_count assert_equal 100, lines.match_count
assert_includes lines[-2], '+T' assert_includes lines[-2], '+T'
@@ -1763,7 +1763,7 @@ class TestCore < TestInteractive
# With async reload, +T* should clear as soon as the match streams in, # With async reload, +T* should clear as soon as the match streams in,
# even while loading is still in progress. # even while loading is still in progress.
# sleep 1 first so +T* is observable, then the match arrives, then more items after a delay. # 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.send_keys "seq 5 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 1; echo 1; sleep 2; seq 2 10'", :Enter
tmux.until do |lines| tmux.until do |lines|
assert_equal 5, lines.match_count assert_equal 5, lines.match_count
assert_includes lines, '> 1' assert_includes lines, '> 1'
@@ -1784,7 +1784,7 @@ class TestCore < TestInteractive
def test_track_nth_reload_sync_blocks_until_complete def test_track_nth_reload_sync_blocks_until_complete
# With reload-sync, +T* should stay until the entire stream is complete, # With reload-sync, +T* should stay until the entire stream is complete,
# even though the match arrives early in the stream. # 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.send_keys "seq 5 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload-sync:sleep 1; echo 1; sleep 2; seq 2 10'", :Enter
tmux.until do |lines| tmux.until do |lines|
assert_equal 5, lines.match_count assert_equal 5, lines.match_count
assert_includes lines, '> 1' assert_includes lines, '> 1'
@@ -1810,7 +1810,7 @@ class TestCore < TestInteractive
def test_track_nth_toggle_track_unblocks def test_track_nth_toggle_track_unblocks
# toggle-track during track-blocked state should unblock and disable tracking # 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.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 5; seq 100' --bind 'ctrl-t:toggle-track'", :Enter
tmux.until do |lines| tmux.until do |lines|
assert_equal 100, lines.match_count assert_equal 100, lines.match_count
assert_includes lines[-2], '+T' assert_includes lines[-2], '+T'
@@ -1830,7 +1830,7 @@ class TestCore < TestInteractive
def test_track_nth_reload_async_no_match def test_track_nth_reload_async_no_match
# With async reload, when tracked item is not found, cursor stays at # With async reload, when tracked item is not found, cursor stays at
# current position after stream completes # 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.send_keys "printf 'alpha\\nbeta\\ngamma\\n' | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 1; printf \"delta\\nepsilon\\nzeta\\n\"'", :Enter
tmux.until { |lines| assert_equal 3, lines.match_count } tmux.until { |lines| assert_equal 3, lines.match_count }
tmux.send_keys :Up tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> beta' } tmux.until { |lines| assert_includes lines, '> beta' }
@@ -1846,12 +1846,12 @@ class TestCore < TestInteractive
end end
end end
def test_track_action_with_nth def test_track_action_with_id_nth
# track(1) action should track by first field # track-current with --id-nth should track by specified 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.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{FZF} --id-nth 1 --bind 'ctrl-t:track-current,ctrl-r:reload:printf \"1 apricot\\n2 blueberry\\n3 cranberry\\n\"'", :Enter
tmux.until { |lines| assert_equal 3, lines.match_count } tmux.until { |lines| assert_equal 3, lines.match_count }
# Move to "2 banana" and activate field tracking # Move to "2 banana" and activate tracking
tmux.send_keys :Up tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> 2 banana' } tmux.until { |lines| assert_includes lines, '> 2 banana' }
tmux.send_keys 'C-t' tmux.send_keys 'C-t'
@@ -1865,6 +1865,30 @@ class TestCore < TestInteractive
end end
end end
def test_id_nth_preserve_multi_selection
# --id-nth with --multi should preserve selections across reload-sync
File.write(tempname, "1 apricot\n2 blueberry\n3 cranberry\n")
tmux.send_keys "printf '1 apple\\n2 banana\\n3 cherry\\n' | #{fzf("--multi --id-nth 1 --bind 'ctrl-r:reload-sync:cat #{tempname}'")}", :Enter
tmux.until { |lines| assert_equal 3, lines.match_count }
# Select first item (1 apple) and third item (3 cherry)
tmux.send_keys :Tab
tmux.send_keys :Up, :Up, :Tab
tmux.until { |lines| assert_includes lines[-2], '(2)' }
# Reload — selections should be preserved by id-nth key
tmux.send_keys 'C-r'
tmux.until do |lines|
assert_equal 3, lines.match_count
assert_includes lines[-2], '(2)'
assert(lines.any? { |l| l.include?('apricot') })
end
# Accept and verify the correct items were preserved
tmux.send_keys :Enter
assert_equal ['1 apricot', '3 cranberry'], fzf_output_lines
end
def test_one_and_zero 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 "seq 10 | #{FZF} --bind 'zero:preview(echo no match),one:preview(echo {} is the only match)'", :Enter
tmux.send_keys '1' tmux.send_keys '1'