diff --git a/CHANGELOG.md b/CHANGELOG.md index d1de179d..58465bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ``` diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 6c213e33..00e272f7 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -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) diff --git a/src/options.go b/src/options.go index 78e24f20..36fd0d18 100644 --- a/src/options.go +++ b/src/options.go @@ -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": diff --git a/src/terminal.go b/src/terminal.go index dbc29ea5..f9e6e4bb 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -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 diff --git a/test/test_core.rb b/test/test_core.rb index d3541b6e..7f5090d1 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -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'