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

View File

@@ -3,16 +3,16 @@ CHANGELOG
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.
- Cross-reload item identity with `--id-nth`
- Added `--id-nth=NTH` to define item identity fields for cross-reload operations
- When a `reload` is triggered with tracking enabled, fzf searches for the tracked item by its identity fields in the new list.
- `--track --id-nth ..` tracks by the entire line
- `--track --id-nth 1` tracks by the first field
- `--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.
- 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)`
- Press `Escape` or `Ctrl-C` to cancel the blocked state without quitting
- Info line shows `+T*` / `+t*` while searching
- With `--multi`, selected items are preserved across `reload-sync` by matching their identity fields
- 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.
```

View File

@@ -617,28 +617,18 @@ 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
.BI "\-\-track" "[=NTH]"
.BI "\-\-track"
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.
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.
When \fB\-\-id\-nth\fR is also set, fzf enables field\-based tracking across
\fBreload\fRs. See \fB\-\-id\-nth\fR for details.
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.
The info line shows \fB+T*\fR (or \fB+t*\fR for one\-off tracking) while
the search is in progress.
.RS
e.g.
\fB# Index\-based tracking (does not persist across reloads)
@@ -646,10 +636,36 @@ e.g.
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 \\
kubectl get pods | fzf \-\-track \-\-id\-nth 1 \-\-header\-lines=1 \\
\-\-bind 'ctrl\-r:reload:kubectl get pods'\fR
.RE
.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"
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+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)

View File

@@ -101,6 +101,7 @@ Usage: fzf [options]
--no-multi-line Disable multi-line display of items when using --read0
--raw Enable raw mode (show non-matching items)
--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
--gap[=N] Render empty lines between each item
--gap-line[=STR] Draw horizontal line on each gap using the string
@@ -594,7 +595,7 @@ type Options struct {
Sort int
Raw bool
Track trackOption
TrackNth []Range
IdNth []Range
Tac bool
Tail int
Criteria []criterion
@@ -1631,7 +1632,7 @@ const (
func init() {
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("[,:]+")
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 {
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, "|") {
@@ -2166,8 +2161,6 @@ func isExecuteAction(str string) actionType {
return actTrigger
case "search":
return actSearch
case "track", "track-current":
return actTrackCurrent
}
return actIgnore
}
@@ -2818,18 +2811,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 "--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":
opts.Tac = true
case "--no-tac":

View File

@@ -314,11 +314,12 @@ type Terminal struct {
sort bool
toggleSort bool
track trackOption
trackNth []Range
idNth []Range
trackKey string
trackBlocked bool
trackSync bool
trackKeyCache map[int32]bool
pendingSelections map[string]selectedItem
targetIndex int32
delimiter Delimiter
expect map[tui.Event]string
@@ -1049,7 +1050,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
sort: opts.Sort > 0,
toggleSort: opts.ToggleSort,
track: opts.Track,
trackNth: opts.TrackNth,
idNth: opts.IdNth,
targetIndex: minItem.Index(),
delimiter: opts.Delimiter,
expect: opts.Expect,
@@ -1857,7 +1858,14 @@ func (t *Terminal) UpdateList(result MatchResult) {
}
if t.revision != 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.clearNumLinesCache()
} else {
@@ -1912,7 +1920,7 @@ func (t *Terminal) UpdateList(result MatchResult) {
idx := item.Index()
match, ok := t.trackKeyCache[idx]
if !ok {
match = t.trackKeyFor(item, t.trackNth) == t.trackKey
match = t.trackKeyFor(item, t.idNth) == t.trackKey
t.trackKeyCache[idx] = match
}
if match {
@@ -1943,6 +1951,18 @@ func (t *Terminal) UpdateList(result MatchResult) {
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
if !t.reading {
switch t.resultMerger.Length() {
@@ -7104,12 +7124,6 @@ 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() {
@@ -7469,9 +7483,9 @@ func (t *Terminal) Loop() error {
t.reading = true
// 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 {
t.trackKey = t.trackKeyFor(item, t.trackNth)
t.trackKey = t.trackKeyFor(item, t.idNth)
t.trackKeyCache = make(map[int32]bool)
t.trackBlocked = true
t.trackSync = reloadSync

View File

@@ -1660,8 +1660,8 @@ class TestCore < TestInteractive
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
# --track --id-nth .. should track by entire line across reloads
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 }
# Move to item 555
@@ -1683,8 +1683,8 @@ class TestCore < TestInteractive
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
# --track --id-nth 1 should track by first field across reloads
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|
assert_equal 3, lines.match_count
assert_includes lines, '> 1 apple'
@@ -1704,7 +1704,7 @@ class TestCore < TestInteractive
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.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.send_keys :Up
tmux.until { |lines| assert_includes lines, '> beta' }
@@ -1721,7 +1721,7 @@ class TestCore < TestInteractive
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.send_keys "seq 100 | #{FZF} --track --id-nth .. --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'
@@ -1741,7 +1741,7 @@ class TestCore < TestInteractive
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.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:reload:sleep 3; seq 100'", :Enter
tmux.until do |lines|
assert_equal 100, lines.match_count
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,
# 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.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|
assert_equal 5, lines.match_count
assert_includes lines, '> 1'
@@ -1784,7 +1784,7 @@ class TestCore < TestInteractive
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.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|
assert_equal 5, lines.match_count
assert_includes lines, '> 1'
@@ -1810,7 +1810,7 @@ class TestCore < TestInteractive
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.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|
assert_equal 100, lines.match_count
assert_includes lines[-2], '+T'
@@ -1830,7 +1830,7 @@ class TestCore < TestInteractive
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.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.send_keys :Up
tmux.until { |lines| assert_includes lines, '> beta' }
@@ -1846,12 +1846,12 @@ class TestCore < TestInteractive
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
def test_track_action_with_id_nth
# track-current with --id-nth should track by specified field
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 }
# Move to "2 banana" and activate field tracking
# Move to "2 banana" and activate tracking
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> 2 banana' }
tmux.send_keys 'C-t'
@@ -1865,6 +1865,30 @@ class TestCore < TestInteractive
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
tmux.send_keys "seq 10 | #{FZF} --bind 'zero:preview(echo no match),one:preview(echo {} is the only match)'", :Enter
tmux.send_keys '1'