Implement asynchronous transform actions (#4419)
Some checks failed
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled

Close #4418

Example:

    fzf --bind 'focus:bg-transform-header(sleep 2; date; echo {})'
This commit is contained in:
Junegunn Choi
2025-06-16 00:39:11 +09:00
committed by GitHub
parent 3b68dcdd81
commit 0c00b203e6
7 changed files with 460 additions and 197 deletions

View File

@@ -388,6 +388,10 @@ type Terminal struct {
startChan chan fitpad
killChan chan bool
serverInputChan chan []*action
callbackChan chan func()
bgQueue map[action][]func()
bgSemaphore chan struct{}
bgSemaphores map[action]chan struct{}
keyChan chan tui.Event
eventChan chan tui.Event
slab *util.Slab
@@ -489,6 +493,7 @@ const (
actBackwardDeleteCharEof
actBackwardWord
actCancel
actChangeBorderLabel
actChangeGhost
actChangeHeader
@@ -505,6 +510,7 @@ const (
actChangePreviewWindow
actChangePrompt
actChangeQuery
actClearScreen
actClearQuery
actClearSelection
@@ -561,6 +567,7 @@ const (
actHidePreview
actTogglePreview
actTogglePreviewWrap
actTransform
actTransformBorderLabel
actTransformGhost
@@ -576,6 +583,23 @@ const (
actTransformPrompt
actTransformQuery
actTransformSearch
actBgTransform
actBgTransformBorderLabel
actBgTransformGhost
actBgTransformHeader
actBgTransformFooter
actBgTransformHeaderLabel
actBgTransformFooterLabel
actBgTransformInputLabel
actBgTransformListLabel
actBgTransformNth
actBgTransformPointer
actBgTransformPreviewLabel
actBgTransformPrompt
actBgTransformQuery
actBgTransformSearch
actSearch
actPreview
actPreviewTop
@@ -613,6 +637,7 @@ const (
actBell
actExclude
actExcludeMulti
actAsync
)
func (a actionType) Name() string {
@@ -623,10 +648,34 @@ func processExecution(action actionType) bool {
switch action {
case actTransform,
actTransformBorderLabel,
actTransformGhost,
actTransformHeader,
actTransformFooter,
actTransformHeaderLabel,
actTransformFooterLabel,
actTransformInputLabel,
actTransformListLabel,
actTransformNth,
actTransformPointer,
actTransformPreviewLabel,
actTransformPrompt,
actTransformQuery,
actTransformSearch,
actBgTransform,
actBgTransformBorderLabel,
actBgTransformGhost,
actBgTransformHeader,
actBgTransformFooter,
actBgTransformHeaderLabel,
actBgTransformFooterLabel,
actBgTransformInputLabel,
actBgTransformListLabel,
actBgTransformNth,
actBgTransformPointer,
actBgTransformPreviewLabel,
actBgTransformPrompt,
actBgTransformQuery,
actBgTransformSearch,
actPreview,
actChangePreview,
actRefreshPreview,
@@ -773,7 +822,7 @@ func mayTriggerPreview(opts *Options) bool {
for _, actions := range opts.Keymap {
for _, action := range actions {
switch action.t {
case actPreview, actChangePreview, actTransform:
case actPreview, actChangePreview, actTransform, actBgTransform:
return true
}
}
@@ -987,6 +1036,10 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
startChan: make(chan fitpad, 1),
killChan: make(chan bool),
serverInputChan: make(chan []*action, 100),
callbackChan: make(chan func(), maxBgProcesses),
bgQueue: make(map[action][]func()),
bgSemaphore: make(chan struct{}, maxBgProcesses),
bgSemaphores: make(map[action]chan struct{}),
keyChan: make(chan tui.Event),
eventChan: make(chan tui.Event, 6), // start | (load + result + zero|one) | (focus) | (resize)
tui: renderer,
@@ -2578,7 +2631,9 @@ func (t *Terminal) printPrompt() {
before, after := t.updatePromptOffset()
if len(before) == 0 && len(after) == 0 && len(t.ghost) > 0 {
w.CPrint(tui.ColGhost, t.ghost)
maxWidth := util.Max(1, w.Width()-t.promptLen-1)
runes, _ := t.trimRight([]rune(t.ghost), maxWidth)
w.CPrint(tui.ColGhost, string(runes))
return
}
@@ -4291,6 +4346,75 @@ func (t *Terminal) captureLines(template string) string {
return t.executeCommand(template, false, true, true, false, "")
}
func (t *Terminal) captureAsync(a action, firstLineOnly bool, callback func(string)) {
_, list := t.buildPlusList(a.a, false)
command, tempFiles := t.replacePlaceholder(a.a, false, string(t.input), list)
item := func() {
cmd := t.executor.ExecCommand(command, false)
cmd.Env = t.environ()
out, _ := cmd.StdoutPipe()
reader := bufio.NewReader(out)
var output string
if err := cmd.Start(); err == nil {
if firstLineOnly {
output, _ = reader.ReadString('\n')
output = strings.TrimRight(output, "\r\n")
} else {
bytes, _ := io.ReadAll(reader)
output = string(bytes)
}
cmd.Wait()
}
removeFiles(tempFiles)
t.callbackChan <- func() { callback(output) }
}
queue, prs := t.bgQueue[a]
if !prs {
queue = []func(){}
}
queue = append(queue, item)
t.bgQueue[a] = queue
}
func (t *Terminal) dispatchAsync() {
Loop:
for a, queue := range t.bgQueue {
delete(t.bgQueue, a)
if len(queue) == 0 {
continue
}
semaphore, prs := t.bgSemaphores[a]
if !prs {
semaphore = make(chan struct{}, maxBgProcessesPerAction)
t.bgSemaphores[a] = semaphore
}
for _, item := range queue {
select {
// Acquire local semaphore
case semaphore <- struct{}{}:
default:
// Failed to acquire local semaphore, putting only the last one back to the queue
t.bgQueue[a] = queue[len(queue)-1:]
continue Loop
}
todo := item
go func() {
// Acquire global semaphore
t.bgSemaphore <- struct{}{}
todo()
// Release local semaphore
<-semaphore
// Release global semaphore
<-t.bgSemaphore
}()
}
}
}
func (t *Terminal) executeCommand(template string, forcePlus bool, background bool, capture bool, firstLineOnly bool, info string) string {
line := ""
valid, list := t.buildPlusList(template, forcePlus)
@@ -5089,11 +5213,27 @@ func (t *Terminal) Loop() error {
barrier <- true
needBarrier = false
}
// These variables are defined outside the loop to be accessible from closures
events := []util.EventType{}
changed := false
var newNth *[]Range
req := func(evts ...util.EventType) {
for _, event := range evts {
events = append(events, event)
if event == reqClose || event == reqQuit {
looping = false
}
}
}
// The main event loop
for loopIndex := int64(0); looping; loopIndex++ {
var newCommand *commandSpec
var newNth *[]Range
var reloadSync bool
changed := false
events = []util.EventType{}
changed = false
newNth = nil
beof := false
queryChanged := false
denylist := []int32{}
@@ -5110,6 +5250,7 @@ func (t *Terminal) Loop() error {
var event tui.Event
actions := []*action{}
callbacks := []func(){}
select {
case event = <-t.keyChan:
needBarrier = true
@@ -5141,6 +5282,20 @@ func (t *Terminal) Loop() error {
}
}
}
case callback := <-t.callbackChan:
event = tui.Invalid.AsEvent()
actions = append(actions, &action{t: actAsync})
callbacks = append(callbacks, callback)
DrainCallback:
for {
select {
case callback = <-t.callbackChan:
callbacks = append(callbacks, callback)
continue DrainCallback
default:
break DrainCallback
}
}
}
t.mutex.Lock()
@@ -5155,15 +5310,6 @@ func (t *Terminal) Loop() error {
previousInput := t.input
previousCx := t.cx
t.lastKey = event.KeyName()
events := []util.EventType{}
req := func(evts ...util.EventType) {
for _, event := range evts {
events = append(events, event)
if event == reqClose || event == reqQuit {
looping = false
}
}
}
updatePreviewWindow := func(forcePreview bool) {
t.resizeWindows(forcePreview, false)
req(reqPrompt, reqList, reqInfo, reqHeader, reqFooter)
@@ -5238,9 +5384,29 @@ func (t *Terminal) Loop() error {
// actions to allow changing the query even when the input is hidden
// e.g. fzf --no-input --bind 'space:show-input+change-query(foo)+hide-input'
currentInput := t.input
capture := func(firstLineOnly bool, callback func(string)) {
if a.t >= actBgTransform {
// bg-transform-*
t.captureAsync(*a, firstLineOnly, callback)
} else if a.t >= actTransform {
// transform-*
if firstLineOnly {
callback(t.captureLine(a.a))
} else {
callback(t.captureLines(a.a))
}
} else {
// change-*
callback(a.a)
}
}
Action:
switch a.t {
case actIgnore, actStart, actClick:
case actAsync:
for _, callback := range callbacks {
callback()
}
case actBecome:
valid, list := t.buildPlusList(a.a, false)
if valid {
@@ -5333,15 +5499,17 @@ func (t *Terminal) Loop() error {
t.previewed.version = 0
req(reqPreviewRefresh)
}
case actTransformPrompt:
prompt := t.captureLine(a.a)
t.promptString = prompt
t.prompt, t.promptLen = t.parsePrompt(prompt)
req(reqPrompt)
case actTransformQuery:
query := t.captureLine(a.a)
t.input = []rune(query)
t.cx = len(t.input)
case actTransformPrompt, actBgTransformPrompt:
capture(true, func(prompt string) {
t.promptString = prompt
t.prompt, t.promptLen = t.parsePrompt(prompt)
req(reqPrompt)
})
case actTransformQuery, actBgTransformQuery:
capture(true, func(query string) {
t.input = []rune(query)
t.cx = len(t.input)
})
case actToggleSort:
t.sort = !t.sort
changed = true
@@ -5399,119 +5567,102 @@ func (t *Terminal) Loop() error {
}
t.multi = multi
req(reqList, reqInfo)
case actChangeNth, actTransformNth:
expr := a.a
if a.t == actTransformNth {
expr = t.captureLine(a.a)
}
// Split nth expression
tokens := strings.Split(expr, "|")
if nth, err := splitNth(tokens[0]); err == nil {
// Changed
newNth = &nth
} else {
// The default
newNth = &t.nth
}
// Cycle
if len(tokens) > 1 {
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
}
if !compareRanges(t.nthCurrent, *newNth) {
changed = true
t.nthCurrent = *newNth
t.forceRerenderList()
}
case actChangeNth, actTransformNth, actBgTransformNth:
capture(true, func(expr string) {
// Split nth expression
tokens := strings.Split(expr, "|")
if nth, err := splitNth(tokens[0]); err == nil {
// Changed
newNth = &nth
} else {
// The default
newNth = &t.nth
}
// Cycle
if len(tokens) > 1 {
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
}
if !compareRanges(t.nthCurrent, *newNth) {
changed = true
t.nthCurrent = *newNth
t.forceRerenderList()
}
})
case actChangeQuery:
t.input = []rune(a.a)
t.cx = len(t.input)
case actChangeHeader, actTransformHeader:
header := a.a
if a.t == actTransformHeader {
header = t.captureLines(a.a)
}
if t.changeHeader(header) {
if t.headerWindow != nil {
// Need to resize header window
case actChangeHeader, actTransformHeader, actBgTransformHeader:
capture(false, func(header string) {
if t.changeHeader(header) {
if t.headerWindow != nil {
// Need to resize header window
req(reqFullRedraw)
} else {
req(reqHeader, reqList, reqPrompt, reqInfo)
}
} else {
req(reqHeader)
}
})
case actChangeFooter, actTransformFooter, actBgTransformFooter:
capture(false, func(footer string) {
if t.changeFooter(footer) {
req(reqFullRedraw)
} else {
req(reqHeader, reqList, reqPrompt, reqInfo)
req(reqFooter)
}
} else {
req(reqHeader)
}
case actChangeFooter, actTransformFooter:
footer := a.a
if a.t == actTransformFooter {
footer = t.captureLines(a.a)
}
if t.changeFooter(footer) {
req(reqFullRedraw)
} else {
req(reqFooter)
}
case actChangeHeaderLabel, actTransformHeaderLabel:
label := a.a
if a.t == actTransformHeaderLabel {
label = t.captureLine(a.a)
}
t.headerLabelOpts.label = label
t.headerLabel, t.headerLabelLen = t.ansiLabelPrinter(label, &tui.ColHeaderLabel, false)
req(reqRedrawHeaderLabel)
case actChangeFooterLabel, actTransformFooterLabel:
label := a.a
if a.t == actTransformFooterLabel {
label = t.captureLine(a.a)
}
t.footerLabelOpts.label = label
t.footerLabel, t.footerLabelLen = t.ansiLabelPrinter(label, &tui.ColFooterLabel, false)
req(reqRedrawFooterLabel)
case actChangeInputLabel, actTransformInputLabel:
label := a.a
if a.t == actTransformInputLabel {
label = t.captureLine(a.a)
}
t.inputLabelOpts.label = label
if t.inputBorder != nil {
t.inputLabel, t.inputLabelLen = t.ansiLabelPrinter(label, &tui.ColInputLabel, false)
req(reqRedrawInputLabel)
}
case actChangeListLabel, actTransformListLabel:
label := a.a
if a.t == actTransformListLabel {
label = t.captureLine(a.a)
}
t.listLabelOpts.label = label
if t.wborder != nil {
t.listLabel, t.listLabelLen = t.ansiLabelPrinter(label, &tui.ColListLabel, false)
req(reqRedrawListLabel)
}
case actChangeBorderLabel, actTransformBorderLabel:
label := a.a
if a.t == actTransformBorderLabel {
label = t.captureLine(a.a)
}
t.borderLabelOpts.label = label
if t.border != nil {
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false)
req(reqRedrawBorderLabel)
}
case actChangePreviewLabel, actTransformPreviewLabel:
label := a.a
if a.t == actTransformPreviewLabel {
label = t.captureLine(a.a)
}
t.previewLabelOpts.label = label
if t.pborder != nil {
t.previewLabel, t.previewLabelLen = t.ansiLabelPrinter(label, &tui.ColPreviewLabel, false)
req(reqRedrawPreviewLabel)
}
case actTransform:
body := t.captureLines(a.a)
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n")); err == nil {
return doActions(actions)
}
})
case actChangeHeaderLabel, actTransformHeaderLabel, actBgTransformHeaderLabel:
capture(true, func(label string) {
t.headerLabelOpts.label = label
t.headerLabel, t.headerLabelLen = t.ansiLabelPrinter(label, &tui.ColHeaderLabel, false)
req(reqRedrawHeaderLabel)
})
case actChangeFooterLabel, actTransformFooterLabel, actBgTransformFooterLabel:
capture(true, func(label string) {
t.footerLabelOpts.label = label
t.footerLabel, t.footerLabelLen = t.ansiLabelPrinter(label, &tui.ColFooterLabel, false)
req(reqRedrawFooterLabel)
})
case actChangeInputLabel, actTransformInputLabel, actBgTransformInputLabel:
capture(true, func(label string) {
t.inputLabelOpts.label = label
if t.inputBorder != nil {
t.inputLabel, t.inputLabelLen = t.ansiLabelPrinter(label, &tui.ColInputLabel, false)
req(reqRedrawInputLabel)
}
})
case actChangeListLabel, actTransformListLabel, actBgTransformListLabel:
capture(true, func(label string) {
t.listLabelOpts.label = label
if t.wborder != nil {
t.listLabel, t.listLabelLen = t.ansiLabelPrinter(label, &tui.ColListLabel, false)
req(reqRedrawListLabel)
}
})
case actChangeBorderLabel, actTransformBorderLabel, actBgTransformBorderLabel:
capture(true, func(label string) {
t.borderLabelOpts.label = label
if t.border != nil {
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false)
req(reqRedrawBorderLabel)
}
})
case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel:
capture(true, func(label string) {
t.previewLabelOpts.label = label
if t.pborder != nil {
t.previewLabel, t.previewLabelLen = t.ansiLabelPrinter(label, &tui.ColPreviewLabel, false)
req(reqRedrawPreviewLabel)
}
})
case actTransform, actBgTransform:
capture(false, func(body string) {
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n")); err == nil {
// NOTE: We're not properly passing the return value here
doActions(actions)
}
})
case actChangePrompt:
t.promptString = a.a
t.prompt, t.promptLen = t.parsePrompt(a.a)
@@ -5933,10 +6084,12 @@ func (t *Terminal) Loop() error {
override := []rune(a.a)
t.inputOverride = &override
changed = true
case actTransformSearch:
override := []rune(t.captureLine(a.a))
t.inputOverride = &override
changed = true
case actTransformSearch, actBgTransformSearch:
capture(true, func(query string) {
override := []rune(query)
t.inputOverride = &override
changed = true
})
case actEnableSearch:
t.paused = false
changed = true
@@ -6276,30 +6429,26 @@ func (t *Terminal) Loop() error {
}
}
}
case actChangeGhost, actTransformGhost:
ghost := a.a
if a.t == actTransformGhost {
ghost = t.captureLine(a.a)
}
t.ghost = ghost
if len(t.input) == 0 {
req(reqPrompt)
}
case actChangePointer, actTransformPointer:
pointer := a.a
if a.t == actTransformPointer {
pointer = t.captureLine(a.a)
}
length := uniseg.StringWidth(pointer)
if length <= 2 {
if length != t.pointerLen {
t.forceRerenderList()
case actChangeGhost, actTransformGhost, actBgTransformGhost:
capture(true, func(ghost string) {
t.ghost = ghost
if len(t.input) == 0 {
req(reqPrompt)
}
t.pointer = pointer
t.pointerLen = length
t.pointerEmpty = strings.Repeat(" ", t.pointerLen)
req(reqList)
}
})
case actChangePointer, actTransformPointer, actBgTransformPointer:
capture(true, func(pointer string) {
length := uniseg.StringWidth(pointer)
if length <= 2 {
if length != t.pointerLen {
t.forceRerenderList()
}
t.pointer = pointer
t.pointerLen = length
t.pointerEmpty = strings.Repeat(" ", t.pointerLen)
req(reqList)
}
})
case actChangePreview:
if t.previewOpts.command != a.a {
t.previewOpts.command = a.a
@@ -6451,6 +6600,10 @@ func (t *Terminal) Loop() error {
if reload {
reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.merger.Revision()}
}
// Dispatch queued background requests
t.dispatchAsync()
t.mutex.Unlock() // Must be unlocked before touching reqBox
if reload {