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

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