mirror of
https://github.com/junegunn/fzf.git
synced 2026-08-05 07:28:37 +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:
+24
-5
@@ -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":
|
||||
|
||||
+104
-7
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user