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:
Junegunn Choi
2026-03-13 01:18:43 +09:00
parent 7a811f0cb8
commit 9f422851fe
5 changed files with 370 additions and 17 deletions

View File

@@ -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.
```

View File

@@ -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)

View File

@@ -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":

View File

@@ -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 {

View File

@@ -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'