Skip to content

Commit 42afde5

Browse files
committed
Restore bracketed paste mode instead of forcing it off
Ask the terminal whether the mode is already on (DECRQM) and put it back that way on exit. Forcing it off broke pasting in shells that run fzf from a line editor widget, which enable the mode only when the editor starts. Terminals that do not answer fall back to disabling it. - Startup queries go out in one write, cursor position last. Every terminal answers DSR, so its reply bounds the wait: a paste reply still missing by then means the terminal does not know the query. - Bound the first read with select(2). Terminals that never answer escape sequences, such as FreeBSD virtual terminals, blocked startup until a key was pressed, and that keystroke was then discarded. Fix #4887 Fix #2860 Fix #976
1 parent 9264278 commit 42afde5

4 files changed

Lines changed: 160 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ CHANGELOG
1212
- ASCII input is unaffected
1313
- Fixed an image from a preview command being torn apart when its rows are separated by IND instead of newlines, as `chafa` does under tmux (#4885)
1414
- Fixed `replace-query` corrupting the item text when the query is edited afterwards
15+
- fzf no longer turns bracketed paste mode off on exit when the terminal already had it on, which broke pasting in shells that run fzf from a line editor widget (#4887)
16+
- Fixed startup blocking on terminals that never answer escape sequences, such as FreeBSD virtual terminals. fzf waited for a reply until a key was pressed, then dropped that keystroke (#2860, #976)
1517

1618
0.74.2
1719
------

src/tui/light.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,38 @@ const (
2424
defaultEscDelay = 100
2525
escPollInterval = 5
2626
offsetPollTries = 10
27+
queryTimeout = 500 * time.Millisecond
2728
maxInputBuffer = 1024 * 1024
2829
maxSelectTries = 100
2930
)
3031

3132
const DefaultTtyDevice string = "/dev/tty"
3233

33-
var offsetRegexp = regexp.MustCompile("(.*?)\x00?\x1b\\[([0-9]+);([0-9]+)R")
34+
var offsetRegexp = regexp.MustCompile("\x00?\x1b\\[([0-9]+);([0-9]+)R")
3435
var offsetRegexpBegin = regexp.MustCompile("^\x1b\\[[0-9]+;[0-9]+R")
3536

37+
// DECRPM reply to the DECRQM query for bracketed paste mode. Ps is 1 or 3 when
38+
// the mode was already set, 2 or 4 when reset, 0 when the terminal does not
39+
// recognize the mode.
40+
var pasteModeRegexp = regexp.MustCompile("\x00?\x1b\\[\\?2004;([0-4])\\$y")
41+
var pasteModeRegexpBegin = regexp.MustCompile("^\x1b\\[\\?2004;[0-4]\\$y")
42+
43+
// A report to ask the terminal for, and the reply to recognize it by.
44+
type termQuery struct {
45+
seq string
46+
reply *regexp.Regexp
47+
}
48+
49+
// What we ask the terminal at startup, in the order the queries go out. A
50+
// terminal answers them in that order, so the cursor position query is last and
51+
// also ends the wait: every terminal fzf supports answers it, so once its reply
52+
// arrives, a query still unanswered is one the terminal does not know rather
53+
// than one we stopped waiting for too early.
54+
var startupQueries = []termQuery{
55+
{"?2004$p", pasteModeRegexp},
56+
{"6n", offsetRegexp},
57+
}
58+
3659
func (r *LightRenderer) Bell() {
3760
r.flushRaw("\a")
3861
}
@@ -158,6 +181,10 @@ type LightRenderer struct {
158181
showCursor bool
159182
mutex sync.Mutex
160183

184+
// Whether bracketed paste was already on before we enabled it. Nil when
185+
// the terminal did not answer the query.
186+
pasteWasSet *bool
187+
161188
// Windows only
162189
ttyinChannel chan byte
163190
inHandle uintptr
@@ -230,8 +257,13 @@ func (r *LightRenderer) Init() error {
230257

231258
if r.fullscreen {
232259
r.smcup()
233-
} else {
234-
y, x := r.findOffset()
260+
}
261+
262+
// Ask everything in one round trip, before the offset is needed.
263+
y, x, pasteWasSet := r.queryStartup()
264+
r.pasteWasSet = pasteWasSet
265+
266+
if !r.fullscreen {
235267
r.mouse = r.mouse && y >= 0
236268
// When --no-clear is used for repetitive relaunching, there is a small
237269
// time frame between fzf processes where the user keystrokes are not
@@ -446,6 +478,12 @@ func (r *LightRenderer) escSequence(sz *int) Event {
446478
return Event{Invalid, 0, nil}
447479
}
448480

481+
loc = pasteModeRegexpBegin.FindIndex(r.buffer)
482+
if loc != nil && loc[0] == 0 {
483+
*sz = loc[1]
484+
return Event{Invalid, 0, nil}
485+
}
486+
449487
*sz = 2
450488
if r.buffer[1] == 8 {
451489
return Event{CtrlAltBackspace, 0, nil}
@@ -1019,7 +1057,16 @@ func (r *LightRenderer) disableMouse() {
10191057

10201058
func (r *LightRenderer) disableModes() {
10211059
r.disableMouse()
1022-
r.csi("?2004l")
1060+
// Put bracketed paste back the way we found it. A shell that runs fzf from
1061+
// a line editor widget re-enables the mode only when the editor starts, so
1062+
// forcing it off here would leave it off for the rest of the session.
1063+
// Terminals that did not answer the query fall back to disabling, which is
1064+
// what fzf has always done.
1065+
if r.pasteWasSet != nil && *r.pasteWasSet {
1066+
r.csi("?2004h")
1067+
} else {
1068+
r.csi("?2004l")
1069+
}
10231070
}
10241071

10251072
func (r *LightRenderer) Resume(clear bool, sigcont bool) {

src/tui/light_unix.go

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os/exec"
99
"strings"
1010
"syscall"
11+
"time"
1112

1213
"github.com/junegunn/fzf/src/util"
1314
"golang.org/x/sys/unix"
@@ -93,25 +94,112 @@ func (r *LightRenderer) updateTerminalSize() {
9394
}
9495
}
9596

96-
func (r *LightRenderer) findOffset() (row int, col int) {
97-
r.csi("6n")
97+
// waitReadable reports whether the tty has something to read before the
98+
// deadline. A terminal that does not recognize a query answers nothing at all,
99+
// so the read that follows must be able to stop waiting, or fzf would wait for
100+
// the user to press a key instead of drawing itself. The timeout is generous
101+
// because a terminal that does answer exceeds it only when the link is slow
102+
// enough to be unusable anyway.
103+
func (r *LightRenderer) waitReadable(timeout time.Duration) bool {
104+
fd := r.fd()
105+
deadline := time.Now().Add(timeout)
106+
for {
107+
remaining := time.Until(deadline)
108+
if remaining <= 0 {
109+
return false
110+
}
111+
var rfds unix.FdSet
112+
if fd >= len(rfds.Bits)*unix.NFDBITS {
113+
return false
114+
}
115+
rfds.Set(fd)
116+
// Recomputed each time round: Linux select rewrites the timeout with
117+
// the time left, other systems leave it alone
118+
tv := unix.NsecToTimeval(int64(remaining))
119+
n, err := unix.Select(fd+1, &rfds, nil, nil, &tv)
120+
if err == syscall.EINTR {
121+
continue
122+
}
123+
if err != nil {
124+
// Fall through to the read and let it report the failure
125+
return true
126+
}
127+
return n > 0
128+
}
129+
}
130+
131+
// queryTerminal sends every query in a single write and reads until the last
132+
// one is answered. Returns the submatches of each reply, nil for a query the
133+
// terminal ignored. Replies are cut out of what we read as they are recognized,
134+
// so whatever is left over is input the user typed during the round trip.
135+
func (r *LightRenderer) queryTerminal(queries []termQuery) [][][]byte {
136+
for _, query := range queries {
137+
r.csi(query.seq)
138+
}
98139
r.flush()
99-
var err error
100-
bytes := []byte{}
140+
141+
replies := make([][][]byte, len(queries))
142+
buffer := []byte{}
101143
for tries := range offsetPollTries {
102-
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
144+
// Only the first read blocks, so that is the one to put a bound on
145+
if tries == 0 && !r.waitReadable(queryTimeout) {
146+
return replies
147+
}
148+
149+
var err error
150+
buffer, _, err = r.getBytesInternal(false, buffer, tries > 0)
103151
if err != nil {
104-
return -1, -1
152+
return replies
105153
}
106154

107-
offsets := offsetRegexp.FindSubmatch(bytes)
108-
if len(offsets) > 3 {
109-
// Add anything we skipped over to the input buffer
110-
r.buffer = append(r.buffer, offsets[1]...)
111-
return atoi(string(offsets[2]), 0) - 1, atoi(string(offsets[3]), 0) - 1
155+
for idx, query := range queries {
156+
if replies[idx] != nil {
157+
continue
158+
}
159+
loc := query.reply.FindSubmatchIndex(buffer)
160+
if loc == nil {
161+
continue
162+
}
163+
groups := make([][]byte, len(loc)/2)
164+
for group := range groups {
165+
if loc[group*2] >= 0 {
166+
groups[group] = buffer[loc[group*2]:loc[group*2+1]]
167+
}
168+
}
169+
replies[idx] = groups
170+
// Capping the prefix makes append copy, leaving groups valid
171+
buffer = append(buffer[:loc[0]:loc[0]], buffer[loc[1]:]...)
172+
}
173+
174+
if replies[len(queries)-1] != nil {
175+
break
112176
}
113177
}
114-
return -1, -1
178+
179+
r.buffer = append(r.buffer, buffer...)
180+
return replies
181+
}
182+
183+
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
184+
replies := r.queryTerminal(startupQueries)
185+
if paste := replies[0]; paste != nil && paste[1][0] != '0' {
186+
// 1 = set, 3 = permanently set
187+
set := paste[1][0] == '1' || paste[1][0] == '3'
188+
pasteWasSet = &set
189+
}
190+
row, col = parseOffset(replies[1])
191+
return
192+
}
193+
194+
func parseOffset(reply [][]byte) (row int, col int) {
195+
if reply == nil {
196+
return -1, -1
197+
}
198+
return atoi(string(reply[1]), 0) - 1, atoi(string(reply[2]), 0) - 1
199+
}
200+
201+
func (r *LightRenderer) findOffset() (row int, col int) {
202+
return parseOffset(r.queryTerminal(startupQueries[1:])[0])
115203
}
116204

117205
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {

src/tui/light_windows.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ func (r *LightRenderer) findOffset() (row int, col int) {
151151
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
152152
}
153153

154+
// The console API answers for the cursor, and there is no reply to parse for
155+
// bracketed paste, so fzf keeps disabling the mode on exit here.
156+
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
157+
row, col = r.findOffset()
158+
return
159+
}
160+
154161
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
155162
if !nonblock && !cancellable {
156163
bc := <-r.ttyinChannel

0 commit comments

Comments
 (0)