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-14 20:52:04 +09:00
parent 7a811f0cb8
commit 9f422851fe
5 changed files with 370 additions and 17 deletions
+24 -5
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":