Compare commits

..
29 Commits
Author SHA1 Message Date
Junegunn Choi c0a193e031 0.74.5
CodeQL / Analyze (go) (push) Waiting to run
build / build (push) Waiting to run
Test fzf on macOS / build (push) Waiting to run
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
Junegunn Choi a140afeb4d 0.74.4
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
2026-09-12 09:21:00 +09:00
Junegunn Choi 758b1ac73d Document adaptive height waiting for the input (#4915)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
fzf cannot pick the height until the input ends or exceeds it, so a
stream that stays below the height delays the first render.
2026-09-11 22:16:00 +09:00
Junegunn Choi 013532d5cd Update CHANGELOG 2026-09-11 21:54:58 +09:00
Junegunn Choi fe04475d24 [nushell] Quote paths inserted by CTRL-T (#4919)
- Selections were appended to the command line as-is, so a path with
  spaces became several arguments and syntax in a file name was left
  unquoted for the parser
- Read the selection with --print0 and serialize each path with `to nuon`,
  so each one stays a single string literal
- Skip the insertion entirely when nothing was selected
2026-09-11 21:46:03 +09:00
Junegunn Choi b224480a98 Do not erase the line the prompt was on (#4918)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
- When the cursor is not at column 0, Init() makes space so fzf draws on
  the next line and the prompt line is left intact
- Close() undid that: it moved up onto the prompt line before ESC[J, so
  the erase wiped a line fzf never wrote on
- Shells repaint afterwards, so it showed as a flicker of the last prompt
  line. Reported on fish, also seen on bash and nushell. zsh erases the
  same line but repaints fast enough to hide it
- Erase our own area first, then step back and restore the column, so
  nothing on that line is touched, RPROMPT included

Fix #4913
2026-09-10 23:19:38 +09:00
Junegunn Choi 1578b7c3c3 Fix spurious exit while waiting for a key with --listen
- getch() charged EINTR against a 100 iteration limit meant for read
  attempts, and the count only reset once a byte was read
- So signals arriving during an idle prompt drained it, and running out
  returned getCharError, which became Event{Fatal} and exit status 2
- Only the cancellable path is affected, hence only --listen
- select() is not restarted by SA_RESTART, unlike the blocking read on
  the non-cancellable path, so it is the only one that sees EINTR
- waitReadable() already retries EINTR without a limit

Fix #4917
2026-09-10 17:56:15 +09:00
Simon Désaulniers 63e82a9e3d nushell: keybinds support for helix mode (#4914)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
Thanks!
2026-09-09 09:20:02 +09:00
bitraid a1e983baf1 fish: Optimize description alignment of completions (#4912)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
Close #4910
2026-09-09 00:14:48 +09:00
bitraid ad151d8415 fish: Improve custom CTRL-T command execution (#4909)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
The custom CTRL-T command is now executed by the script in the current
shell session, instead of being called by fzf through a subshell. This
ensures that the value of $dir can't be altered by user initialization
scripts, and is also a little faster.

Fix #4498
2026-09-07 08:36:30 +09:00
Junegunn Choi 7e6339979f Fix stale progress in --listen status payload (#4907)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
- progress was set to 100 by every UpdateList, partial results included,
  and never reset when a search started
- So a GET could report a new query, the previous result set, and
  progress 100, and a client had no way to tell it was stale
- Now cleared where the search is requested and set to 100 only on the
  final result. Query and the reset happen in the same locked section
  that dumpStatus locks, so query == X with progress 100 means the
  matches belong to X

Fix #4903
2026-09-06 19:36:40 +09:00
Junegunn Choi 1372d04f79 Fix adaptive height with an inline header or footer border
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
- extraLines() used borderLines(), which reports zero for BorderInline
- Divider drawn for an inline section still takes a line, and
  resizeWindows() reserves it via addInline()
- So adaptive height came up one line short per inline section and the
  list scrolled
- Affects --header-lines-border, --header-border and --footer-border
  alike. All three set came up three lines short
- Without a list border the shape normalizes to BorderTop or BorderBottom,
  which report a line, hence --no-list-border looked like a fix

    seq 10 | fzf --height=~100% --list-border \
                 --header-lines=1 --header-lines-border=inline

Fix #4904
2026-09-06 11:27:17 +09:00
Junegunn Choi a52ac843d7 [vim] Open fzf in a tmux or Zellij floating pane by default (#4905)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
The window fzf was started from stays visible, and can be used while fzf
runs, which a popup inside Vim cannot offer.

Only where the pane is not modal, so tmux 3.7 or above, or Zellij 0.44 or
above with fzf 0.71.0 or above. A tmux popup from 3.3 to 3.6 cannot be left,
and below 3.3 fzf goes through the fzf-tmux script, whose options are
spelled differently. Both keep the window inside Vim. An explicit popup
layout is unaffected and still works from 3.3.

fzf checks tmux before Zellij, so tmux wins when both are set.

g:fzf_layout still wins, so anyone who set it sees no change.
2026-09-06 00:36:08 +09:00
Junegunn Choi 52f4319a72 Update CHANGELOG
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
2026-08-31 19:09:35 +09:00
Junegunn Choi b395cfbd91 Fix --tiebreak=pathname with non-ASCII text
Fix #4902
2026-08-31 18:57:12 +09:00
Cyrus f7ae439ff5 Pin $SHELL to cmd in TestQuoteEntry and TestReplacePlaceholder
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
2026-08-26 09:06:03 +09:00
Junegunn Choi d46bfd005a Keep a minimum wait for a sequence in flight
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
ESCDELAY=0 makes the retry count zero, so the wait added in d0377ed had
no effect and the reply still leaked. Past the introducer these bytes
cannot be the ESC key, so waiting for them adds no Escape latency.

- Wait at least defaultEscDelay, honour a larger ESCDELAY
- Lone ESC still honours ESCDELAY=0, measured 3ms to exit either way

Fix #4899
2026-08-24 09:54:09 +09:00
Junegunn Choi d0377ed4dc Wait for rest of escape sequence before parsing (#4901)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
Read loop dropped its escDelay retry budget after every successful byte,
so a sequence split across reads reached the parser as a fragment, parsed
as ALT-[ with the remainder left behind as query text.

- fzf queries DECRQM at startup since dab626b, so a terminal answering
  late leaked "?2004;2$y" into the query
- Same split leaked modified keys and mouse sequences: CTRL-UP left "5A",
  SGR mouse left "0;1;1M"
- Bound unchanged, a stall longer than escDelay still falls back to ALT

Fix #4899
2026-08-24 00:40:06 +09:00
Junegunn Choi 5cb7bab702 Run fzf asynchronously in the Vim plugin (#4897)
* Run fzf asynchronously in the Vim plugin

Popup mode held the fzf process with system(), which froze Vim until fzf
exited. fzf in a popup draws in a pane of its own, so that process only
waits for it and does not need a window. Hold it with a job instead and
Vim keeps processing its event loop, which is what a live preview needs.
Nothing is displayed for the job. Falls back to the blocking path when
the job cannot start, so the sink still runs and temp files are removed.

- job_start() sets $TERM=dumb and the popup inherits the environment, so
  fzf dropped to its 16-color scheme. Restore it via 'env', or in the
  command itself before 8.0.902, when 'env' was added
- Fullscreen now uses a terminal buffer in a new tab on Vim too. use_term
  lacked parentheses, so && bound tighter than || and the layout test was
  dead on Neovim, which already behaved this way
- fzf#run returns an empty list in these modes. Callers use sink,
  sinklist or exit, and the vader specs now wait for completion
- Append --no-tmux only when the spec asks for a Vim window, so --popup
  in $FZF_DEFAULT_OPTS survives a spec with no layout option

Accept popup as a synonym of the tmux layout key, matching --popup being
the name of --tmux. popup wins when both are given.

s:tmux_enabled():

- Accept $ZELLIJ, which --popup covers as well
- Parse tmux -V with matchstr and compare with s:compare_versions. The
  old string comparison against 'tmux 1.7' misreads 10.0
- Drop the fzf-tmux requirement on tmux 3.3 or above, where --tmux needs
  no script. Removing the script silently disabled popups entirely
- Resolve the script where it is used, and anchor the legacy test to ^-
  so a --tmux value containing a dash, as in 90%,60%,border-native, is
  not mistaken for a legacy flag

* Turn g:loaded_fzf into a version marker

Callers cannot detect plugin-side behavior. fzf#exec() reports the version
of the binary, not of this plugin, and everything else here is script-local.
fzf.vim needs to know whether fzf#run is asynchronous in popup mode before
offering a key binding that depends on it.

The value was only ever read through the exists() guard, so raising it from
1 breaks nothing.
2026-08-23 23:23:51 +09:00
34 changed files with 1311 additions and 144 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
+34
View File
@@ -1,8 +1,32 @@
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)
- e.g. A terminal answering the startup `DECRQM` query late left `?2004;2$y`, CTRL-UP left `5A`, and SGR mouse input left `0;1;1M`
- Fixed `--tiebreak=pathname` not detecting the last path separator when the line contains a non-ASCII character before it (#4902)
- Fixed `progress` in the `--listen` status payload staying at 100 while a new search was running, which made a snapshot with a new query and the previous result set look complete (#4903)
- It is now reset when a search starts and reaches 100 on the final result, so `progress` of 100 means the matches belong to the query reported next to them
- Fixed adaptive height not reserving a line for the divider of an inline header or footer border, so the list came up one line short for each of them (#4904)
- e.g. `seq 10 | fzf --height=~100% --list-border --header-lines=1 --header-lines-border=inline`
- Fixed fzf erasing the line the prompt was on when it exits, which made the last line of the prompt flicker in fish, bash, and nushell (#4913)
- Fixed fzf exiting with status 2 while waiting for a key, when `--listen` is used and 100+ signals interrupt the wait (#4917)
- Vim plugin
- fzf no longer blocks the editor, so live previews keep working while fzf is open
- `fzf#run` returns an empty list when it runs fzf asynchronously. Use `sink`, `sinklist`, or `exit` to get the result
@@ -11,6 +35,16 @@ CHANGELOG
```vim
let g:fzf_layout = { 'popup': '90%,70%' }
```
- fzf now opens in a tmux or Zellij floating pane by default, so the window it was started from stays visible and can be used while fzf is running
- Requires tmux 3.7+ or Zellij 0.44+
- Set `g:fzf_layout` to pick a different layout
- fish
- Fixed custom CTRL-T command not using the prefixed target directory in some cases (#4498) (@bitraid)
- Optimized description alignment of completion items (#4910) (@bitraid)
- nushell
- Added key bindings for Helix editing modes, on nushell 0.115.0 or above (#4914) (@sim590)
- Fixed CTRL-T inserting the selected paths unquoted
- p4p3r (@P4P3R-HAK) reported the security vulnerability and suggested the fix
0.74.3
------
+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
+73 -19
View File
@@ -1,6 +1,26 @@
FZF Vim integration
===================
<!-- vim-markdown-toc GFM -->
* [Installation](#installation)
* [Summary](#summary)
* [`:FZF[!]`](#fzf)
* [Configuration](#configuration)
* [Examples](#examples)
* [Explanation of `g:fzf_colors`](#explanation-of-gfzf_colors)
* [`fzf#run`](#fzfrun)
* [`fzf#wrap`](#fzfwrap)
* [Global options supported by `fzf#wrap`](#global-options-supported-by-fzfwrap)
* [Tips](#tips)
* [fzf inside terminal buffer](#fzf-inside-terminal-buffer)
* [Starting fzf in a Vim popup window](#starting-fzf-in-a-vim-popup-window)
* [Starting fzf in a tmux/Zellij popup window](#starting-fzf-in-a-tmuxzellij-popup-window)
* [Hide statusline](#hide-statusline)
* [License](#license)
<!-- vim-markdown-toc -->
Installation
------------
@@ -133,19 +153,35 @@ let g:fzf_action = {
\ 'ctrl-v': 'vsplit' }
" Default fzf layout
" - Popup window (center of the screen)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
if exists('$TMUX') || exists('$ZELLIJ')
" The Vim plugin will try to open fzf in a tmux or Zellij popup
" if possible (requires recent fzf and tmux/zellij) using --popup option,
" with the following argument:
let g:fzf_layout = { 'popup': '90%,60%' }
else
" If --popup option is not available, it will open in a popup window inside
" Vim (center of the screen)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
endif
" - Popup window (center of the current window)
" Here are some more layout examples:
" - Tmux or Zellij popup at the bottom 40%
let g:fzf_layout = { 'popup': 'bottom,40%' }
" - Tmux or Zellij popup at the top with a different size
let g:fzf_layout = { 'popup': 'top,90%,40%' }
" - Vim popup window: at the center of the current window (relative)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } }
" - Popup window (anchored to the bottom of the current window)
" - Vim popup window: anchored to the bottom of the current window
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true, 'yoffset': 1.0 } }
" - down / up / left / right
" - Vim split window: down / up / left / right
let g:fzf_layout = { 'down': '40%' }
" - Window using a Vim command
" - Vim window using a Vim command
let g:fzf_layout = { 'window': 'enew' }
let g:fzf_layout = { 'window': '-tabnew' }
let g:fzf_layout = { 'window': '10new' }
@@ -408,10 +444,10 @@ Tips
### fzf inside terminal buffer
On the latest versions of Vim and Neovim, fzf will start in a terminal buffer.
If you find the default ANSI colors to be different, consider configuring the
colors using `g:terminal_ansi_colors` in regular Vim or `g:terminal_color_x`
in Neovim.
When fzf is configured to start in a terminal buffer inside Vim or Neovim, you
may find the default ANSI colors to be different. In that case, configure the
colors using `g:terminal_ansi_colors` in regular Vim or `g:terminal_color_x` in
Neovim.
```vim
" Terminal colors for seoul256 color scheme
@@ -442,7 +478,10 @@ else
endif
```
### Starting fzf in a popup window
### Starting fzf in a Vim popup window
You can configure fzf to start in a Vim popup window by setting the `window` key
in `g:fzf_layout`.
```vim
" Required:
@@ -458,18 +497,32 @@ endif
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
```
Alternatively, you can make fzf open in a popup window (requires tmux 3.3 or
above, or Zellij 0.44 or above) by putting `--popup` option value in `popup`
key. `tmux` is accepted as a synonym, just as `--tmux` is an alias of
`--popup`.
### Starting fzf in a tmux/Zellij popup window
fzf can also start in a popup of the multiplexer instead of a window inside
Vim, by putting a `--popup` option value in the `popup` key. `tmux` is
accepted as a synonym, just as `--tmux` is an alias of `--popup`.
The layout works on tmux 3.3 or above, or on Zellij 0.44 or above with fzf
0.71.0 or above. It is the default on tmux 3.7 or above with fzf 0.74.0 or
above, and on Zellij, where the pane is not modal: Vim keeps the window fzf
was started from visible, and you can switch to it while fzf is open. Below
those versions tmux gives a popup that cannot be left, so a window inside Vim
is the default there. On tmux, an explicit `--border` style also gives a modal
popup rather than a floating pane, because the native border of a tmux
floating pane cannot be removed. Drop `--border` to keep the floating pane and
its native border. Zellij keeps the floating pane either way, and hides its
native border when fzf draws one. Set `g:fzf_layout` yourself to choose either
one.
```vim
" See `--popup` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
if exists('$TMUX') || exists('$ZELLIJ')
" See `--popup` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
let g:fzf_layout = { 'popup': '90%,70%' }
else
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
" Configure the Vim popup window in case not on the multiplexer
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } }
endif
```
@@ -477,7 +530,8 @@ endif
When fzf starts in a terminal buffer, the file type of the buffer is set to
`fzf`. So you can set up `FileType fzf` autocmd to customize the settings of
the window.
the window. This applies to the layouts that open inside Vim, not to the tmux
or Zellij pane the default uses, which is not a buffer.
For example, if you open fzf on the bottom on the screen (e.g. `{'down':
'40%'}`), you might want to temporarily disable the statusline for a cleaner
+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.3
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"
+56 -18
View File
@@ -14,7 +14,8 @@ FZF - TABLE OF CONTENTS *fzf* *fzf-to
Global options supported by fzf#wrap |fzf-global-options-supported-by-fzf#wrap|
Tips |fzf-tips|
fzf inside terminal buffer |fzf-inside-terminal-buffer|
Starting fzf in a popup window |fzf-starting-fzf-in-a-popup-window|
Starting fzf in a Vim popup window |fzf-starting-fzf-in-a-vim-popup-window|
Starting fzf in a tmux/Zellij popup window |fzf-starting-fzf-in-a-tmuxzellij-popup-window|
Hide statusline |fzf-hide-statusline|
License |fzf-license|
@@ -161,19 +162,35 @@ Examples~
\ 'ctrl-v': 'vsplit' }
" Default fzf layout
" - Popup window (center of the screen)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
if exists('$TMUX') || exists('$ZELLIJ')
" The Vim plugin will try to open fzf in a tmux or Zellij popup
" if possible (requires recent fzf and tmux/zellij) using --popup option,
" with the following argument:
let g:fzf_layout = { 'popup': '90%,60%' }
else
" If --popup option is not available, it will open in a popup window inside
" Vim (center of the screen)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
endif
" - Popup window (center of the current window)
" Here are some more layout examples:
" - Tmux or Zellij popup at the bottom 40%
let g:fzf_layout = { 'popup': 'bottom,40%' }
" - Tmux or Zellij popup at the top with a different size
let g:fzf_layout = { 'popup': 'top,90%,40%' }
" - Vim popup window: at the center of the current window (relative)
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } }
" - Popup window (anchored to the bottom of the current window)
" - Vim popup window: anchored to the bottom of the current window
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true, 'yoffset': 1.0 } }
" - down / up / left / right
" - Vim split window: down / up / left / right
let g:fzf_layout = { 'down': '40%' }
" - Window using a Vim command
" - Vim window using a Vim command
let g:fzf_layout = { 'window': 'enew' }
let g:fzf_layout = { 'window': '-tabnew' }
let g:fzf_layout = { 'window': '10new' }
@@ -418,8 +435,8 @@ TIPS *fzf-tips*
*fzf-inside-terminal-buffer*
On the latest versions of Vim and Neovim, fzf will start in a terminal buffer.
If you find the default ANSI colors to be different, consider configuring the
When fzf is configured to start in a terminal buffer inside Vim or Neovim, you
may find the default ANSI colors to be different. In that case, configure the
colors using `g:terminal_ansi_colors` in regular Vim or `g:terminal_color_x`
in Neovim.
@@ -452,8 +469,11 @@ in Neovim.
endif
<
< Starting fzf in a popup window >____________________________________________~
*fzf-starting-fzf-in-a-popup-window*
< Starting fzf in a Vim popup window >________________________________________~
*fzf-starting-fzf-in-a-vim-popup-window*
You can configure fzf to start in a Vim popup window by setting the `window`
key in `g:fzf_layout`.
>
" Required:
" - width [float range [0 ~ 1]] or [integer range [8 ~ ]]
@@ -467,16 +487,33 @@ in Neovim.
" - 'rounded' / 'sharp' / 'horizontal' / 'vertical' / 'top' / 'bottom' / 'left' / 'right'
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
<
Alternatively, you can make fzf open in a popup window (requires tmux 3.3 or
above, or Zellij 0.44 or above) by putting `--popup` options in `popup` key.
`tmux` is accepted as a synonym, just as `--tmux` is an alias of `--popup`.
< Starting fzf in a tmux/Zellij popup window >________________________________~
*fzf-starting-fzf-in-a-tmuxzellij-popup-window*
fzf can also start in a popup of the multiplexer instead of a window inside
Vim, by putting a `--popup` option value in the `popup` key. `tmux` is
accepted as a synonym, just as `--tmux` is an alias of `--popup`.
The layout works on tmux 3.3 or above, or on Zellij 0.44 or above with fzf
0.71.0 or above. It is the default on tmux 3.7 or above with fzf 0.74.0 or
above, and on Zellij, where the pane is not modal: Vim keeps the window fzf
was started from visible, and you can switch to it while fzf is open. Below
those versions tmux gives a popup that cannot be left, so a window inside Vim
is the default there. On tmux, an explicit `--border` style also gives a modal
popup rather than a floating pane, because the native border of a tmux
floating pane cannot be removed. Drop `--border` to keep the floating pane and
its native border. Zellij keeps the floating pane either way, and hides its
native border when fzf draws one. Set `g:fzf_layout` yourself to choose either
one.
>
" See `--popup` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
if exists('$TMUX') || exists('$ZELLIJ')
" See `--popup` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
let g:fzf_layout = { 'popup': '90%,70%' }
else
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
" Configure the Vim popup window in case not on the multiplexer
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } }
endif
<
@@ -485,7 +522,8 @@ above, or Zellij 0.44 or above) by putting `--popup` options in `popup` key.
When fzf starts in a terminal buffer, the file type of the buffer is set to
`fzf`. So you can set up `FileType fzf` autocmd to customize the settings of
the window.
the window. This applies to the layouts that open inside Vim, not to the tmux
or Zellij pane the default uses, which is not a buffer.
For example, if you open fzf on the bottom on the screen (e.g. `{'down':
'40%'}`), you might want to temporarily disable the statusline for a cleaner
+1 -1
View File
@@ -2,7 +2,7 @@
set -u
version=0.74.3
version=0.74.5
auto_completion=
key_bindings=
update_config=2
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.74.3"
$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 "Aug 2026" "fzf 0.74.3" "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
+8 -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 "Aug 2026" "fzf 0.74.3" "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
@@ -408,6 +408,9 @@ Adaptive height has the following limitations:
* Cannot be used with top/bottom margin and padding given in percent size
.br
* It will not find the right size when there are multi-line items
.br
* fzf cannot start until the input ends or exceeds the height, so a stream
that stays below it will delay the first render
.TP
.BI "\-\-min\-height=" "HEIGHT[+]"
@@ -1906,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 {}'
+81 -12
View File
@@ -140,6 +140,15 @@ function! s:popup_support()
endfunction
function! s:default_layout()
" A floating pane leaves the window fzf was started from visible and
" reachable while fzf is open. A popup covers it, inside Vim or not.
" Without a job, s:execute_tmux() blocks and freezes Vim. fzf also wants a
" tmux window of at least 3x3, and Vim's pane is never larger than the
" window, so asking Vim is free and survives a resize
if (has('nvim') || has('job')) && &columns >= 3 && &lines >= 3
\ && s:tmux_enabled() && get(s:, 'tmux_floating', 0)
return { 'tmux': '90%,60%' }
endif
return s:popup_support()
\ ? { 'window' : { 'width': 0.9, 'height': 0.6 } }
\ : { 'down': '~40%' }
@@ -165,6 +174,12 @@ function! fzf#install()
if v:shell_error
throw 'Failed to download fzf: '.script
endif
" A new binary invalidates the chosen executable and everything derived from
" its version, including whether fzf opens a floating pane. fzf#install() is
" also the vim-plug 'do' hook, so this can run long after the first fzf call
let [s:versions, s:checked] = [{}, {}]
unlet! s:exec s:tmux s:tmux_floating
endfunction
let s:versions = {}
@@ -257,7 +272,7 @@ function! fzf#exec(...)
endfunction
" Path to the fzf-tmux script, or an empty string if it is not available. Only
" the legacy options still need it. --tmux is handled by fzf itself.
" the legacy options still need it. --popup is handled by fzf itself.
function! s:fzf_tmux_script()
if !executable(s:fzf_tmux)
if !executable('fzf-tmux')
@@ -273,14 +288,7 @@ function! s:tmux_enabled()
return 0
endif
" --tmux covers Zellij as well, where the fzf-tmux script and the tmux
" version are irrelevant, but the binary only learned it in 0.71.0
if exists('$ZELLIJ')
return exists('s:exec')
\ && s:compare_versions(s:get_version(s:exec), '0.71.0') >= 0
endif
if !exists('$TMUX')
if empty($TMUX) && empty($ZELLIJ)
return 0
endif
@@ -288,7 +296,21 @@ function! s:tmux_enabled()
return s:tmux
endif
let s:tmux = 0
let [s:tmux, s:tmux_floating] = [0, 0]
" --popup covers Zellij as well, where the fzf-tmux script and the tmux
" version are irrelevant. fzf learned it in 0.71.0, and the floating pane
" options it passes need Zellij 0.44 or above. fzf checks tmux first, so
" this branch is Zellij without tmux. Both non-empty means tmux wins.
" empty(), not exists(), to match how fzf reads the two variables
if empty($TMUX)
let s:tmux =
\ s:compare_versions(s:get_version(s:fzf_binary()), '0.71.0') >= 0
\ && s:compare_versions(s:zellij_version(), '0.44') >= 0
let s:tmux_floating = s:tmux
return s:tmux
endif
let output = system('tmux -V')
if v:shell_error
return s:tmux
@@ -296,9 +318,15 @@ function! s:tmux_enabled()
" e.g. 'tmux 3.7b', 'tmux next-3.8'
let ver = matchstr(output, '\d\+\.\d\+')
" --tmux requires tmux 3.3 or above, and needs no fzf-tmux script
" --popup requires tmux 3.3 or above, and needs no fzf-tmux script. The
" default layout wants a floating pane, which also needs fzf 0.74.0 or
" above. fzf opens a modal popup otherwise. The version here only skips the
" probe for servers too old to answer it
if s:compare_versions(ver, '3.3') >= 0
let s:tmux = 1
let s:tmux_floating = s:compare_versions(ver, '3.7') >= 0
\ && s:tmux_floating_pane_support()
\ && s:compare_versions(s:get_version(s:fzf_binary()), '0.74.0') >= 0
return s:tmux
endif
@@ -451,7 +479,10 @@ function! fzf#wrap(...)
if !exists('g:fzf_layout') && exists('g:fzf_height')
let opts.down = g:fzf_height
else
let opts = extend(opts, s:validate_layout(get(g:, 'fzf_layout', s:default_layout())))
" Not get(), which would evaluate s:default_layout() and run its version
" checks even when g:fzf_layout makes the answer irrelevant
let opts = extend(opts, s:validate_layout(
\ exists('g:fzf_layout') ? g:fzf_layout : s:default_layout()))
endif
endif
@@ -627,6 +658,44 @@ function! s:present(dict, ...)
return 0
endfunction
" The binary fzf#exec() would choose, without its prompting or installing.
" Layout selection runs before fzf#exec() has resolved one
function! s:fzf_binary()
if exists('s:exec')
return s:exec
endif
let bins = filter(['fzf', s:fzf_go], 'executable(v:val)')
if empty(bins)
return ''
endif
return len(bins) > 1 ? sort(bins, 's:compare_binary_versions')[-1] : bins[0]
endfunction
function! s:zellij_version()
if !exists('s:zellij_ver')
let output = systemlist('zellij --version')
let s:zellij_ver = v:shell_error || empty(output)
\ ? '' : matchstr(output[0], '[0-9.]\+')
endif
return s:zellij_ver
endfunction
" Whether the running server can put fzf in a floating pane. fzf decides on
" the server, not on the version the tmux client reports, so ask it the same
" question rather than predicting the answer. See tmuxFloatingPaneInfo in
" src/tmux.go, which also requires the tmux window to be at least 3x3; that
" one changes with a resize, so s:default_layout() reads it from Vim instead
" of caching it here
function! s:tmux_floating_pane_support()
" fzf does not use a floating pane when it was not started from a pane
if empty($TMUX_PANE)
return 0
endif
let out = system('tmux list-commands new-pane')
" A server that does not know the command exits normally with no output
return !v:shell_error && out =~# 'new-pane'
endfunction
function! s:fzf_tmux(dict)
let size = get(a:dict, 'tmux', '')
if empty(size)
+2 -4
View File
@@ -124,10 +124,8 @@ function fzf_complete -w fzf -d 'fzf command completion and wildcard expansion s
# Determine the tabstop length for description alignment
set -l -- max_columns (math $COLUMNS - 40)
for i in $list[1..500]
set -l -- item (string split -f 1 -- \t $i)
and set -l -- len (string length -V -- $item)
and test "$len" -gt "$tabstop" -a "$len" -lt "$max_columns"
for len in (string match -r -- '^[^\\t]*(?=\\t)' $list[1..500] | string length -V)
test "$len" -gt "$tabstop" -a "$len" -lt "$max_columns"
and set -- tabstop $len
end
set -- tabstop (math $tabstop + 4)
+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.
+10 -3
View File
@@ -104,7 +104,7 @@ function fzf_key_bindings
# Store current token in $dir as root for the 'find' command
function fzf-file-widget -d "List files and folders"
set -l commandline (__fzf_parse_commandline)
set -lx dir $commandline[1]
set -l dir $commandline[1]
set -l fzf_query $commandline[2]
set -l prefix $commandline[3]
@@ -112,10 +112,17 @@ function fzf_key_bindings
"--reverse --walker=file,dir,follow,hidden --scheme=path" \
"--multi $FZF_CTRL_T_OPTS --print0")
set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND"
set -lx FZF_DEFAULT_OPTS_FILE
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
set -lx FZF_DEFAULT_COMMAND
if test -n "$FZF_CTRL_T_COMMAND"
set -f result (eval $FZF_CTRL_T_COMMAND \| (__fzfcmd) --query=$fzf_query | string split0)
else
set -f result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
end
test -n "$result"
and commandline -rt -- (string join -- ' ' $prefix(string escape -n -- $result))' '
commandline -f repaint
+34 -10
View File
@@ -47,6 +47,20 @@ def __fzfcmd []: nothing -> list<string> {
['fzf']
}
# Keybinding modes to activate. The Helix modes only exist since Nushell
# 0.115.0, so they are only included when running a version that supports
# them (major > 0 covers a hypothetical 1.0+ where the minor resets).
def __fzf_modes []: nothing -> list<string> {
let v = version
let major = ($v.major | into int)
let minor = ($v.minor | into int)
if ($major > 0) or ($minor >= 115) {
['emacs', 'vi_normal', 'vi_insert', 'helix_normal', 'helix_select', 'helix_insert']
} else {
['emacs', 'vi_normal', 'vi_insert']
}
}
export-env {
$env.FZF_CTRL_T_OPTS = $env.FZF_CTRL_T_OPTS? | default ""
@@ -55,11 +69,11 @@ export-env {
}
# Directories
const alt_c = {
let alt_c = {
name: fzf_dirs
modifier: alt
keycode: char_c
mode: [emacs, vi_normal, vi_insert]
mode: (__fzf_modes)
event: [
{
send: executehostcommand
@@ -82,11 +96,11 @@ const alt_c = {
}
# History
const ctrl_r = {
let ctrl_r = {
name: fzf_history
modifier: control
keycode: char_r
mode: [emacs, vi_insert, vi_normal]
mode: (__fzf_modes)
event: [
{
send: executehostcommand
@@ -115,16 +129,16 @@ const ctrl_r = {
}
# Files
const ctrl_t = {
let ctrl_t = {
name: fzf_files
modifier: control
keycode: char_t
mode: [emacs, vi_normal, vi_insert]
mode: (__fzf_modes)
event: [
{
send: executehostcommand
cmd: "
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m');
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m --print0');
let fzfcmd = (__fzfcmd);
let fzf_args = ($fzfcmd | skip 1);
let ctrl_t_cmd = ($env.FZF_CTRL_T_COMMAND? | default null);
@@ -135,9 +149,19 @@ const ctrl_t = {
let sh_cmd = [$ctrl_t_cmd '|' $fzf_cmd_str] | str join ' ';
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
};
let result = ($result | str replace --all (char newline) ' ' | str trim);
commandline edit --append $result;
commandline set-cursor --end
# Serialize each path as a Nushell string literal, so that syntax
# in a file name is not evaluated when the line is executed.
let result = (
$result
| split row (char nul)
| where {|path| $path != ''}
| each {|path| $path | to nuon}
| str join ' '
);
if ($result | is-not-empty) {
commandline edit --append $'($result) ';
commandline set-cursor --end
}
"
}
]
+3 -3
View File
@@ -82,10 +82,10 @@ func buildResultFromBounds(item *Item, score int, minBegin, minEnd, maxEnd int,
val = item.TrimLength()
case byPathname:
if validOffsetFound {
// Rune index, to be comparable with minBegin
lastDelim := -1
s := item.text.ToString()
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '/' || s[i] == '\\' {
for i := numChars - 1; i >= 0; i-- {
if r := item.text.Get(i); r == '/' || r == '\\' {
lastDelim = i
break
}
+25
View File
@@ -272,3 +272,28 @@ func TestRadixSortResults(t *testing.T) {
}
}
}
func TestPathnameTiebreak(t *testing.T) {
// FIXME global
sortCriteria = []criterion{byScore, byPathname}
score := 100
test := func(input string, offset Offset, expected uint16) {
for _, chars := range []util.Chars{util.ToChars([]byte(input)), util.RunesToChars([]rune(input))} {
item := buildResult(withIndex(&Item{text: chars}, 1), []Offset{offset}, score)
if item.points[3] != math.MaxUint16-uint16(score) || item.points[2] != expected {
t.Error(input, item.points, expected)
}
}
}
// Match in the file name
test("x/foo/foo.txt", Offset{6, 9}, 1)
// Match in the directory path
test("x/foo/aa.txt", Offset{2, 5}, math.MaxUint16)
// Offsets are rune indexes, so a multi-byte character before the last
// delimiter must not shift the delimiter position
test("一x/foo/foo.txt", Offset{7, 10}, 1)
test("一x/foo/aa.txt", Offset{3, 6}, math.MaxUint16)
}
+48 -11
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').
@@ -1547,6 +1552,14 @@ func (t *Terminal) visibleInputLinesInList() int {
// Extra number of lines needed to display fzf
func (t *Terminal) extraLines() int {
// borderLines() reports zero for BorderInline, but addInline() still
// reserves a divider line for it
sectionLines := func(shape tui.BorderShape) int {
if shape == tui.BorderInline {
return 1
}
return borderLines(shape)
}
extra := 0
if !t.inputless {
extra++
@@ -1562,16 +1575,16 @@ func (t *Terminal) extraLines() int {
}
if t.headerVisible {
if t.hasHeaderWindow() {
extra += borderLines(t.headerBorderShape)
extra += sectionLines(t.headerBorderShape)
}
extra += len(t.header0)
if w, shape := t.determineHeaderLinesShape(); w {
extra += borderLines(shape)
extra += sectionLines(shape)
}
extra += t.headerLines
}
if len(t.footer) > 0 {
extra += borderLines(t.footerBorderShape)
extra += sectionLines(t.footerBorderShape)
extra += len(t.footer)
}
return extra
@@ -2051,7 +2064,9 @@ func (t *Terminal) UpdateList(result MatchResult) {
prevIndex = t.targetIndex
t.targetIndex = minItem.Index()
}
t.progress = 100
if result.final() {
t.progress = 100
}
t.merger = merger
t.resultMerger = merger
t.passMerger = result.passMerger
@@ -6056,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)
@@ -6621,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
@@ -7007,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)
}
@@ -8589,6 +8625,7 @@ func (t *Terminal) Loop() error {
reload := changed || newCommand != nil
if reload {
t.wait.searching = true
t.progress = 0
}
var reloadRequest *searchRequest
if reload {
+6
View File
@@ -29,6 +29,9 @@ func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter
}
func TestReplacePlaceholder(t *testing.T) {
// Pin $SHELL so the quoting style doesn't depend on the test runner's shell
t.Setenv("SHELL", "cmd")
item1 := newItem(" foo'bar \x1b[31mbaz\x1b[m")
items1 := [3][]*Item{{item1}, {item1}, nil}
items2 := [3][]*Item{
@@ -255,6 +258,9 @@ func TestQuoteEntry(t *testing.T) {
unixStyle := quotes{``, `'`, `'\''`, `"`, `\`, `\`}
windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`, `\`}
var effectiveStyle quotes
// Pin $SHELL so the quoting style doesn't depend on the test runner's shell
t.Setenv("SHELL", "cmd")
exec := util.NewExecutor("")
if util.IsWindows() {
+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)
}
}
+249 -18
View File
@@ -26,7 +26,7 @@ const (
offsetPollTries = 10
queryTimeout = 500 * time.Millisecond
maxInputBuffer = 1024 * 1024
maxSelectTries = 100
escapeLookback = 256
)
const DefaultTtyDevice string = "/dev/tty"
@@ -175,6 +175,7 @@ type LightRenderer struct {
width int
height int
yoffset int
xoffset int
tabstop int
escDelay int
fullscreen bool
@@ -277,6 +278,7 @@ func (r *LightRenderer) Init() error {
// increased and we're left with unwanted extra new line.
if x > 0 && r.clearOnExit {
r.upOneLine = true
r.xoffset = x
r.makeSpace()
}
// We assume that --no-clear is used for repetitive relaunching of fzf.
@@ -338,6 +340,150 @@ func getEnv(name string, defaultValue int) int {
return atoi(env, defaultValue)
}
// Bytes of a CSI sequence: parameter and intermediate bytes continue it, a
// final byte ends it. Order is not enforced. Strictness would only make fzf
// give up on a sequence it could have framed.
//
// https://vt100.net/emu/dec_ansi_parser
func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f }
func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
// 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.
tail := buffer
if len(tail) > escapeLookback {
tail = tail[len(tail)-escapeLookback:]
}
start := bytes.LastIndexByte(tail, Esc.Byte())
if start < 0 || len(tail)-start < 2 {
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) {
return r.getBytesInternal(cancellable, r.buffer, false)
}
@@ -378,6 +524,13 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
retries = 0
}
buffer = append(buffer, byte(c))
// Keep waiting while a sequence is still arriving. Dropping the budget
// after every byte left fzf parsing whatever the read happened to end on.
// Past the introducer this is not the ESC key, so the wait costs no
// Escape latency and ESCDELAY=0 must not reduce it to nothing.
if retries == 0 && incompleteEscape(buffer) {
retries = max(r.escDelay, defaultEscDelay) / escPollInterval
}
pc = c
// This should never happen under normal conditions,
@@ -435,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
}
@@ -477,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}
}
@@ -558,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':
@@ -590,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':
@@ -609,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':
@@ -632,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':
@@ -656,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':
@@ -682,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':
@@ -701,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':
@@ -724,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':
@@ -939,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 {
@@ -955,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 {
@@ -1132,10 +1360,13 @@ func (r *LightRenderer) Close() {
r.rmcup()
} else {
r.origin()
// Erase our own area first, then step back onto the line the
// prompt was on, so nothing on that line is touched
r.csi("J")
if r.upOneLine {
r.csi("A")
r.csi(fmt.Sprintf("%dG", r.xoffset+1))
}
r.csi("J")
}
} else if !r.fullscreen {
r.stderr("\x1b8") // DECRC: restore cursor position
+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)
}
}
}
+96
View File
@@ -0,0 +1,96 @@
package tui
import (
"strings"
"testing"
)
func TestIncompleteEscape(t *testing.T) {
for _, c := range []struct {
buffer string
want bool
}{
// Complete sequences: nothing to wait for
{"\x1b[A", false},
{"\x1bOA", false},
{"\x1b[1;5A", false},
{"\x1b[200~", false},
{"\x1b[<0;1;1M", false},
{"\x1b[12;34R", false},
{"\x1b[?2004;2$y", false},
{"\x1b[?1;2c", false},
// Fragments: keep waiting
{"\x1b[", true},
{"\x1b[?", true},
{"\x1b[1;", true},
{"\x1b[?2004;2$", true},
{"\x1bO", true},
{"\x1b[<0;1;", true},
// Only the trailing sequence matters
{"ab\x1b[?2004;2$", true},
{"\x1b[A\x1b[", true},
{"\x1b[A\x1b[B", false},
// Long buffers: only the tail is scanned, so an introducer further
// back than escapeLookback is not waited for
{strings.Repeat("a", 100000), false},
{"\x1b[" + strings.Repeat("a", 100000), false},
{strings.Repeat("a", 100000) + "\x1b[1;", true},
// Not a sequence fzf waits on
{"", false},
{"abc", false},
{"\x1b", false}, // lone ESC, handled by the existing escDelay branch
{"\x1ba", false}, // ALT-a
{"\x1b[\x01", false}, // malformed, do not stall on it
} {
if got := incompleteEscape([]byte(c.buffer)); got != c.want {
t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want)
}
}
}
// 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")
+3 -2
View File
@@ -240,7 +240,7 @@ func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResu
}()
cancelFd := int(rpipe.Fd())
for range maxSelectTries {
for {
var rfds unix.FdSet
limit := len(rfds.Bits) * unix.NFDBITS
if fd >= limit || cancelFd >= limit {
@@ -251,6 +251,8 @@ func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResu
rfds.Set(cancelFd)
_, err := unix.Select(max(fd, cancelFd)+1, &rfds, nil, nil, nil)
if err != nil {
// An interrupted wait is not a failed read, so it must not count
// against anything. Retry until the fd is ready or the wait fails.
if err == syscall.EINTR {
continue
}
@@ -265,7 +267,6 @@ func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResu
return getter()
}
}
return 0, getCharError
}
func (r *LightRenderer) Size() TermSize {
+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 }
+22
View File
@@ -882,6 +882,28 @@ class TestLayout < TestInteractive
tmux.until { assert_block(block, it) }
end
def test_adaptive_height_with_inline_sections
tmux.send_keys %(seq 10 | #{FZF} --height=~100% --list-border --header-lines=1 --header-lines-border=inline), :Enter
block = <<~BLOCK
╭──────
│ 10
│ 9
│ 8
│ 7
│ 6
│ 5
│ 4
│ 3
│ > 2
├──────
│ 1
╰──────
9/9
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_style_full_adaptive_height
tmux.send_keys %(seq 1| #{FZF} --style=full:rounded --height=~100% --header-lines=1 --info=default), :Enter
block = <<~BLOCK
+23
View File
@@ -55,6 +55,29 @@ class TestServer < TestInteractive
end
end
def test_listen_progress
tmux.send_keys "seq 10 | #{FZF} --listen 6266", :Enter
tmux.until { |lines| assert_equal 10, lines.match_count }
uri = URI('http://localhost:6266')
state = -> { JSON.parse(Net::HTTP.get(uri), symbolize_names: true) }
# Idle: the last search is complete
assert_equal 100, state.call[:progress]
# While a search is running, progress is not left at 100 from the
# previous one, so it can tell a settled snapshot from a stale one
Net::HTTP.post(uri, 'reload(sleep 1; seq 100)')
tmux.until { assert_equal 0, state.call[:progress] }
# Settled: matches belong to the snapshot that reports 100
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.until do
st = state.call
assert_equal 100, st[:progress]
assert_equal 100, st[:matchCount]
end
end
def test_listen_with_api_key
uri = URI('http://localhost:6266')
tmux.send_keys 'seq 10 | FZF_API_KEY=123abc fzf --listen 6266', :Enter
+37
View File
@@ -1273,6 +1273,43 @@ class TestNushell < TestBase
FileUtils.rm_rf('/tmp/fzf-test')
end
# Override: paths are inserted as Nushell string literals, so the
# selections appear quoted on the command line.
def test_ctrl_t
set_var('FZF_CTRL_T_COMMAND', 'seq 100')
tmux.prepare
tmux.send_keys 'C-t'
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.send_keys :Tab, :Tab, :Tab
tmux.until { |lines| assert lines.any_include?(' (3)') }
tmux.send_keys :Enter
tmux.until { |lines| assert lines.any_include?('"1" "2" "3"') }
tmux.send_keys 'C-c'
end
# A path is inserted as a Nushell string literal, so that syntax in a file
# name is not evaluated and each path stays a single argument.
def test_ctrl_t_quoting
marker = "#{tempname}-marker"
FileUtils.rm_f(marker)
writelines(["fzf-inject$(touch #{marker}).txt", 'fzf-inject space.txt'])
set_var('FZF_CTRL_T_COMMAND', "cat #{tempname}")
tmux.prepare
tmux.send_keys '^printf "%s\n" ', 'C-t'
tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys :Tab, :Tab
tmux.until { |lines| assert_equal 2, lines.select_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_includes lines[-1].to_s, '"fzf-inject$(touch' }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal ["fzf-inject$(touch #{marker}).txt", 'fzf-inject space.txt'], lines[-2..]
end
refute_path_exists marker
end
# Nushell does not support multiline command recall the same way
# as bash/zsh/fish, so test_ctrl_r_multiline is omitted.
+27
View File
@@ -102,9 +102,36 @@ Execute (fzf#run with dir option and autochdir when final cwd is same as dir):
" Working directory changed due to &acd
AssertEqual '/', getcwd()
Execute (Default layout):
unlet! g:fzf_layout g:fzf_height
let layout_keys = ['window', 'popup', 'tmux', 'up', 'down', 'left', 'right']
let opts = fzf#wrap('foobar')
Log opts
let found = filter(copy(layout_keys), 'has_key(opts, v:val)')
AssertEqual 1, len(found)
if found[0] ==# 'tmux'
" Only where fzf opens a floating pane, which can be left while fzf runs
Assert !empty($TMUX) || !empty($ZELLIJ)
AssertEqual '90%,60%', opts.tmux
elseif found[0] ==# 'window'
AssertEqual 0.9, opts.window.width
else
" No popup support in this build
AssertEqual '~40%', opts.down
endif
" Fullscreen strips it, whichever it was
let opts = fzf#wrap('foobar', {}, 1)
Log opts
AssertEqual [], filter(copy(layout_keys), 'has_key(opts, v:val)')
Execute (fzf#wrap):
AssertThrows fzf#wrap({'foo': 'bar'})
" Pin the layout so the assertions do not depend on the environment
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
let opts = fzf#wrap('foobar')
Log opts
AssertEqual 0.9, opts.window.width