mirror of
https://github.com/junegunn/fzf.git
synced 2026-09-04 13:57:03 +08:00
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:
+11
-1
@@ -1,8 +1,18 @@
|
|||||||
CHANGELOG
|
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
|
- 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.
|
- The search performance now scales linearly with the number of CPU cores, as we dropped static partitioning to allow better load balancing across threads.
|
||||||
```
|
```
|
||||||
|
|||||||
+25
-4
@@ -617,17 +617,37 @@ Disable multi-line display of items when using \fB\-\-read0\fR
|
|||||||
.B "\-\-raw"
|
.B "\-\-raw"
|
||||||
Enable raw mode where non-matching items are also displayed in a dimmed color.
|
Enable raw mode where non-matching items are also displayed in a dimmed color.
|
||||||
.TP
|
.TP
|
||||||
.B "\-\-track"
|
.BI "\-\-track" "[=NTH]"
|
||||||
Make fzf track the current selection when the result list is updated.
|
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
|
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
|
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
|
can be confusing.
|
||||||
option.
|
|
||||||
|
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
|
.RS
|
||||||
e.g.
|
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
|
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
|
.RE
|
||||||
.TP
|
.TP
|
||||||
.B "\-\-tac"
|
.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+down\fR \fIctrl\-i (tab)\fR
|
||||||
\fBtoggle+up\fR \fIbtab (shift\-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; 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(...)\fR (transform states using the output of an external command)
|
||||||
\fBtransform\-border\-label(...)\fR (transform border label using an external command)
|
\fBtransform\-border\-label(...)\fR (transform border label using an external command)
|
||||||
\fBtransform\-ghost(...)\fR (transform ghost text using an external command)
|
\fBtransform\-ghost(...)\fR (transform ghost text using an external command)
|
||||||
|
|||||||
+24
-5
@@ -594,6 +594,7 @@ type Options struct {
|
|||||||
Sort int
|
Sort int
|
||||||
Raw bool
|
Raw bool
|
||||||
Track trackOption
|
Track trackOption
|
||||||
|
TrackNth []Range
|
||||||
Tac bool
|
Tac bool
|
||||||
Tail int
|
Tail int
|
||||||
Criteria []criterion
|
Criteria []criterion
|
||||||
@@ -1610,7 +1611,7 @@ func parseWalkerOpts(str string) (walkerOpts, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
executeRegexp *regexp.Regexp
|
argActionRegexp *regexp.Regexp
|
||||||
splitRegexp *regexp.Regexp
|
splitRegexp *regexp.Regexp
|
||||||
actionNameRegexp *regexp.Regexp
|
actionNameRegexp *regexp.Regexp
|
||||||
)
|
)
|
||||||
@@ -1629,8 +1630,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
executeRegexp = regexp.MustCompile(
|
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)`)
|
`(?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("[,:]+")
|
splitRegexp = regexp.MustCompile("[,:]+")
|
||||||
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
|
actionNameRegexp = regexp.MustCompile("(?i)^[a-z-]+")
|
||||||
}
|
}
|
||||||
@@ -1639,7 +1640,7 @@ func maskActionContents(action string) string {
|
|||||||
masked := ""
|
masked := ""
|
||||||
Loop:
|
Loop:
|
||||||
for len(action) > 0 {
|
for len(action) > 0 {
|
||||||
loc := executeRegexp.FindStringIndex(action)
|
loc := argActionRegexp.FindStringIndex(action)
|
||||||
if loc == nil {
|
if loc == nil {
|
||||||
masked += action
|
masked += action
|
||||||
break
|
break
|
||||||
@@ -1694,7 +1695,7 @@ Loop:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseSingleActionList(str string) ([]*action, error) {
|
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:]
|
masked := maskActionContents(":" + str)[1:]
|
||||||
return parseActionList(masked, str, []*action{}, false)
|
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 {
|
if _, _, err := parseKeyChords(actionArg, spec[0:offset]+" target required"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
case actTrackCurrent:
|
||||||
|
if len(actionArg) > 0 {
|
||||||
|
if _, err := splitNth(actionArg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
case actChangePreviewWindow:
|
case actChangePreviewWindow:
|
||||||
opts := previewOpts{}
|
opts := previewOpts{}
|
||||||
for _, arg := range strings.Split(actionArg, "|") {
|
for _, arg := range strings.Split(actionArg, "|") {
|
||||||
@@ -2159,6 +2166,8 @@ func isExecuteAction(str string) actionType {
|
|||||||
return actTrigger
|
return actTrigger
|
||||||
case "search":
|
case "search":
|
||||||
return actSearch
|
return actSearch
|
||||||
|
case "track", "track-current":
|
||||||
|
return actTrackCurrent
|
||||||
}
|
}
|
||||||
return actIgnore
|
return actIgnore
|
||||||
}
|
}
|
||||||
@@ -2809,8 +2818,18 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
|
|||||||
opts.Raw = false
|
opts.Raw = false
|
||||||
case "--track":
|
case "--track":
|
||||||
opts.Track = trackEnabled
|
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":
|
case "--no-track":
|
||||||
opts.Track = trackDisabled
|
opts.Track = trackDisabled
|
||||||
|
opts.TrackNth = nil
|
||||||
case "--tac":
|
case "--tac":
|
||||||
opts.Tac = true
|
opts.Tac = true
|
||||||
case "--no-tac":
|
case "--no-tac":
|
||||||
|
|||||||
+104
-7
@@ -314,6 +314,11 @@ type Terminal struct {
|
|||||||
sort bool
|
sort bool
|
||||||
toggleSort bool
|
toggleSort bool
|
||||||
track trackOption
|
track trackOption
|
||||||
|
trackNth []Range
|
||||||
|
trackKey string
|
||||||
|
trackBlocked bool
|
||||||
|
trackSync bool
|
||||||
|
trackKeyCache map[int32]bool
|
||||||
targetIndex int32
|
targetIndex int32
|
||||||
delimiter Delimiter
|
delimiter Delimiter
|
||||||
expect map[tui.Event]string
|
expect map[tui.Event]string
|
||||||
@@ -1043,6 +1048,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
|
|||||||
sort: opts.Sort > 0,
|
sort: opts.Sort > 0,
|
||||||
toggleSort: opts.ToggleSort,
|
toggleSort: opts.ToggleSort,
|
||||||
track: opts.Track,
|
track: opts.Track,
|
||||||
|
trackNth: opts.TrackNth,
|
||||||
targetIndex: minItem.Index(),
|
targetIndex: minItem.Index(),
|
||||||
delimiter: opts.Delimiter,
|
delimiter: opts.Delimiter,
|
||||||
expect: opts.Expect,
|
expect: opts.Expect,
|
||||||
@@ -1893,7 +1899,33 @@ func (t *Terminal) UpdateList(result MatchResult) {
|
|||||||
t.triggerLoad = false
|
t.triggerLoad = false
|
||||||
t.eventChan <- tui.Load.AsEvent()
|
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
|
pos := t.cy - t.offset
|
||||||
count := t.merger.Length()
|
count := t.merger.Length()
|
||||||
i := t.merger.FindIndex(prevIndex)
|
i := t.merger.FindIndex(prevIndex)
|
||||||
@@ -1929,9 +1961,17 @@ func (t *Terminal) UpdateList(result MatchResult) {
|
|||||||
if t.hasResultActions {
|
if t.hasResultActions {
|
||||||
t.eventChan <- tui.Result.AsEvent()
|
t.eventChan <- tui.Result.AsEvent()
|
||||||
}
|
}
|
||||||
|
updateList := !t.trackBlocked
|
||||||
|
updatePrompt := trackWasBlocked && !t.trackBlocked
|
||||||
t.mutex.Unlock()
|
t.mutex.Unlock()
|
||||||
|
|
||||||
t.reqBox.Set(reqInfo, nil)
|
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 {
|
if needActivation {
|
||||||
t.reqBox.Set(reqActivate, nil)
|
t.reqBox.Set(reqActivate, nil)
|
||||||
}
|
}
|
||||||
@@ -2880,6 +2920,8 @@ func (t *Terminal) printPrompt() {
|
|||||||
color := tui.ColInput
|
color := tui.ColInput
|
||||||
if t.paused {
|
if t.paused {
|
||||||
color = tui.ColDisabled
|
color = tui.ColDisabled
|
||||||
|
} else if t.trackBlocked {
|
||||||
|
color = color.WithAttr(tui.Dim)
|
||||||
}
|
}
|
||||||
w.CPrint(color, string(before))
|
w.CPrint(color, string(before))
|
||||||
w.CPrint(color, string(after))
|
w.CPrint(color, string(after))
|
||||||
@@ -2971,9 +3013,17 @@ func (t *Terminal) printInfoImpl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if t.track.Global() {
|
if t.track.Global() {
|
||||||
output += " +T"
|
if t.trackBlocked {
|
||||||
|
output += " +T*"
|
||||||
|
} else {
|
||||||
|
output += " +T"
|
||||||
|
}
|
||||||
} else if t.track.Current() {
|
} else if t.track.Current() {
|
||||||
output += " +t"
|
if t.trackBlocked {
|
||||||
|
output += " +t*"
|
||||||
|
} else {
|
||||||
|
output += " +t"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if t.multi > 0 {
|
if t.multi > 0 {
|
||||||
if t.multi == maxMulti {
|
if t.multi == maxMulti {
|
||||||
@@ -5366,6 +5416,22 @@ func (t *Terminal) currentIndex() int32 {
|
|||||||
return minItem.Index()
|
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 {
|
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'
|
* 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)
|
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:
|
Action:
|
||||||
switch a.t {
|
switch a.t {
|
||||||
case actIgnore, actStart, actClick:
|
case actIgnore, actStart, actClick:
|
||||||
@@ -6958,14 +7032,16 @@ func (t *Terminal) Loop() error {
|
|||||||
case trackDisabled:
|
case trackDisabled:
|
||||||
t.track = trackEnabled
|
t.track = trackEnabled
|
||||||
}
|
}
|
||||||
req(reqInfo)
|
t.unblockTrack()
|
||||||
|
req(reqPrompt, reqInfo)
|
||||||
case actToggleTrackCurrent:
|
case actToggleTrackCurrent:
|
||||||
if t.track.Current() {
|
if t.track.Current() {
|
||||||
t.track = trackDisabled
|
t.track = trackDisabled
|
||||||
} else if t.track.Disabled() {
|
} else if t.track.Disabled() {
|
||||||
t.track = trackCurrent(t.currentIndex())
|
t.track = trackCurrent(t.currentIndex())
|
||||||
}
|
}
|
||||||
req(reqInfo)
|
t.unblockTrack()
|
||||||
|
req(reqPrompt, reqInfo)
|
||||||
case actShowHeader:
|
case actShowHeader:
|
||||||
t.headerVisible = true
|
t.headerVisible = true
|
||||||
req(reqList, reqInfo, reqPrompt, reqHeader)
|
req(reqList, reqInfo, reqPrompt, reqHeader)
|
||||||
@@ -7023,12 +7099,19 @@ func (t *Terminal) Loop() error {
|
|||||||
if !t.track.Global() {
|
if !t.track.Global() {
|
||||||
t.track = trackCurrent(t.currentIndex())
|
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)
|
req(reqInfo)
|
||||||
case actUntrackCurrent:
|
case actUntrackCurrent:
|
||||||
if t.track.Current() {
|
if t.track.Current() {
|
||||||
t.track = trackDisabled
|
t.track = trackDisabled
|
||||||
}
|
}
|
||||||
req(reqInfo)
|
t.unblockTrack()
|
||||||
|
req(reqPrompt, reqInfo)
|
||||||
case actSearch:
|
case actSearch:
|
||||||
override := []rune(a.a)
|
override := []rune(a.a)
|
||||||
t.inputOverride = &override
|
t.inputOverride = &override
|
||||||
@@ -7379,6 +7462,20 @@ func (t *Terminal) Loop() error {
|
|||||||
newCommand = &commandSpec{command, tempFiles}
|
newCommand = &commandSpec{command, tempFiles}
|
||||||
reloadSync = a.t == actReloadSync
|
reloadSync = a.t == actReloadSync
|
||||||
t.reading = true
|
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:
|
case actUnbind:
|
||||||
if keys, _, err := parseKeyChords(a.a, "PANIC"); err == nil {
|
if keys, _, err := parseKeyChords(a.a, "PANIC"); err == nil {
|
||||||
|
|||||||
@@ -1659,6 +1659,212 @@ class TestCore < TestInteractive
|
|||||||
end
|
end
|
||||||
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
|
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 "seq 10 | #{FZF} --bind 'zero:preview(echo no match),one:preview(echo {} is the only match)'", :Enter
|
||||||
tmux.send_keys '1'
|
tmux.send_keys '1'
|
||||||
|
|||||||
Reference in New Issue
Block a user