From 7c2ffd3fef3f9131ee448a5f40d91835c8bd814d Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Thu, 20 Jun 2024 23:18:28 +0900 Subject: [PATCH] Make transform*, --info-command, and execute-silent cancellable Users can press CTRL-C after 1 second to terminate the command. Close #3883 --- man/man1/fzf.1 | 2 + src/terminal.go | 97 +++++++++++++++++++++++++++++++------------ src/util/util.go | 12 +++++- src/util/util_test.go | 10 ++++- 4 files changed, 92 insertions(+), 29 deletions(-) diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index eb9882e..136aa1c 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -457,6 +457,8 @@ Determines the display style of the finder info. (e.g. match counter, loading in Command to generate the finder info line. The command runs synchronously and blocks the UI until completion, so make sure that it's fast. ANSI color codes are supported. \fB$FZF_INFO\fR variable is set to the original info text. +For additional environment variables available to the command, see the section +ENVIRONMENT VARIABLES EXPORTED TO CHILD PROCESSES. e.g. \fB# Prepend the current cursor position in yellow diff --git a/src/terminal.go b/src/terminal.go index f131f1d..a9e2f48 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -58,6 +59,10 @@ const clearCode string = "\x1b[2J" // Number of maximum focus events to process synchronously const maxFocusEvents = 10000 +// execute-silent and transform* actions will block user input for this duration. +// After this duration, users can press CTRL-C to terminate the command. +const blockDuration = 1 * time.Second + func init() { placeholder = regexp.MustCompile(`\\?(?:{[+sf]*[0-9,-.]*}|{q}|{fzf:(?:query|action|prompt)}|{\+?f?nf?})`) whiteSuffix = regexp.MustCompile(`\s*$`) @@ -1792,20 +1797,8 @@ func (t *Terminal) printInfo() { t.window.Print(strings.Repeat(" ", fillLength+1)) } } - switch t.infoStyle { - case infoDefault: - move(line+1, 0, t.separatorLen == 0) - printSpinner() - t.window.Print(" ") // Margin - pos = 2 - case infoRight: - move(line+1, 0, false) - case infoInlineRight: - pos = t.promptLen + t.queryLen[0] + t.queryLen[1] + 1 - case infoInline: - pos = t.promptLen + t.queryLen[0] + t.queryLen[1] + 1 - printInfoPrefix() - case infoHidden: + + if t.infoStyle == infoHidden { if t.separatorLen > 0 { move(line+1, 0, false) printSeparator(t.window.Width()-1, false) @@ -1849,6 +1842,21 @@ func (t *Terminal) printInfo() { outputPrinter, outputLen = t.ansiLabelPrinter(output, &tui.ColInfo, false) } + switch t.infoStyle { + case infoDefault: + move(line+1, 0, t.separatorLen == 0) + printSpinner() + t.window.Print(" ") // Margin + pos = 2 + case infoRight: + move(line+1, 0, false) + case infoInlineRight: + pos = t.promptLen + t.queryLen[0] + t.queryLen[1] + 1 + case infoInline: + pos = t.promptLen + t.queryLen[0] + t.queryLen[1] + 1 + printInfoPrefix() + } + if t.infoStyle == infoRight { maxWidth := t.window.Width() if t.reading { @@ -3055,6 +3063,7 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo cmd.Run() t.tui.Resume(true, false) t.mutex.Lock() + // NOTE: Using t.reqBox.Set(reqFullRedraw...) instead can cause a deadlock t.fullRedraw() t.flush() } else { @@ -3062,6 +3071,18 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo if len(info) == 0 { t.uiMutex.Lock() } + paused := atomic.Int32{} + ctx, cancel := context.WithCancel(context.Background()) + go func() { + select { + case <-ctx.Done(): + return + case <-time.After(blockDuration): + if paused.CompareAndSwap(0, 1) { + t.tui.Pause(false) + } + } + }() if capture { out, _ := cmd.StdoutPipe() reader := bufio.NewReader(out) @@ -3077,7 +3098,20 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo } else { cmd.Run() } + cancel() + if paused.CompareAndSwap(1, 2) { + t.tui.Resume(false, false) + } t.mutex.Lock() + + // Redraw prompt in case the user has typed something after blockDuration + if paused.Load() > 0 { + // NOTE: Using t.reqBox.Set(reqXXX...) instead can cause a deadlock + t.printPrompt() + if t.infoStyle == infoInline || t.infoStyle == infoInlineRight { + t.printInfo() + } + } } if len(info) == 0 { t.uiMutex.Unlock() @@ -3300,11 +3334,11 @@ func (t *Terminal) Loop() error { t.termSize = t.tui.Size() t.resizeWindows(false) t.window.Erase() - t.printPrompt() - t.printInfo() - t.printHeader() - t.flush() t.mutex.Unlock() + + t.reqBox.Set(reqPrompt, nil) + t.reqBox.Set(reqInfo, nil) + t.reqBox.Set(reqHeader, nil) if t.initDelay > 0 { go func() { timer := time.NewTimer(t.initDelay) @@ -3530,6 +3564,14 @@ func (t *Terminal) Loop() error { t.reqBox.Wait(func(events *util.Events) { defer events.Clear() + // Sort events. + // e.g. Make sure that reqPrompt is processed before reqInfo + keys := make([]int, 0, len(*events)) + for key := range *events { + keys = append(keys, int(key)) + } + sort.Ints(keys) + // t.uiMutex must be locked first to avoid deadlock. Execute actions // will 1. unlock t.mutex to allow GET endpoint and 2. lock t.uiMutex // to block rendering during the execution. @@ -3547,33 +3589,36 @@ func (t *Terminal) Loop() error { // U t.uiMutex | t.uiMutex.Lock() t.mutex.Lock() - for req, value := range *events { + printInfo := util.RunOnce(t.printInfo) + for _, key := range keys { + req := util.EventType(key) + value := (*events)[req] switch req { case reqPrompt: t.printPrompt() if t.infoStyle == infoInline || t.infoStyle == infoInlineRight { - t.printInfo() + printInfo() } case reqInfo: - t.printInfo() + printInfo() case reqList: t.printList() currentIndex := t.currentIndex() focusChanged := focusedIndex != currentIndex - printInfo := false + info := false if focusChanged && t.track == trackCurrent { t.track = trackDisabled - printInfo = true + info = true } if (t.hasFocusActions || t.infoCommand != "") && focusChanged && currentIndex != t.lastFocus { t.lastFocus = currentIndex t.eventChan <- tui.Focus.AsEvent() if t.infoCommand != "" { - printInfo = true + info = true } } - if printInfo { - t.printInfo() + if info { + printInfo() } if focusChanged || version != t.version { version = t.version diff --git a/src/util/util.go b/src/util/util.go index ec5a1ea..c830136 100644 --- a/src/util/util.go +++ b/src/util/util.go @@ -144,12 +144,22 @@ func IsTty(file *os.File) bool { return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) } +// RunOnce runs the given function only once +func RunOnce(f func()) func() { + once := Once(true) + return func() { + if once() { + f() + } + } +} + // Once returns a function that returns the specified boolean value only once func Once(nextResponse bool) func() bool { state := nextResponse return func() bool { prevState := state - state = false + state = !nextResponse return prevState } } diff --git a/src/util/util_test.go b/src/util/util_test.go index 013f3c2..36d71bd 100644 --- a/src/util/util_test.go +++ b/src/util/util_test.go @@ -137,8 +137,11 @@ func TestOnce(t *testing.T) { if o() { t.Error("Expected: false") } - if o() { - t.Error("Expected: false") + if !o() { + t.Error("Expected: true") + } + if !o() { + t.Error("Expected: true") } o = Once(true) @@ -148,6 +151,9 @@ func TestOnce(t *testing.T) { if o() { t.Error("Expected: false") } + if o() { + t.Error("Expected: false") + } } func TestRunesWidth(t *testing.T) {