summaryrefslogtreecommitdiff
path: root/src/terminal.go
diff options
context:
space:
mode:
authorJunegunn Choi <junegunn.c@gmail.com>2025-06-16 00:39:11 +0900
committerGitHub <noreply@github.com>2025-06-16 00:39:11 +0900
commit0c00b203e61bffbadbc499cbf68af6f89a5a3e29 (patch)
treeeffd2636b35640cede4f2e8e362f18ee91836a5b /src/terminal.go
parent3b68dcdd81394f1ac9f743e1f74ff754f95eef9e (diff)
downloadfzf-0c00b203e61bffbadbc499cbf68af6f89a5a3e29.tar.gz
Implement asynchronous transform actions (#4419)
Close #4418 Example: fzf --bind 'focus:bg-transform-header(sleep 2; date; echo {})'
Diffstat (limited to 'src/terminal.go')
-rw-r--r--src/terminal.go465
1 files changed, 309 insertions, 156 deletions
diff --git a/src/terminal.go b/src/terminal.go
index 898d34c7..85a51112 100644
--- a/src/terminal.go
+++ b/src/terminal.go
@@ -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 {