Compare commits

...
Author SHA1 Message Date
Simon Désaulniers 227e96f4ad fix(nushell): adapt the completer to the 0.116 contract
Nushell 0.116 runs an external completer on a background worker
without the terminal unless the registered closure reaches a
command carrying the @interactive attribute. Interactivity is
seen through the closure to the command it calls, so wrap the
completion logic in an @interactive entry point and register a
closure that ends with the call to it.

Read the named `place` input instead of the deprecated
positional `spans`; $place.command carries the same token list.

An @interactive completer cannot decline with null (null reads
as a dismissed picker), so request the fallback explicitly when
the `**` trigger is absent, keeping plain file completion
working. The previous external completer is still called when
the trigger is absent.

The script now requires Nushell 0.116+, since @interactive is a
parse error on older versions.

Fixes #4932
2026-10-13 11:38:26 -04:00
Junegunn Choi c0a193e031 0.74.5
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
2026-10-11 11:29:16 +09:00
Junegunn Choi cbc11e4d95 completion(zsh): use private mktemp dir for completion FIFO
FIFO path was predictable (${TMPDIR:-/tmp}/fzf-complete-fifo-$$) and
_fzf_feed_fifo ignored rm/mkfifo failures, so on a shared /tmp another
user could pre-create the path (symlink or FIFO) and have the victim's
redirect follow it.

Allocate the FIFO inside a per-invocation mktemp -d directory, check
mkfifo status, and clean up the directory instead of the bare path.

Reported-by: sb24CK (https://github.com/sb24CK)
2026-10-11 11:27:06 +09:00
Junegunn Choi f126ff2954 fzf-tmux: validate attached layout size value
Attached option form (e.g. -l50) captured size via ${arg:2} with no
validation, unlike the separate-argument form. For -l/-u the value
reached $((...)) arithmetic, where a[$(cmd)] runs cmd during option
parsing, before any tmux check. Same value was also expanded unquoted
into `tmux popup ... $opt`, allowing tmux flag injection.

Apply the separate-form regex to the attached form too. Rejects the
array-subscript payload and space-bearing geometry values that enabled
flag injection.

Reported-by: sb24CK (https://github.com/sb24CK)
2026-10-11 11:27:05 +09:00
Junegunn ChoiandJere Viikari d8aeec3058 Preserve tmux window zoom when opening a floating pane
- Use new-pane -A on tmux 3.8+ to keep floating pane above zoomed pane
- Detect -A from new-pane usage; keep unzoom fallback for 3.7

Fix #4927
Fix junegunn/fzf.vim#1625

Co-authored-by: Jere Viikari <jere.viikari@gmail.com>
2026-10-11 11:22:03 +09:00
Junegunn Choi 981f1539b0 Run itest in a separate tmux server
- No user config, so options like extended-keys do not affect tests
- Default 80x24 window, same as CI
2026-10-11 11:05:23 +09:00
Junegunn Choi 25adb67453 Trigger focus event after reload (#4931)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
- Indexes restart after reload, so same-index item was not seen as new
- With --id-nth, compare by key. No event if focused item keeps its key.
- Skip focus check while track is blocked
- Document that binding reload to focus can loop

Close #4930
2026-10-10 20:59:13 +09:00
Junegunn Choi 33a3456921 Fix panic on a read error during the second-chance read
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
The read error left a nil buffer, and the deferred slice in GetChar
panicked instead of returning Fatal.
2026-10-08 21:42:32 +09:00
Junegunn Choi b563e208d6 Check escape sequence bytes before counting them (#4929)
The parser decided how many bytes a key used before checking them, so a
key that arrived in the same read was lost or misread.

- Recognize F1-F8 and modified Delete, PageUp and PageDown when more input
  follows them, as with a paste or tmux send-keys. They required the
  sequence to be the whole buffer.
- After \e[ and a digit, do not consume a byte that cannot continue the
  sequence. ALT-[, 2 and Enter lost the Enter.
- Report Home and End for \e[7, \e[4 and \e[8 only before ~, $, ^ or @.
  ALT-[, 4 and x fired End and lost the x.
- Look for the end of a mouse report within its frame. A later M swallowed
  the keys before it, and repeated \e[< took quadratic time.
2026-10-08 21:34:46 +09:00
Junegunn Choi c009c106b9 Drop unrecognized escape sequences instead of typing them (#4926)
fzf consumed only the part of a sequence it recognized, and the rest was
typed into the query. CTRL-A sent as \e[97;5u became "97;5u", and CTRL-F
sent as \e[70;5u fired Home before typing ";5u". A BEL-terminated OSC
reply also aborted fzf, because the BEL that followed the typed payload
was read as CTRL-G.

Frame CSI and SS3 by their parameter and final byte ranges, OSC, DCS and
APC by their terminator. Drop the whole frame when the parser does not
recognize it, or recognizes only a prefix of it, and never consume past
it: the parser could count a key typed after it. rxvt ends keys with $,
as in \e[7$ and \e[23$, so $ after plain digits is a final byte. Elsewhere
it stays intermediate, as in DECRPM replies.

Take the second-chance read only when the sequence at the start of the
buffer is the only one and is unfinished. Otherwise it blocked until the
next key, holding back what followed, CTRL-G included. That read has no
time limit, so what it returns may be the next key, typed any time later.
It is never dropped with the frame.

Telling a terminal's sequence from an ALT key is the hard part, so these
keep what the parser made of them:

- An unterminated sequence, since that is how ALT-[, ALT-O, ALT-], ALT-P
  and ALT-_ arrive
- A CSI or SS3 of four bytes or fewer. It could be an ALT key and typed
  text, and rxvt sends keys of that size fzf does not know, such as \e[3^
- A string sequence whose payload does not start like a reply: digits and
  ';' for OSC, '>|', '!|', '[01]$r' or '[01]+r' for DCS, 'G' and a key
  for APC
- SOS and PM, which nothing sends

Within a string, an ESC ends it and introduces a sequence of its own, and
only OSC ends with BEL. The read loop does not wait for a string
terminator: that held ALT-], ALT-P and ALT-_ for ESCDELAY and let typed
bytes pile up until they looked like a sequence.
2026-10-08 20:17:03 +09:00
Junegunn Choi b1be3a8be1 Update issue template
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
2026-09-14 12:16:10 +09:00
Junegunn Choi 961793cf39 Fix --gap-line cutting a grapheme cluster
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
RepeatToFill filled the remaining width rune by rune, so a cluster
could be split at the right edge. Iterate grapheme clusters instead.

Fix #4920
2026-09-13 19:31:36 +09:00
21 changed files with 827 additions and 106 deletions

No files matched your search

+14 -5
View File
@@ -3,10 +3,6 @@ name: Issue Template
description: Report a problem or bug related to fzf to help us improve
body:
- type: markdown
attributes:
value: ISSUES NOT FOLLOWING THIS TEMPLATE WILL BE CLOSED AND DELETED
- type: checkboxes
attributes:
label: Checklist
@@ -32,7 +28,6 @@ body:
- label: Linux
- label: macOS
- label: Windows
- label: Etc.
- type: checkboxes
attributes:
@@ -41,9 +36,23 @@ body:
- label: bash
- label: zsh
- label: fish
- label: nushell
- label: PowerShell
- type: textarea
attributes:
label: Problem / Steps to reproduce
validations:
required: true
- type: textarea
attributes:
label: How did you run into this?
description: |
What were you actually trying to do? Include the command line or
configuration from your real setup.
If you did not hit this in actual use, say how you found it
(reading the code, automated analysis, etc.).
validations:
required: true
+15
View File
@@ -1,6 +1,21 @@
CHANGELOG
=========
0.74.5
------
- Fixed `--popup` unzooming the window when opened over a zoomed pane on tmux 3.8 or above (#4927, junegunn/fzf.vim#1625) (@jereviikari)
- On tmux 3.7, the window is still unzoomed, as tmux 3.7b crashes when a floating pane is created over a zoomed window
- Fixed `--gap-line` cutting a grapheme cluster when filling the last cells of the line (#4920)
- Fixed an escape sequence fzf does not recognize being partly consumed, which leaked the rest into the query (#4926)
- Fixed a key lost when it arrived in the same read as an escape sequence
- Fixed `focus` event not triggered after `reload` when the new item is at the same position as the previous one (#4930)
- With `--id-nth`, the event is not triggered if the focus moves directly from an item to another item with the same key
- A `focus` binding that runs `reload` now triggers itself again after each reload, unless `--id-nth` is set and the key does not change
- Security fixes
- fzf-tmux no longer evaluates an attached layout size (e.g. `-l50`) via bash arithmetic, which could run commands or inject tmux flags (GHSA-w6x2-f5m4-xw82)
- zsh completion now creates its FIFO in a private `mktemp -d` directory instead of a predictable `/tmp` path (GHSA-7rj6-7pqx-v7rm)
- Reported by Sai Sreewathsa Kovalluri, Shon Babu, and Kannan K (Researchers) of Innspark Solutions
0.74.4
------
- Fixed an escape sequence split across reads being parsed as a fragment, which leaked the rest into the query (#4899)
+5 -1
View File
@@ -94,8 +94,12 @@ test: $(SOURCES)
github.com/junegunn/fzf/src/tui \
github.com/junegunn/fzf/src/util
# Run in a separate tmux server without user config, like CI (80x24)
itest:
ruby test/runner.rb
@dir=$$(mktemp -d) && \
env -u TMUX TMUX_TMPDIR=$$dir sh -c \
'tmux -f /dev/null new-session -d && ruby test/runner.rb; s=$$?; tmux kill-server; exit $$s'; \
s=$$?; rm -rf $$dir; exit $$s
# Actively fuzz the matcher fast paths against the general algorithm.
# Go fuzzes one target at a time, so iterate. Override duration with
+9 -5
View File
@@ -7,7 +7,11 @@ triggered by a tag push.
## Steps
1. Update version in the following files and commit on `master`:
1. Sign in at https://developer.apple.com/account and accept any pending
agreements (e.g. an updated Apple Developer Program License Agreement).
Otherwise notarization fails.
2. Update version in the following files and commit on `master`:
- `CHANGELOG.md`
- `main.go`
- `install`
@@ -15,10 +19,10 @@ triggered by a tag push.
- `man/man1/fzf.1`
- `man/man1/fzf-tmux.1`
2. Verify file consistency, sign the tag, and push the tag.
3. Verify file consistency, sign the tag, and push the tag.
```sh
make tag VERSION=0.74.4
make tag VERSION=0.74.5
```
`make tag` runs `prerelease` first (checks that the version
@@ -29,10 +33,10 @@ triggered by a tag push.
old version, so `/master/install` keeps resolving against existing
binaries during the publish window.
3. The workflow fires on the tag push and pauses on the `release`
4. The workflow fires on the tag push and pauses on the `release`
environment gate. Approve it in the Actions tab to release.
4. After the GitHub release is published, fast-forward `master`:
5. After the GitHub release is published, fast-forward `master`:
```sh
git push origin master
+1
View File
@@ -78,6 +78,7 @@ while [[ $# -gt 0 ]]; do
fi
if [[ ${#arg} -gt 2 ]]; then
size="${arg:2}"
[[ $size =~ ^[0-9%,]+$ || $size =~ ^[A-Z]$ ]] || continue
else
if [[ $1 =~ ^[0-9%,]+$ ]] || [[ $1 =~ ^[A-Z]$ ]]; then
size="$1"
+1 -1
View File
@@ -2,7 +2,7 @@
set -u
version=0.74.4
version=0.74.5
auto_completion=
key_bindings=
update_config=2
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.74.4"
$version="0.74.5"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+1 -1
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf\-tmux 1 "Sep 2026" "fzf 0.74.4" "fzf\-tmux - open fzf in tmux split pane"
.TH fzf\-tmux 1 "Oct 2026" "fzf 0.74.5" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME
fzf\-tmux - open fzf in tmux split pane
+5 -2
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf 1 "Sep 2026" "fzf 0.74.4" "fzf - a command-line fuzzy finder"
.TH fzf 1 "Oct 2026" "fzf 0.74.5" "fzf - a command-line fuzzy finder"
.SH NAME
fzf - a command-line fuzzy finder
@@ -1909,7 +1909,10 @@ e.g.
\fIfocus\fR
.RS
Triggered when the focus changes due to a vertical cursor movement or a search
result update.
result update. After a reload, the focused item is considered new unless
\fB\-\-id\-nth\fR is set and the item has the same key as the previously
focused item. Because of this, binding \fBreload\fR to this event can cause an
infinite loop.
e.g.
\fBfzf \-\-bind 'focus:transform\-preview\-label:echo [ {} ]' \-\-preview 'cat {}'
+43 -21
View File
@@ -8,6 +8,15 @@
# An implementation of completion.nu
# This loads FZF as a Nushell External Completer
# https://www.nushell.sh/cookbook/external_completers.html
#
# Requires Nushell 0.116.0 or newer.
#
# On 0.116+, the registered completer closure must reach a command carrying the
# `@interactive` attribute: interactivity is seen through the closure to that
# command (a closure cannot carry the attribute itself), and that is what makes
# the completion run on the REPL thread with the terminal, so fzf can take it.
# A closure that does not reach such a command runs on a background worker
# without the terminal, where fzf cannot run.
# --- Default Environment Variables ---
@@ -367,9 +376,9 @@ def _fzf_complete_kill [query: string] {
# --- Main FZF External Completer ---
# This function is registered with Nushell's external completion system.
# It gets called when Tab is pressed.
let fzf_external_completer = {|spans|
# Main completion logic. It receives the token list of the command being
# completed (the legacy `spans`). Called directly, so no attribute is needed.
def __fzf_completer_logic [spans] {
let trigger: string = $env.FZF_COMPLETION_TRIGGER? | default '**'
if ($trigger | is-empty) { return null } # Cannot work with empty trigger
@@ -446,6 +455,29 @@ let fzf_external_completer = {|spans|
}
}
# Nushell 0.116+ entry point. The registered closure must END with a call to
# this command for the completion to run on the REPL thread with the terminal.
#
# It declares `place` (a named input) rather than the legacy `spans`, so the
# completer never goes through the deprecated positional bridge. `$place.command`
# is the token list of the command being completed, the same value the old
# `spans` held (with a trailing "" when the cursor sits on an empty slot).
@interactive
def __fzf_external_completer [place] {
let result = (__fzf_completer_logic $place.command)
if $result != null {
$result
} else {
# Fall back to the previously configured external completer, if any.
let previous = ($env.__fzf_previous_completer? | default null)
let previous_result = if $previous != null { do $previous $place } else { null }
# An @interactive completer cannot decline with null: null reads as a
# dismissed picker with an empty, final answer. Request the fallback
# explicitly so file completion still runs when the trigger is absent.
if $previous_result != null { $previous_result } else { {completions: [], fallback: true} }
}
}
# --- WRAPPER AND REGISTRATION ---
# Guard against re-sourcing: wrapping the completer multiple times would
@@ -455,25 +487,15 @@ if ($env.__fzf_completer_registered? | default false) != true {
# Get the currently configured external completer, if any exists
let previous_external_completer = $env.config? | get completions? | get external? | get completer?
# Define the new wrapper completer
let fzf_wrapper_completer = {|spans|
# 1. Try the FZF completer logic first
let fzf_result = do $fzf_external_completer $spans
# Stash it for `__fzf_external_completer`: a def cannot capture a local
# variable, and the fallback to the previous completer happens inside it.
$env.__fzf_previous_completer = $previous_external_completer
# 2. If FZF returned a result (a list, even an empty one), return it.
# `null` means FZF didn't handle it because the trigger wasn't present.
if $fzf_result != null {
$fzf_result
} else {
# 3. FZF didn't handle it, so call the previous completer (if it exists).
if $previous_external_completer != null {
do $previous_external_completer $spans
} else {
# 4. No previous completer, and FZF didn't handle it. Return null.
null
}
}
}
# The registered closure must END with a call to an `@interactive` command so
# that Nushell 0.116+ runs the completion on the REPL thread with the terminal
# (it inspects the last pipeline element of the closure). Declaring `place`
# instead of `spans` keeps it off the deprecated positional bridge.
let fzf_wrapper_completer = {|place| __fzf_external_completer $place }
# Register the new wrapper completer
# This ensures external completions are enabled and sets our wrapper.
+5 -4
View File
@@ -215,7 +215,7 @@ _fzf_dir_completion() {
_fzf_feed_fifo() {
command rm -f "$1"
mkfifo "$1"
command mkfifo "$1" || return
cat <&0 > "$1" &|
}
@@ -242,8 +242,9 @@ _fzf_complete() {
rest=("$@")
fi
local fifo lbuf matches post
fifo="${TMPDIR:-/tmp}/fzf-complete-fifo-$$"
local fifo fifo_dir lbuf matches post
fifo_dir=$(command mktemp -d "${TMPDIR:-/tmp}/fzf-completion-XXXXXX") || return
fifo="$fifo_dir/fifo"
lbuf=${rest[0]}
post="${funcstack[1]}_post"
type $post > /dev/null 2>&1 || post=cat
@@ -256,7 +257,7 @@ _fzf_complete() {
if [ -n "$matches" ]; then
LBUFFER="$lbuf$matches"
fi
command rm -f "$fifo"
command rm -rf "$fifo_dir"
}
# To use custom hostname lists, override __fzf_list_hosts.
+33 -7
View File
@@ -467,7 +467,7 @@ type Terminal struct {
termSize tui.TermSize
lastAction actionType
lastKey string
lastFocus int32
lastFocus focusedItem
areaLines int
areaColumns int
forcePreview bool
@@ -492,6 +492,11 @@ type selectedItem struct {
item *Item
}
type focusedItem struct {
major int // revision.major; changes on reload
item *Item
}
type byTimeOrder []selectedItem
func (a byTimeOrder) Len() int {
@@ -1186,7 +1191,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
initFunc: func() error { return renderer.Init() },
executing: util.NewAtomicBool(false),
lastAction: actStart,
lastFocus: minItem.Index(),
lastFocus: focusedItem{},
lastActivity: time.Now(),
numLinesCache: make(map[int32]numLinesCacheValue),
// The initial load counts as a search in progress ('start:wait').
@@ -6066,6 +6071,26 @@ func (t *Terminal) currentIndex() int32 {
return minItem.Index()
}
func (t *Terminal) currentFocus() focusedItem {
return focusedItem{t.revision.major, t.currentItem()}
}
// Records the current focus and returns whether it differs from the last one.
// Indexes restart after a reload, so items are compared by --id-nth key, if
// set. Without it, any focused item after a reload is a new one.
func (t *Terminal) updateFocus() bool {
curr := t.currentFocus()
last := t.lastFocus
t.lastFocus = curr
if curr.item == nil || last.item == nil {
return curr.item != last.item
}
if curr.major == last.major {
return curr.item.Index() != last.item.Index()
}
return len(t.idNth) == 0 || t.trackKeyFor(curr.item, t.idNth) != t.trackKeyFor(last.item, t.idNth)
}
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)
@@ -6631,8 +6656,7 @@ func (t *Terminal) Loop() error {
info = true
}
focusChanged := focusedIndex != currentIndex
if (t.hasFocusActions || t.infoCommand != "") && focusChanged && currentIndex != t.lastFocus {
t.lastFocus = currentIndex
if (t.hasFocusActions || t.infoCommand != "") && !t.trackBlocked && t.updateFocus() {
t.eventChan <- tui.Focus.AsEvent()
if t.infoCommand != "" {
info = true
@@ -7017,9 +7041,11 @@ func (t *Terminal) Loop() error {
}
}
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents {
if newIndex := t.currentIndex(); newIndex != currentIndex {
t.lastFocus = newIndex
// Actions are ignored while blocked. The render loop will
// report the focus after the reload.
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents && !t.trackBlocked {
if t.currentIndex() != currentIndex {
t.lastFocus = t.currentFocus()
if t.infoCommand != "" {
req(reqInfo)
}
+23 -16
View File
@@ -5,14 +5,19 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/junegunn/fzf/src/tui"
)
// The flag group of new-pane usage includes -A (tmux 3.8 or above)
var tmuxOverZoomFlag = regexp.MustCompile(`new-pane.*\[-[[:alpha:]]*A`)
// Returns the size of the current window if the tmux server supports
// floating panes (tmux 3.7 or above)
func tmuxFloatingPaneInfo() (int, int, bool) {
// floating panes (tmux 3.7 or above), and whether a floating pane can stay
// above a zoomed pane
func tmuxFloatingPaneInfo() (int, int, bool, bool) {
// TMUX_PANE is not set when fzf is not started from within a pane
// (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating
// pane there; a blocking run-shell suspends key processing for the
@@ -25,7 +30,7 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
// https://github.com/tmux/tmux/issues/5384
target := os.Getenv("TMUX_PANE")
if target == "" {
return 0, 0, false
return 0, 0, false, false
}
// A single invocation for both checks. Cannot rely on the exit status;
// tmux versions before 3.7 exit normally with empty output for an
@@ -33,17 +38,17 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
out, err := exec.Command("tmux", "display-message", "-p", "-t", target,
"#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output()
if err != nil || !strings.Contains(string(out), "new-pane") {
return 0, 0, false
return 0, 0, false, false
}
var width, height int
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
return 0, 0, false
return 0, 0, false, false
}
// The window is too small to fit a floating pane of the minimum size
if width < 3 || height < 3 {
return 0, 0, false
return 0, 0, false, false
}
return width, height, true
return width, height, tmuxOverZoomFlag.Match(out), true
}
// tmux ends a command at an argument ending in ';', so a trailing one is
@@ -85,7 +90,7 @@ func tmuxDim(spec sizeSpec, window int) int {
return max(3, min(dim, window))
}
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, overZoom bool, opts *Options) (int, error) {
// Unlike display-popup, the size of a floating pane does not account for
// the border around it, and the position is that of the content area. To
// stay consistent with popups, treat the requested size as the total
@@ -166,13 +171,15 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh
}
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
// Unzoom the window first; creating a floating pane over a zoomed
// window crashes the tmux server on 3.7b, and newer versions of
// tmux unzoom the window anyway.
target := os.Getenv("TMUX_PANE")
newPane := fmt.Sprintf(
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
newPane := "tmux new-pane -A"
if !overZoom {
// Unzoom the window first; creating a floating pane over a
// zoomed window crashes the tmux server on 3.7b
newPane = fmt.Sprintf("tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane",
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target))
}
newPane += fmt.Sprintf(" -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
@@ -222,10 +229,10 @@ func runTmux(args []string, opts *Options) (int, error) {
// popup is used instead so that the fzf-drawn border is the only border
// shown; the native border of a tmux floating pane cannot be removed.
if nativeBorder(opts) {
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok {
if windowWidth, windowHeight, overZoom, ok := tmuxFloatingPaneInfo(); ok {
opts.Tmux.border = true
argStr, dir := popupArgStr(args, opts)
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, overZoom, opts)
}
}
+214 -34
View File
@@ -348,9 +348,125 @@ func getEnv(name string, defaultValue int) int {
func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f }
func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
// incompleteEscape reports whether the buffer ends in an escape sequence that
// has not been terminated yet. The read loop keeps waiting in that case, so the
// parser is never handed a fragment to guess at.
// csiEnd returns the length of the CSI or SS3 sequence at the start of the
// buffer, 0 if it has no final byte yet, or -1 if it is malformed.
func csiEnd(buffer []byte) int {
digits := true
for i := 2; i < len(buffer); i++ {
b := buffer[i]
// rxvt ends keys with $ after at least one digit, as in \e[7$ for
// SHIFT-HOME and \e[23$ for SHIFT-F11. Elsewhere $ is an intermediate byte.
if csiFinal(b) || b == '$' && digits && i > 2 {
return i + 1
}
if !csiContinues(b) {
return -1
}
digits = digits && b >= '0' && b <= '9'
}
return 0
}
// stringEnd returns the length of the string sequence (DCS, OSC or APC) at the
// start of the buffer, or 0 while nothing has ended it. A terminator is
// included, an ESC that introduces another sequence is not.
func stringEnd(buffer []byte) int {
if len(buffer) < 2 {
return 0
}
// Every one of them ends with ST. BEL ends an OSC as well, because xterm has
// always allowed it, but stopping at a BEL inside a DCS or APC payload would
// frame only its first half and leave the rest to be typed into the query.
bel := buffer[1] == ']'
for i := 2; i < len(buffer); i++ {
switch buffer[i] {
case '\a':
if bel {
return i + 1
}
case Esc.Byte():
if i+1 == len(buffer) {
return 0 // ST may still be arriving
}
if buffer[i+1] == '\\' {
return i + 2
}
// Any other ESC ends the string and introduces a sequence of its
// own, so frame only what precedes it and leave the ESC to be
// parsed again. Scanning past it would swallow that sequence too.
return i
}
}
return 0
}
// rxvtKeyEnd reports whether the byte ends a key such as \e[7~ for Home.
// rxvt marks modifiers with the final byte: $ for SHIFT, ^ for CTRL, @ for both.
func rxvtKeyEnd(b byte) bool {
return b == '~' || b == '$' || b == '^' || b == '@'
}
// csiIntroducer reports whether the byte after ESC starts a sequence that is
// framed by a final byte. The parser routes CSI and SS3 through the same
// parameterized subcases, so both are framed by csiEnd.
func csiIntroducer(b byte) bool {
return b == '[' || b == 'O'
}
// stringIntroducer reports whether the byte after ESC starts a string sequence.
// Only the three that terminals reply with: OSC for colors, title and clipboard,
// DCS for XTVERSION, DECRQSS and XTGETTCAP, APC for Kitty graphics. Nothing
// sends SOS or PM.
func stringIntroducer(b byte) bool {
switch b {
case 'P', ']', '_':
return true
}
return false
}
// stringReply reports whether a framed string sequence starts the way a
// terminal's reply does. Its introducer is also ALT-], ALT-P or ALT-_, and typed
// text after one, ended by CTRL-G or another key, frames like a reply when it
// all arrives in one read.
func stringReply(buffer []byte) bool {
payload := buffer[2:]
switch buffer[1] {
case ']': // Ps ; ...
i := 0
for i < len(payload) && payload[i] >= '0' && payload[i] <= '9' {
i++
}
return i > 0 && i < len(payload) && payload[i] == ';'
case 'P': // >| or !| for the version, [01]$r or [01]+r for settings
if len(payload) < 2 {
return false
}
if payload[1] == '|' {
return payload[0] == '>' || payload[0] == '!'
}
return len(payload) >= 3 && (payload[0] == '0' || payload[0] == '1') &&
(payload[1] == '$' || payload[1] == '+') && payload[2] == 'r'
case '_': // G, then key=value
return len(payload) >= 3 && payload[0] == 'G' &&
(payload[1] >= 'a' && payload[1] <= 'z' || payload[1] >= 'A' && payload[1] <= 'Z') &&
payload[2] == '='
}
return false
}
// stillArriving reports whether the CSI or SS3 sequence at the start of the
// buffer may still be completed by more input. It must be the only one in the buffer:
// whatever follows it, another sequence or a byte that ended it, means it is not
// waiting for anything.
func stillArriving(buffer []byte) bool {
return bytes.IndexByte(buffer[1:], Esc.Byte()) < 0 && incompleteEscape(buffer)
}
// incompleteEscape reports whether the buffer ends in a CSI or SS3 sequence that
// has not been terminated yet. The read loop keeps waiting while it does, so a
// fragment reaches the parser only once that wait has run out. A string sequence
// is never waited for, because its introducer is also ALT-], ALT-P or ALT-_.
func incompleteEscape(buffer []byte) bool {
// Only the tail can hold a sequence still arriving. This runs once per byte
// read, so scanning all of a large paste would make the read quadratic.
@@ -362,21 +478,10 @@ func incompleteEscape(buffer []byte) bool {
if start < 0 || len(tail)-start < 2 {
return false
}
switch tail[start+1] {
case '[':
for _, b := range tail[start+2:] {
if csiFinal(b) {
return false
}
if !csiContinues(b) {
return false // malformed, do not wait for a terminator
}
}
return true
case 'O':
return len(tail)-start < 3
}
return false
// SS3 is judged like CSI: declaring it finished after one byte made the
// parser give up on a split \eO1;5A. A malformed sequence is not waited for,
// as nothing can complete it.
return csiIntroducer(tail[start+1]) && csiEnd(tail[start:]) == 0
}
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
@@ -483,17 +588,27 @@ func (r *LightRenderer) GetChar(cancellable bool) Event {
return Event{CtrlSlash, 0, nil}
case Esc.Byte():
ev := r.escSequence(&sz)
// Second chance
if ev.Type == Invalid {
r.buffer, result, err = r.getBytes(true)
// Second chance, but only for an unfinished sequence. Re-reading
// otherwise blocks until the next keystroke, holding back whatever
// follows in the buffer.
if ev.Type == Invalid && stillArriving(r.buffer) {
waited := len(r.buffer)
var buffer []byte
buffer, result, err = r.getBytes(true)
if err != nil {
return Event{Fatal, 0, nil}
}
r.buffer = buffer
if result == getCharCancelled {
return Event{Invalid, 0, nil}
}
ev = r.escSequence(&sz)
// This read has no time limit, so what it returned may be the next
// keystroke rather than the rest of the sequence. Never drop it.
if ev.Type == Invalid && sz > waited {
sz = waited
}
}
return ev
}
@@ -525,7 +640,41 @@ func (r *LightRenderer) setCancel(f func()) {
r.mutex.Unlock()
}
// escSequence parses an escape sequence, then checks a CSI or SS3 result
// against its frame. parseEscSequence can stop after a few bytes, and consuming
// only those would leave the rest to be typed into the query. Complete
// sequences it does not recognize are dropped there, unless they could be an
// ALT key followed by typed text.
func (r *LightRenderer) escSequence(sz *int) Event {
ev := r.parseEscSequence(sz)
if len(r.buffer) < 3 || !csiIntroducer(r.buffer[1]) {
return ev
}
// A frame missing its final byte may still be arriving
end := csiEnd(r.buffer)
if end <= 0 {
return ev
}
// The parser can also set the size before checking the bytes and then give
// up, counting typed input past the frame, as in \e[3;1u followed by ~
if ev.Type == Invalid && *sz > end {
*sz = end
}
// Four bytes or fewer could be ALT-[ or ALT-O and typed text, and rxvt sends
// keys of that size fzf does not know, such as \e[3^ for CTRL-DELETE
if end <= 4 {
return ev
}
// A key matched on a prefix, such as Home for \e[70;5u, or a sequence given
// up on partway
if end > *sz {
*sz = end
return Event{Invalid, 0, nil}
}
return ev
}
func (r *LightRenderer) parseEscSequence(sz *int) Event {
if len(r.buffer) < 2 {
return Event{Esc, 0, nil}
}
@@ -606,6 +755,11 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if len(r.buffer) < 4 {
return Event{Invalid, 0, nil}
}
// A byte that cannot continue the sequence, such as Enter after
// ALT-[ and a digit, is a key of its own
if csiEnd(r.buffer) < 0 {
break
}
*sz = 4
switch r.buffer[2] {
case '2':
@@ -638,7 +792,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if r.buffer[3] == '~' {
return Event{Delete, 0, nil}
}
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
*sz = 7
switch r.buffer[5] {
case '0':
@@ -657,7 +811,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{CtrlAltShiftDelete, 0, nil}
}
}
if len(r.buffer) == 6 && r.buffer[5] == '~' {
if len(r.buffer) > 5 && r.buffer[5] == '~' {
*sz = 6
switch r.buffer[4] {
case '2':
@@ -680,12 +834,14 @@ func (r *LightRenderer) escSequence(sz *int) Event {
}
return Event{Invalid, 0, nil}
case '4':
return Event{End, 0, nil}
if rxvtKeyEnd(r.buffer[3]) {
return Event{End, 0, nil}
}
case '5':
if r.buffer[3] == '~' {
return Event{PageUp, 0, nil}
}
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
*sz = 7
switch r.buffer[5] {
case '0':
@@ -704,7 +860,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{CtrlAltShiftPageUp, 0, nil}
}
}
if len(r.buffer) == 6 && r.buffer[5] == '~' {
if len(r.buffer) > 5 && r.buffer[5] == '~' {
*sz = 6
switch r.buffer[4] {
case '2':
@@ -730,7 +886,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
if r.buffer[3] == '~' {
return Event{PageDown, 0, nil}
}
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
*sz = 7
switch r.buffer[5] {
case '0':
@@ -749,7 +905,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{CtrlAltShiftPageDown, 0, nil}
}
}
if len(r.buffer) == 6 && r.buffer[5] == '~' {
if len(r.buffer) > 5 && r.buffer[5] == '~' {
*sz = 6
switch r.buffer[4] {
case '2':
@@ -772,15 +928,19 @@ func (r *LightRenderer) escSequence(sz *int) Event {
}
return Event{Invalid, 0, nil}
case '7':
return Event{Home, 0, nil}
if rxvtKeyEnd(r.buffer[3]) {
return Event{Home, 0, nil}
}
case '8':
return Event{End, 0, nil}
if rxvtKeyEnd(r.buffer[3]) {
return Event{End, 0, nil}
}
case '1':
switch r.buffer[3] {
case '~':
return Event{Home, 0, nil}
case '1', '2', '3', '4', '5', '7', '8', '9':
if len(r.buffer) == 5 && r.buffer[4] == '~' {
if len(r.buffer) > 4 && r.buffer[4] == '~' {
*sz = 5
switch r.buffer[3] {
case '1':
@@ -987,6 +1147,24 @@ func (r *LightRenderer) escSequence(sz *int) Event {
} // r.buffer[2]
} // r.buffer[2]
} // r.buffer[1]
// Nothing matched. A framed sequence is dropped whole: reading its
// introducer as an ALT-key below would type the rest into the query.
// Unterminated ones are left alone, as that is how ALT-[, ALT-O, ALT-],
// ALT-P and ALT-_ arrive.
if csiIntroducer(r.buffer[1]) {
// ALT-[ or ALT-O and one or two typed characters can look like a short
// sequence, so ask for at least two bytes before the final byte
if end := csiEnd(r.buffer); end > 4 {
*sz = end
return Event{Invalid, 0, nil}
}
} else if stringIntroducer(r.buffer[1]) {
if end := stringEnd(r.buffer); end > 0 && stringReply(r.buffer) {
*sz = end
return Event{Invalid, 0, nil}
}
}
rest := bytes.NewBuffer(r.buffer[1:])
c, size, err := rest.ReadRune()
if err == nil {
@@ -1003,11 +1181,13 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
return Event{Invalid, 0, nil}
}
rest := r.buffer[*sz:]
end := bytes.IndexAny(rest, "mM")
if end == -1 {
// Not past the frame, where M or m can be a key typed later
frame := csiEnd(r.buffer)
if frame <= 0 || r.buffer[frame-1] != 'm' && r.buffer[frame-1] != 'M' {
return Event{Invalid, 0, nil}
}
rest := r.buffer[*sz:]
end := frame - 1 - *sz
elems := strings.SplitN(string(rest[:end]), ";", 3)
if len(elems) != 3 {
+274
View File
@@ -0,0 +1,274 @@
package tui
import "testing"
// An unrecognized escape sequence must be consumed whole, CSI and string
// sequences alike. Consuming only part of one leaves the rest to be read as
// input and typed into the query.
func TestUnknownEscapeSequence(t *testing.T) {
for _, c := range []struct {
sequence string
event EventType
size int
}{
// Key encodings fzf does not implement
{"\x1b[97;5u", Invalid, 7},
{"\x1b[70;5u", Invalid, 7}, // not Home, which its prefix \e[7 matches
{"\x1b[42;5u", Invalid, 7}, // nor End
{"\x1b[4;5~", Invalid, 6},
{"\x1b[127;5u", Invalid, 8},
{"\x1b[27;5;127~", Invalid, 11},
{"\x1b[57441;1u", Invalid, 10},
{"\x1b\x1b[97;5u", Invalid, 7}, // ALT prefixed, the first ESC is dropped
// Replies to queries fzf did not send, or sent and stopped waiting for
{"\x1b[?1;2c", Invalid, 7},
{"\x1b[>0;95;0c", Invalid, 10},
// Mouse report arriving while mouse input is off
{"\x1b[<0;1;1M", Invalid, 9},
// String sequences
{"\x1b]11;rgb:4a4a/4a4a/4a4a\x1b\\", Invalid, 25}, // background color reply
{"\x1b]0;a title\a", Invalid, 12}, // BEL terminated
{"\x1bP>|kitty(0.48.2)\x1b\\", Invalid, 19}, // XTVERSION reply
{"\x1b_Gi=1;OK\x1b\\", Invalid, 11}, // Kitty graphics reply
{"\x1bP1$r\a\x1b\\", Invalid, 8}, // BEL is payload in a DCS
{"\x1b]11;x\x1bX\x1b\\", Invalid, 6}, // ESC ends it, framing "\e]11;x"
// SS3 is framed like CSI, since the parser routes both the same way
{"\x1bO9;5u", Invalid, 6},
{"\x1bO1;5X", Invalid, 6}, // unknown final byte
{"\x1bO1;5A", CtrlUp, 6}, // recognized, unchanged
{"\x1bOP", F1, 3},
// Left alone: this is how ALT-[, ALT-O, ALT-], ALT-P and ALT-_ arrive
{"\x1b[a", Alt, 2},
{"\x1b[9A", Alt, 2}, // ALT-[ and two characters can look like a CSI
{"\x1b[1m", Alt, 2},
{"\x1b[ x", Alt, 2},
{"\x1bOx", Alt, 2},
{"\x1bO9A", Alt, 2},
// rxvt keys fzf does not know: dropped, not typed
{"\x1b[3^", Invalid, 4}, // CTRL-DELETE
{"\x1b[5^", Invalid, 4}, // CTRL-PAGEUP
{"\x1b[3@", Invalid, 4}, // CTRL-SHIFT-DELETE
{"\x1b[11^", Invalid, 5},
// rxvt ends keys with $ after digits, so what follows is typed
{"\x1b[7$x", Home, 4}, // SHIFT-HOME, then x
{"\x1b[7$1x", Home, 4},
{"\x1b[3$x", Invalid, 4},
{"\x1b[3$1x", Invalid, 4},
{"\x1b[23$x", Invalid, 5}, // SHIFT-F11, then x
{"\x1b[24$1x", Invalid, 5},
// A parse that gives up past the frame consumes only the frame, so the
// typed ~ after these stays in the buffer
{"\x1b[3;1u~", Invalid, 6},
{"\x1b[5;1u~", Invalid, 6},
{"\x1b[2$~", Invalid, 4}, // rxvt SHIFT-INSERT
{"\x1b[2^~", Invalid, 4}, // rxvt CTRL-INSERT
{"\x1b[2A~", Invalid, 4},
// Elsewhere $ and other intermediate bytes do not end a sequence
{"\x1b[4;0;10;20;0&w", Invalid, 15}, // not End, which \e[4 matches
{"\x1b[4;2$y", Invalid, 7},
{"\x1b[2 q", Invalid, 5},
{"\x1b]abc", Alt, 2},
{"\x1b]11;rgb:", Alt, 2}, // terminator has not arrived
{"\x1b]\x1b]", Alt, 2}, // a second sequence must not swallow the ALT key
{"\x1b]\x1b[A", Alt, 2},
{"\x1bP\x1bP", Alt, 2},
{"\x1b_\x1b_", Alt, 2},
{"\x1b]\x1b\\", Alt, 2}, // ALT-] then ALT-backslash, not an empty OSC
{"\x1b]\a", Alt, 2}, // ALT-] then CTRL-G, which must still abort
// Typed text after the introducer is not a reply, even when a
// terminator follows in the same read
{"\x1b]a\a", Alt, 2},
{"\x1b]1\a", Alt, 2},
{"\x1b]a\x1b[A", Alt, 2},
{"\x1bPx\x1b[A", Alt, 2},
{"\x1b_ab\x1bb", Alt, 2},
// SOS and PM are not framed
{"\x1bXsos\x1b\\", Alt, 2},
{"\x1b^status\x1b\\", Alt, 2},
// Left alone: no final byte yet, so the sequence may still be arriving
{"\x1b[", Invalid, 2},
// Recognized sequences keep their existing parsing
{"\x1b[1;5A", CtrlUp, 6},
{"\x1b[3;5~", CtrlDelete, 6},
{"\x1b[2~", Insert, 4},
{"\x1b[200~", BracketedPasteBegin, 6},
{"\x1b[Z", ShiftTab, 3},
{"\x1bOA", Up, 3},
{"\x1b[12;34R", Invalid, 8},
{"\x1b[?2004;2$y", Invalid, 11},
} {
r := &LightRenderer{buffer: []byte(c.sequence)}
sz := 1
event := r.escSequence(&sz)
if event.Type != c.event {
t.Errorf("escSequence(%q) = %s, want %s",
c.sequence, event.Type.String(), c.event.String())
}
if sz != c.size {
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
}
}
}
func TestStringEnd(t *testing.T) {
for _, c := range []struct {
buffer string
want int
}{
// Terminated
{"\x1b]0;t\a", 6},
{"\x1b]0;t\x1b\\", 7},
{"\x1b_G\x1b\\", 5},
{"\x1bP1$r\a\x1b\\", 8}, // BEL is payload in a DCS, ST ends it
// BEL terminates an OSC only
{"\x1bP\a", 0},
{"\x1b_Gi=1\a", 0},
// An ESC ends the string and introduces a sequence of its own
{"\x1b]foo\x1bX\x1b\\", 5},
{"\x1b]foo\x1bP", 5},
{"\x1b]\x1b]", 2},
// Nothing has ended it yet
{"\x1b]0;t", 0},
{"\x1b]0;t\x1b", 0}, // ST half arrived
{"\x1b]foo\x1b", 0},
{"\x1b]", 0},
{"\x1b", 0}, // shorter than an introducer
} {
if got := stringEnd([]byte(c.buffer)); got != c.want {
t.Errorf("stringEnd(%q) = %d, want %d", c.buffer, got, c.want)
}
}
}
// A dropped sequence followed by ALT-[ is not waiting for anything, so the
// bytes in between must not be held back until the next keystroke
func TestStillArriving(t *testing.T) {
for _, c := range []struct {
buffer string
want bool
}{
{"\x1b[97;5u\a\x1b[", false},
{"\x1b[97;5u\x1b[", false},
{"\x1b[3;\a\x1b[", false}, // the BEL ended it, the trailing \e[ is another one
{"\x1b[1;\a", false}, // the BEL has already ended it
{"\x1b[1;", true},
{"\x1b[", true},
{"\x1bO1", true},
{"\x1b[<0;1", true},
} {
if got := stillArriving([]byte(c.buffer)); got != c.want {
t.Errorf("stillArriving(%q) = %v, want %v", c.buffer, got, c.want)
}
}
}
func TestStringReply(t *testing.T) {
for _, c := range []struct {
buffer string
want bool
}{
{"\x1b]11;rgb:1/2/3\a", true},
{"\x1b]0;title\a", true},
{"\x1b]52;c;YWJj\x1b\\", true},
{"\x1bP>|kitty(0.48.2)\x1b\\", true},
{"\x1bP!|0\x1b\\", true},
{"\x1bP1$r0m\x1b\\", true},
{"\x1bP0+r\x1b\\", true},
{"\x1b_Gi=1;OK\x1b\\", true},
// What typed text after ALT-], ALT-P or ALT-_ looks like
{"\x1b]\a", false},
{"\x1b]a\a", false},
{"\x1b]1\a", false},
{"\x1b];\a", false},
{"\x1bPx\x1b\\", false},
{"\x1bP1\x1b\\", false},
{"\x1b_ab\x1b\\", false},
{"\x1b_G\x1b\\", false},
} {
if got := stringReply([]byte(c.buffer)); got != c.want {
t.Errorf("stringReply(%q) = %v, want %v", c.buffer, got, c.want)
}
}
}
func TestCsiEnd(t *testing.T) {
for _, c := range []struct {
buffer string
want int
}{
{"\x1b[97;5u", 7},
{"\x1b[A", 3},
{"\x1b[<0;1;1M", 9},
{"\x1b[?2004;2$y", 11},
{"\x1b[7$x", 4}, // rxvt ends keys with $ after digits
{"\x1b[23$", 5},
{"\x1b[4;2$y", 7}, // but $ is intermediate elsewhere
{"\x1b[97;5", 0}, // no final byte
{"\x1b[", 0}, // no final byte
{"\x1b[1\x01A", -1}, // malformed, do not frame it
{"\x1b[\a", -1},
} {
if got := csiEnd([]byte(c.buffer)); got != c.want {
t.Errorf("csiEnd(%q) = %d, want %d", c.buffer, got, c.want)
}
}
}
// A key that follows a sequence in the same read must not be consumed with it
func TestKeyAfterSequence(t *testing.T) {
for _, c := range []struct {
sequence string
mouse bool
event EventType
size int
}{
// Recognized when more input follows, as from a paste or tmux send-keys
{"\x1b[15~x", false, F5, 5},
{"\x1b[3;5~x", false, CtrlDelete, 6},
{"\x1b[5;13~x", false, CtrlAltPageUp, 7},
{"\x1b[6;2~\x1b[A", false, ShiftPageDown, 6},
// ALT-[ or ALT-O and a digit, then a key that cannot continue a sequence
{"\x1b[2\r", false, Alt, 2},
{"\x1b[12\x03", false, Alt, 2},
{"\x1bO2\a", false, Alt, 2},
{"\x1b[4x", false, Alt, 2}, // not End
{"\x1b[7\r", false, Alt, 2}, // not Home
{"\x1b[7~x", false, Home, 4},
{"\x1b[8^x", false, End, 4}, // rxvt CTRL-END
// A mouse report ends at its own final byte
{"\x1b[<0;5;3Mx", true, Mouse, 9},
{"\x1b[<0;1;\x1b[AM", true, Invalid, 3},
{"\x1b[<0;1;1xM", true, Invalid, 9},
} {
r := &LightRenderer{buffer: []byte(c.sequence), mouse: c.mouse}
sz := 1
event := r.escSequence(&sz)
if event.Type != c.event {
t.Errorf("escSequence(%q) = %s, want %s",
c.sequence, event.Type.String(), c.event.String())
}
if sz != c.size {
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
}
}
}
+43
View File
@@ -51,3 +51,46 @@ func TestIncompleteEscape(t *testing.T) {
}
}
}
// A string sequence is not waited for. Its introducer is also ALT-], ALT-P or
// ALT-_, and holding the read open on those both delayed the key and let typed
// bytes accumulate into something that looked like a sequence.
func TestIncompleteStringEscape(t *testing.T) {
for _, buffer := range []string{
"\x1b]11;rgb:",
"\x1bP>|kitty",
"\x1b_Gi=1",
"\x1b]0;t\x1b",
"\x1b]0;title\a",
"ab\x1b]11;rgb:",
} {
if incompleteEscape([]byte(buffer)) {
t.Errorf("incompleteEscape(%q) = true, want false", buffer)
}
}
}
// SS3 reaches the same parameterized subcases as CSI in the parser, so it has to
// be judged the same way here. Declaring it finished after one byte made the
// parser give up on a split \eO1;5A and type its tail into the query.
func TestIncompleteSS3(t *testing.T) {
for _, c := range []struct {
buffer string
want bool
}{
{"\x1bO", true},
{"\x1bO1", true},
{"\x1bO1;", true},
{"\x1bO1;5", true},
{"\x1bOA", false},
{"\x1bOP", false},
{"\x1bO1;5A", false},
{"\x1b[23$", false}, // rxvt SHIFT-F11 is complete
{"\x1b[7$", false},
{"\x1b[4;2$", true}, // $ after a ; is intermediate
} {
if got := incompleteEscape([]byte(c.buffer)); got != c.want {
t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want)
}
}
}
+54
View File
@@ -6,6 +6,9 @@ import (
"os"
"strings"
"testing"
"time"
"golang.org/x/term"
)
// Drives queryStartup against a terminal simulated by pipes, with the replies
@@ -126,3 +129,54 @@ func TestQueryStartup(t *testing.T) {
})
}
}
// The second-chance read has no time limit, so a key typed long after an
// unfinished sequence must not be dropped with it.
func TestLateKeyAfterUnfinishedSequence(t *testing.T) {
inR, inW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { inR.Close(); inW.Close() })
inW.WriteString("\x1b[12")
go func() {
time.Sleep(2 * defaultEscDelay * time.Millisecond)
inW.WriteString("x")
time.Sleep(2 * defaultEscDelay * time.Millisecond)
inW.WriteString("y") // ends the wait if x was dropped
}()
r := &LightRenderer{ttyin: inR, escDelay: defaultEscDelay}
for range 3 {
if ev := r.GetChar(false); ev.Type == Rune {
if ev.Char != 'x' {
t.Errorf("got %q, want 'x'", ev.Char)
}
return
}
}
t.Error("the late key was dropped")
}
// A read error during the second-chance read must end fzf cleanly, not panic.
func TestSecondChanceReadError(t *testing.T) {
inR, inW, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { inR.Close() })
inW.WriteString("\x1b[12")
go func() {
time.Sleep(2 * defaultEscDelay * time.Millisecond)
inW.Close()
}()
out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
r := &LightRenderer{ttyin: inR, ttyout: out, escDelay: defaultEscDelay, origState: &term.State{}}
if ev := r.GetChar(false); ev.Type != Fatal {
t.Errorf("got %s, want Fatal", ev.Type.String())
}
}
+1 -1
View File
@@ -66,7 +66,6 @@ func TestLightRenderer(t *testing.T) {
assertEscSequence("\x1b[1;1R", "!Invalid")
assertEscSequence("\x1b[", "!Invalid")
assertEscSequence("\x1b[1", "!Invalid")
assertEscSequence("\x1b[3;3~1", "!Invalid")
assertEscSequence("\x1b[13", "!Invalid")
assertEscSequence("\x1b[1;3", "!Invalid")
assertEscSequence("\x1b[1;10", "!Invalid")
@@ -200,6 +199,7 @@ func TestLightRenderer(t *testing.T) {
assertEscSequence("\x1b[1;3H", "alt-home")
assertEscSequence("\x1b[1;3F", "alt-end")
assertEscSequence("\x1b[3;3~", "alt-delete")
assertEscSequence("\x1b[3;3~1", "alt-delete") // then 1
assertEscSequence("\x1b[5;3~", "alt-page-up")
assertEscSequence("\x1b[6;3~", "alt-page-down")
+9 -7
View File
@@ -105,16 +105,18 @@ func RepeatToFill(str string, length int, limit int) string {
rest := limit % length
output := strings.Repeat(str, times)
if rest > 0 {
for _, r := range str {
rest -= uniseg.StringWidth(string(r))
if rest < 0 {
break
}
output += string(r)
if rest == 0 {
// Iterate over grapheme clusters so that we don't cut a cluster in half
end := 0
graphemes := uniseg.NewGraphemes(str)
for rest > 0 && graphemes.Next() {
width := graphemes.Width()
if width > rest {
break
}
rest -= width
_, end = graphemes.Positions()
}
output += str[:end]
}
return output
}
+21
View File
@@ -109,6 +109,27 @@ func TestRepeatToFill(t *testing.T) {
if RepeatToFill("abcde", 10, 42) != strings.Repeat("abcde", 4)+"abcde"[:2] {
t.Error("Expected:", strings.Repeat("abcde", 4)+"abcde"[:2])
}
// Should not cut a grapheme cluster in half
for _, test := range []struct {
str string
limit int
expected string
}{
{"a\u0301b", 1, "a\u0301"},
{"a\u0301b", 3, "a\u0301ba\u0301"},
{"a\u0301b", 4, "a\u0301ba\u0301b"},
{"a\u4e00", 2, "a"},
{"a\u4e00", 4, "a\u4e00a"},
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 1, "-"},
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 2, "-"},
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 4, "-\U0001f468\u200d\U0001f469\u200d\U0001f467-"},
} {
actual := RepeatToFill(test.str, StringWidth(test.str), test.limit)
if actual != test.expected {
t.Errorf("Expected: %q, actual: %q", test.expected, actual)
}
}
}
func TestStringWidth(t *testing.T) {
+55
View File
@@ -1532,6 +1532,61 @@ class TestCore < TestInteractive
tmux.until { |lines| refute_includes(lines[-1], '[[1]]') }
end
def assert_focus_log(expected)
wait do
assert_path_exists tempname
assert_equal expected, File.readlines(tempname, chomp: true)
end
end
def test_focus_event_after_reload
# Without --id-nth, the item at the same index after reload is a new item
tmux.send_keys %(printf 'a\\nb\\n' | #{FZF} --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "c\\nd\\n")'), :Enter
tmux.until { |lines| assert_includes lines, '> a' }
assert_focus_log(%w[a])
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines, '> c' }
assert_focus_log(%w[a c])
end
def test_focus_event_after_reload_id_nth_same_key
# Same --id-nth key after reload: no focus event
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n1 aa\\n2 bb\\n")'), :Enter
tmux.until { |lines| assert_includes lines, '> 1 a' }
assert_focus_log(['1 a'])
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines, '> 1 aa' }
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> 2 bb' }
assert_focus_log(['1 a', '2 bb'])
end
def test_focus_event_after_reload_id_nth_key_not_found
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "3 c\\n4 d\\n")'), :Enter
tmux.until { |lines| assert_includes lines, '> 1 a' }
assert_focus_log(['1 a'])
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines, '> 3 c' }
assert_focus_log(['1 a', '3 c'])
end
def test_focus_event_after_async_reload_id_nth
# No focus event for the items shown before the tracked item arrives.
# 'result' binding makes fzf redraw the list while blocked.
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'result:change-footer(R)' --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n"; sleep 1; printf "1 aa\\n2 bb\\n")'), :Enter
tmux.until { |lines| assert_includes lines, '> 1 a' }
assert_focus_log(['1 a'])
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[-2], '+T*' }
tmux.until do |lines|
assert_includes lines, '> 1 aa'
refute_includes lines[-2], '+T*'
end
tmux.send_keys :Up
tmux.until { |lines| assert_includes lines, '> 2 bb' }
assert_focus_log(['1 a', '2 bb'])
end
def test_result_event
tmux.send_keys '(echo 0; seq 10) | fzf --bind "result:pos(2)"', :Enter
tmux.until { |lines| assert_equal 11, lines.match_count }