Compare commits

...
Author SHA1 Message Date
Junegunn Choi 785bc950a7 [vim] Open fzf in a tmux or Zellij floating pane by default
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-03 22:11:22 +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
10 changed files with 554 additions and 77 deletions
+17
View File
@@ -1,6 +1,23 @@
CHANGELOG
=========
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)
- 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
- The popup layout now works under Zellij
- Added `popup` as a synonym of the `tmux` layout key
```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
0.74.3
------
- Performance optimizations for non-ASCII input
+76 -20
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' }
@@ -296,7 +332,7 @@ The following table summarizes the available options.
| `options` | string/list | Options to fzf |
| `dir` | string | Working directory |
| `up`/`down`/`left`/`right` | number/string | (Layout) Window position and size (e.g. `20`, `50%`) |
| `tmux` | string | (Layout) `--tmux` options (e.g. `90%,70%`) |
| `popup`/`tmux` | string | (Layout) `--popup` options (e.g. `90%,70%`) |
| `window` (Vim 8 / Neovim) | string | (Layout) Command to open fzf window (e.g. `vertical aboveleft 30new`) |
| `window` (Vim 8 / Neovim) | dict | (Layout) Popup window settings (e.g. `{'width': 0.9, 'height': 0.6}`) |
@@ -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,16 +497,32 @@ endif
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
```
Alternatively, you can make fzf open in a tmux popup window (requires tmux 3.2
or above) by putting `--tmux` option value in `tmux` key.
### 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 `--tmux` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
if exists('$TMUX')
let g:fzf_layout = { 'tmux': '90%,70%' }
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
```
@@ -475,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
+59 -20
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' }
@@ -310,7 +327,7 @@ The following table summarizes the available options.
`options` | string/list | Options to fzf
`dir` | string | Working directory
`up` / `down` / `left` / `right` | number/string | (Layout) Window position and size (e.g. `20` , `50%` )
`tmux` | string | (Layout) `--tmux` options (e.g. `90%,70%` )
`popup` / `tmux` | string | (Layout) `--popup` options (e.g. `90%,70%` )
`window` (Vim 8 / Neovim) | string | (Layout) Command to open fzf window (e.g. `vertical aboveleft 30new` )
`window` (Vim 8 / Neovim) | dict | (Layout) Popup window settings (e.g. `{'width': 0.9, 'height': 0.6}` )
---------------------------+---------------+----------------------------------------------------------------------
@@ -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,15 +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 tmux popup window (requires tmux 3.2
or above) by putting `--tmux` options in `tmux` key.
< 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 `--tmux` option in `man fzf` for available options
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
if exists('$TMUX')
let g:fzf_layout = { 'tmux': '90%,70%' }
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
<
@@ -484,7 +522,8 @@ or above) by putting `--tmux` options in `tmux` 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
+176 -22
View File
@@ -24,7 +24,8 @@
if exists('g:loaded_fzf')
finish
endif
let g:loaded_fzf = 1
" Bumped when the plugin gains behavior a caller may need to detect
let g:loaded_fzf = 20260821
let s:is_win = has('win32') || has('win64')
if s:is_win && &shellslash
@@ -127,7 +128,7 @@ function! s:fzf_tempname()
return s:fzf_call('tempname')
endfunction
let s:layout_keys = ['window', 'tmux', 'up', 'down', 'left', 'right']
let s:layout_keys = ['window', 'popup', 'tmux', 'up', 'down', 'left', 'right']
let s:fzf_go = s:base_dir.'/bin/fzf'
let s:fzf_tmux = s:base_dir.'/bin/fzf-tmux'
@@ -139,6 +140,11 @@ 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
if s:floating_pane()
return { 'tmux': '90%,60%' }
endif
return s:popup_support()
\ ? { 'window' : { 'width': 0.9, 'height': 0.6 } }
\ : { 'down': '~40%' }
@@ -164,6 +170,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 = {}
@@ -255,8 +267,24 @@ function! fzf#exec(...)
return s:exec
endfunction
" Path to the fzf-tmux script, or an empty string if it is not available. Only
" 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')
return ''
endif
let s:fzf_tmux = 'fzf-tmux'
endif
return s:fzf_tmux
endfunction
function! s:tmux_enabled()
if has('gui_running') || !exists('$TMUX')
if has('gui_running')
return 0
endif
if empty($TMUX) && empty($ZELLIJ)
return 0
endif
@@ -264,17 +292,42 @@ function! s:tmux_enabled()
return s:tmux
endif
let s:tmux = 0
if !executable(s:fzf_tmux)
if executable('fzf-tmux')
let s:fzf_tmux = 'fzf-tmux'
else
return 0
endif
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')
let s:tmux = !v:shell_error && output >= 'tmux 1.7'
if v:shell_error
return s:tmux
endif
" e.g. 'tmux 3.7b', 'tmux next-3.8'
let ver = matchstr(output, '\d\+\.\d\+')
" --popup requires tmux 3.3 or above, and needs no fzf-tmux script. The
" default layout wants a floating pane, which needs tmux 3.7 or above, fzf
" 0.74.0 or above, and a pane to start from. fzf opens a modal popup when
" any of the three is missing
if s:compare_versions(ver, '3.3') >= 0
let s:tmux = 1
let s:tmux_floating = s:compare_versions(ver, '3.7') >= 0
\ && !empty($TMUX_PANE)
\ && s:compare_versions(s:get_version(s:fzf_binary()), '0.74.0') >= 0
return s:tmux
endif
" Older versions still go through the script
let s:tmux = !empty(s:fzf_tmux_script()) && s:compare_versions(ver, '1.7') >= 0
return s:tmux
endfunction
@@ -422,7 +475,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
@@ -502,6 +558,11 @@ try
let [shell, shellslash, shellcmdflag, shellxquote] = s:use_sh()
let dict = exists('a:1') ? copy(a:1) : {}
" 'popup' and 'tmux' are synonyms, as --popup and --tmux are. Normalize here
" so that the rest of the function only has to know about one of them.
if has_key(dict, 'popup')
let dict.tmux = remove(dict, 'popup')
endif
let temps = { 'result': s:fzf_tempname() }
let optstr = s:evaluate_opts(get(dict, 'options', ''))
try
@@ -539,16 +600,19 @@ try
\ executable('tput') && filereadable('/dev/tty')
let has_vim8_term = has('terminal') && has('patch-8.0.995')
let has_nvim_term = has('nvim-0.2.1') || has('nvim') && !s:is_win
let use_term = has_nvim_term || has_vim8_term
\ && !s:need_cmd_window
\ && (has('gui_running') || s:is_win || s:present(dict, 'down', 'up', 'left', 'right', 'window'))
let use_term = (has_nvim_term || has_vim8_term) && !s:need_cmd_window
let use_tmux = (has_key(dict, 'tmux') || (!use_height && !use_term || prefer_tmux) && !has('win32unix') && s:splittable(dict)) && s:tmux_enabled()
if prefer_tmux && use_tmux
let use_height = 0
let use_term = 0
endif
if use_term
let optstr .= ' --no-height --no-tmux'
let optstr .= ' --no-height'
" Cancel a --popup from $FZF_DEFAULT_OPTS only when the spec asks for a Vim
" window. Without a layout option, respect the user's preference.
if s:present(dict, 'window', 'up', 'down', 'left', 'right')
let optstr .= ' --no-tmux'
endif
elseif use_height
let height = s:calc_size(&lines, dict.down, dict)
let optstr .= ' --no-tmux --height='.height
@@ -568,8 +632,12 @@ try
return s:execute_term(dict, command, temps)
endif
let lines = use_tmux ? s:execute_tmux(dict, command, temps)
\ : s:execute(dict, command, use_height, temps)
" s:execute_tmux may run fzf asynchronously, so it calls s:callback itself
if use_tmux
return s:execute_tmux(dict, command, temps)
endif
let lines = s:execute(dict, command, use_height, temps)
call s:callback(dict, lines)
return lines
finally
@@ -586,6 +654,38 @@ 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 fzf will open a floating pane that can be left while fzf runs. A
" tmux popup below 3.7 is modal, and the fzf-tmux script that older tmux
" versions need does not accept a --popup value at all. Without a job to wait
" on fzf, s:execute_tmux() blocks on system() and the window stays visible but
" frozen, so the default keeps fzf inside Vim there
function! s:floating_pane()
return (has('nvim') || has('job'))
\ && s:tmux_enabled() && get(s:, 'tmux_floating', 0)
endfunction
function! s:fzf_tmux(dict)
let size = get(a:dict, 'tmux', '')
if empty(size)
@@ -597,10 +697,15 @@ function! s:fzf_tmux(dict)
endfor
endif
" Legacy fzf-tmux options
if size =~ '-'
" Legacy fzf-tmux options are flags. A --tmux value never starts with a dash,
" but may contain one, as in '90%,60%,border-native'
if size =~ '^-'
let script = s:fzf_tmux_script()
if empty(script)
throw 'fzf-tmux not found, required for the legacy option: ' . size
endif
return printf('LINES=%d COLUMNS=%d %s %s %s --',
\ &lines, &columns, fzf#shellescape(s:fzf_tmux), size, (has_key(a:dict, 'source') ? '' : '-'))
\ &lines, &columns, fzf#shellescape(script), size, (has_key(a:dict, 'source') ? '' : '-'))
end
" Using native --tmux option
@@ -742,6 +847,42 @@ function! s:execute(dict, command, use_height, temps) abort
return s:exit_handler(a:dict, exit_status, command) < 2 ? lines : []
endfunction
" Returns 0 if the job could not be started
function! s:start_popup_job(dict, command, temps) abort
let fzf = { 'dict': a:dict, 'temps': a:temps, 'command': a:command }
" Vim passes (job, status) and Nvim (id, status, event)
function! fzf.on_exit(id, code, ...) abort
redraw!
let lines = s:collect(self.temps)
if s:exit_handler(self.dict, a:code, self.command, 1) >= 2
return
endif
call s:pushd(self.dict)
call s:callback(self.dict, lines)
endfunction
if has('nvim')
return jobstart([&shell, &shellcmdflag, a:command], fzf) > 0
endif
" The command redirects to the result file, so no stream is of interest
let opts = { 'exit_cb': function(fzf.on_exit),
\ 'in_io': 'null', 'out_io': 'null', 'err_io': 'null' }
" job_start() gives the child $TERM=dumb, and the popup inherits the
" environment from here, so fzf would lose its colors. 'env' was added in
" 8.0.902, so set it in the command itself on older versions.
let command = a:command
if has('patch-8.0.902')
let opts.env = { 'TERM': $TERM }
elseif !s:is_win
let command = join(['export TERM=' . fzf#shellescape($TERM) . ';', command])
endif
let job = job_start([&shell, &shellcmdflag, command], opts)
return job_status(job) !=# 'fail'
endfunction
function! s:execute_tmux(dict, command, temps) abort
let command = a:command
let cwd = s:pushd(a:dict)
@@ -750,11 +891,24 @@ function! s:execute_tmux(dict, command, temps) abort
let command = join(['cd', fzf#shellescape(cwd), '&&', command])
endif
" fzf draws in a pane of its own, so the process we start here only waits for
" it. Hold it with a job instead of system() so that Vim keeps processing its
" event loop, and nothing has to be displayed for it. Fall back to the
" blocking path when the job cannot be started, or the sink would never run
" and the temporary files would be left behind.
if (has('nvim') || has('job')) && s:start_popup_job(a:dict, command, a:temps)
" Restore the working directory while fzf runs. on_exit pushes it again
call s:dopopd()
return []
endif
call system(command)
let exit_status = v:shell_error
redraw!
let lines = s:collect(a:temps)
return s:exit_handler(a:dict, exit_status, command) < 2 ? lines : []
let lines = s:exit_handler(a:dict, exit_status, command) < 2 ? lines : []
call s:callback(a:dict, lines)
return lines
endfunction
function! s:calc_size(max, val, dict)
+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)
}
+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() {
+47
View File
@@ -26,6 +26,7 @@ const (
offsetPollTries = 10
queryTimeout = 500 * time.Millisecond
maxInputBuffer = 1024 * 1024
escapeLookback = 256
maxSelectTries = 100
)
@@ -338,6 +339,45 @@ 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 }
// 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.
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
}
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
}
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
return r.getBytesInternal(cancellable, r.buffer, false)
}
@@ -378,6 +418,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,
+53
View File
@@ -0,0 +1,53 @@
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)
}
}
}
+92 -12
View File
@@ -4,15 +4,44 @@ Execute (Setup):
Log 'Test directory: ' . g:dir
Save &acd
" fzf#run runs fzf in a terminal buffer and returns immediately, so the
" assertions have to wait for the run to finish. 'exit' is called from the
" same handler that runs the sink, just before it, so once it has fired the
" sink has run too. It also fires when nothing is selected, unlike the sink.
function! g:FzfExit(code)
let g:fzf_done = 1
endfunction
" Collects the output without displacing a 'sink' the spec already has.
" s:callback runs both
function! g:FzfCollect(lines)
call extend(g:fzf_lines, a:lines)
endfunction
function! g:FzfRun(spec) abort
let g:fzf_done = 0
let g:fzf_lines = []
call fzf#run(extend(copy(a:spec),
\ { 'exit': function('g:FzfExit'), 'sinklist': function('g:FzfCollect') }))
let started = reltime()
while !g:fzf_done && reltimefloat(reltime(started)) < 10
sleep 10m
endwhile
if !g:fzf_done
throw 'Timed out waiting for fzf#run'
endif
return g:fzf_lines
endfunction
Execute (fzf#run with dir option):
let cwd = getcwd()
let result = fzf#run({ 'source': 'git ls-files', 'options': '--filter=vdr', 'dir': g:dir })
let result = g:FzfRun({ 'source': 'git ls-files', 'options': '--filter=vdr', 'dir': g:dir })
AssertEqual ['fzf.vader'], result
AssertEqual 0, haslocaldir()
AssertEqual getcwd(), cwd
execute 'lcd' fnameescape(cwd)
let result = sort(fzf#run({ 'source': 'git ls-files', 'options': '--filter e', 'dir': g:dir }))
let result = sort(g:FzfRun({ 'source': 'git ls-files', 'options': '--filter e', 'dir': g:dir }))
AssertEqual ['fzf.vader'], result
AssertEqual 1, haslocaldir()
AssertEqual getcwd(), cwd
@@ -22,32 +51,32 @@ Execute (fzf#run with Funcref command):
function! g:FzfTest(e)
call add(g:ret, a:e)
endfunction
let result = sort(fzf#run({ 'source': 'git ls-files', 'sink': function('g:FzfTest'), 'options': '--filter e', 'dir': g:dir }))
let result = sort(g:FzfRun({ 'source': 'git ls-files', 'sink': function('g:FzfTest'), 'options': '--filter e', 'dir': g:dir }))
AssertEqual ['fzf.vader'], result
AssertEqual ['fzf.vader'], sort(g:ret)
Execute (fzf#run with string source):
let result = sort(fzf#run({ 'source': 'echo hi', 'options': '-f i' }))
let result = sort(g:FzfRun({ 'source': 'echo hi', 'options': '-f i' }))
AssertEqual ['hi'], result
Execute (fzf#run with list source):
let result = sort(fzf#run({ 'source': ['hello', 'world'], 'options': '-f e' }))
let result = sort(g:FzfRun({ 'source': ['hello', 'world'], 'options': '-f e' }))
AssertEqual ['hello'], result
let result = sort(fzf#run({ 'source': ['hello', 'world'], 'options': '-f o' }))
let result = sort(g:FzfRun({ 'source': ['hello', 'world'], 'options': '-f o' }))
AssertEqual ['hello', 'world'], result
Execute (fzf#run with string source):
let result = sort(fzf#run({ 'source': 'echo hi', 'options': '-f i' }))
let result = sort(g:FzfRun({ 'source': 'echo hi', 'options': '-f i' }))
AssertEqual ['hi'], result
Execute (fzf#run with dir option and noautochdir):
set noacd
let cwd = getcwd()
call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
" No change in working directory
AssertEqual cwd, getcwd()
call fzf#run({'source': ['/foobar'], 'sink': 'tabe', 'dir': '/tmp', 'options': '-1'})
call g:FzfRun({'source': ['/foobar'], 'sink': 'tabe', 'dir': '/tmp', 'options': '-1'})
AssertEqual cwd, getcwd()
tabclose
AssertEqual cwd, getcwd()
@@ -55,13 +84,13 @@ Execute (fzf#run with dir option and noautochdir):
Execute (Incomplete fzf#run with dir option and autochdir):
set acd
let cwd = getcwd()
call fzf#run({'source': [], 'sink': 'e', 'dir': '/tmp', 'options': '-0'})
call g:FzfRun({'source': [], 'sink': 'e', 'dir': '/tmp', 'options': '-0'})
" No change in working directory even if &acd is set
AssertEqual cwd, getcwd()
Execute (FIXME: fzf#run with dir option and autochdir):
set acd
call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/tmp', 'options': '-1'})
" Working directory changed due to &acd
AssertEqual '/foobar', expand('%')
AssertEqual '/', getcwd()
@@ -69,13 +98,40 @@ Execute (FIXME: fzf#run with dir option and autochdir):
Execute (fzf#run with dir option and autochdir when final cwd is same as dir):
set acd
cd /tmp
call fzf#run({'source': ['/foobar'], 'sink': 'e', 'dir': '/', 'options': '-1'})
call g:FzfRun({'source': ['/foobar'], 'sink': 'e', 'dir': '/', 'options': '-1'})
" 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
@@ -150,6 +206,30 @@ Execute (fzf#wrap):
let opts = fzf#wrap({})
Assert opts.options =~ '--color=fg:'
Execute (popup is a synonym of tmux):
unlet! g:fzf_layout
" Treated as a layout option, so g:fzf_layout is not applied on top
let opts = fzf#wrap('foobar', {'popup': '90%,70%'})
AssertEqual '90%,70%', opts.popup
Assert !has_key(opts, 'window')
" And stripped in fullscreen, like the others
let opts = fzf#wrap('foobar', {'popup': '90%,70%'}, 1)
Assert !has_key(opts, 'popup')
" Accepted in g:fzf_layout
let g:fzf_layout = {'popup': '90%,70%'}
let opts = fzf#wrap('foobar')
AssertEqual '90%,70%', opts.popup
unlet g:fzf_layout
" Listed as a valid key
let g:fzf_layout = {'bogus': 1}
AssertThrows call fzf#wrap('foobar')
Assert g:vader_exception =~ 'popup'
unlet g:fzf_layout
Execute (fzf#shellescape with sh):
AssertEqual '''''', fzf#shellescape('', 'sh')
AssertEqual '''\''', fzf#shellescape('\', 'sh')