From 9f422851fe771d8a9196f82a6a9927d2026b51af Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Fri, 13 Mar 2026 01:18:43 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 12 ++- man/man1/fzf.1 | 29 ++++++- src/options.go | 29 +++++-- src/terminal.go | 111 +++++++++++++++++++++++-- test/test_core.rb | 206 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 370 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e4448c..d1de179d 100644 --- a/CHANGELOG.md +++ b/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. ``` diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 93cce3af..6c213e33 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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) diff --git a/src/options.go b/src/options.go index 46a7893f..78e24f20 100644 --- a/src/options.go +++ b/src/options.go @@ -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": diff --git a/src/terminal.go b/src/terminal.go index 59dbb71e..4f625f84 100644 --- a/src/terminal.go +++ b/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 { diff --git a/test/test_core.rb b/test/test_core.rb index f0abd50a..d3541b6e 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -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'