Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package fzf for openSUSE:Factory checked in at 2026-09-21 12:21:39 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/fzf (Old) and /work/SRC/openSUSE:Factory/.fzf.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "fzf" Mon Sep 21 12:21:39 2026 rev:86 rq:1379326 version:0.74.4 Changes: -------- --- /work/SRC/openSUSE:Factory/fzf/fzf.changes 2026-08-18 16:37:39.038437211 +0200 +++ /work/SRC/openSUSE:Factory/.fzf.new.383539/fzf.changes 2026-09-21 12:21:41.510461242 +0200 @@ -1,0 +2,33 @@ +Sun Sep 20 22:27:24 UTC 2026 - Avindra Goolcharan <[email protected]> + +- Update to version 0.74.4: + * Fixed an escape sequence split across reads being parsed as a + fragment, which leaked the rest into the query (#4899) + * 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) + * 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) + * 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: fzf no longer blocks the editor, so live previews keep + working while fzf is open + * vim: The popup layout now works under Zellij + * vim: Added popup as a synonym of the tmux layout key + * vim: 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 + * fish: Fixed custom CTRL-T command not using the prefixed + target directory in some cases (#4498) + * fish: Optimized description alignment of completion items (#4910) + * nushell: Added key bindings for Helix editing modes, on + nushell 0.115.0 or above (#4914) + * nushell: Fixed CTRL-T inserting the selected paths unquoted + +------------------------------------------------------------------- Old: ---- fzf-0.74.3.tar.gz New: ---- fzf-0.74.4.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ fzf.spec ++++++ --- /var/tmp/diff_new_pack.7GHMbK/_old 2026-09-21 12:21:42.251492226 +0200 +++ /var/tmp/diff_new_pack.7GHMbK/_new 2026-09-21 12:21:42.256492435 +0200 @@ -19,7 +19,7 @@ %global _lto_cflags %{nil} Name: fzf -Version: 0.74.3 +Version: 0.74.4 Release: 0 Summary: A command-line fuzzy finder License: MIT ++++++ fzf-0.74.3.tar.gz -> fzf-0.74.4.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/.github/workflows/linux.yml new/fzf-0.74.4/.github/workflows/linux.yml --- old/fzf-0.74.3/.github/workflows/linux.yml 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/.github/workflows/linux.yml 2026-09-12 02:21:00.000000000 +0200 @@ -38,7 +38,9 @@ wget -qO- https://apt.fury.io/nushell/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | sudo tee /etc/apt/sources.list.d/fury-nushell.list sudo apt-get update - sudo apt-get install --yes zsh fish tmux shfmt nushell + # Pinned: 0.115.0 runs external completers on a background thread, so + # fzf never receives keystrokes. https://github.com/nushell/nushell/issues/18771 + sudo apt-get install --yes zsh fish tmux shfmt nushell=0.114.1-0 - name: Install Ruby gems run: bundle install diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/CHANGELOG.md new/fzf-0.74.4/CHANGELOG.md --- old/fzf-0.74.3/CHANGELOG.md 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/CHANGELOG.md 2026-09-12 02:21:00.000000000 +0200 @@ -1,6 +1,36 @@ 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) +- 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 + - 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 +- 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 ------ - Performance optimizations for non-ASCII input diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/README-VIM.md new/fzf-0.74.4/README-VIM.md --- old/fzf-0.74.3/README-VIM.md 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/README-VIM.md 2026-09-12 02:21:00.000000000 +0200 @@ -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 @@ \ '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 @@ | `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 @@ ### 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 @@ 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 @@ 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 -```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%' } +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 +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 @@ 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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/RELEASE.md new/fzf-0.74.4/RELEASE.md --- old/fzf-0.74.3/RELEASE.md 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/RELEASE.md 2026-09-12 02:21:00.000000000 +0200 @@ -18,7 +18,7 @@ 2. Verify file consistency, sign the tag, and push the tag. ```sh - make tag VERSION=0.74.3 + make tag VERSION=0.74.4 ``` `make tag` runs `prerelease` first (checks that the version diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/doc/fzf.txt new/fzf-0.74.4/doc/fzf.txt --- old/fzf-0.74.3/doc/fzf.txt 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/doc/fzf.txt 2026-09-12 02:21:00.000000000 +0200 @@ -14,7 +14,8 @@ 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 @@ \ '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 + + " Here are some more layout examples: + + " - Tmux or Zellij popup at the bottom 40% + let g:fzf_layout = { 'popup': 'bottom,40%' } - " - Popup window (center of the current window) + " - 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 @@ `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 @@ *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 @@ 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 @@ " - '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. -> - " 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%' } + +< 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. +> + 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 @@ 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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/install new/fzf-0.74.4/install --- old/fzf-0.74.3/install 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/install 2026-09-12 02:21:00.000000000 +0200 @@ -2,7 +2,7 @@ set -u -version=0.74.3 +version=0.74.4 auto_completion= key_bindings= update_config=2 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/install.ps1 new/fzf-0.74.4/install.ps1 --- old/fzf-0.74.3/install.ps1 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/install.ps1 2026-09-12 02:21:00.000000000 +0200 @@ -1,4 +1,4 @@ -$version="0.74.3" +$version="0.74.4" $fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/man/man1/fzf-tmux.1 new/fzf-0.74.4/man/man1/fzf-tmux.1 --- old/fzf-0.74.3/man/man1/fzf-tmux.1 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/man/man1/fzf-tmux.1 2026-09-12 02:21:00.000000000 +0200 @@ -21,7 +21,7 @@ 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 "Sep 2026" "fzf 0.74.4" "fzf\-tmux - open fzf in tmux split pane" .SH NAME fzf\-tmux - open fzf in tmux split pane diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/man/man1/fzf.1 new/fzf-0.74.4/man/man1/fzf.1 --- old/fzf-0.74.3/man/man1/fzf.1 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/man/man1/fzf.1 2026-09-12 02:21:00.000000000 +0200 @@ -21,7 +21,7 @@ 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 "Sep 2026" "fzf 0.74.4" "fzf - a command-line fuzzy finder" .SH NAME fzf - a command-line fuzzy finder @@ -408,6 +408,9 @@ * 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[+]" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/plugin/fzf.vim new/fzf-0.74.4/plugin/fzf.vim --- old/fzf-0.74.3/plugin/fzf.vim 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/plugin/fzf.vim 2026-09-12 02:21:00.000000000 +0200 @@ -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 @@ 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,15 @@ 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%' } @@ -164,6 +174,12 @@ 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 +271,24 @@ 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 +296,42 @@ 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 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 + + " 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 +479,10 @@ 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 +562,11 @@ 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 +604,19 @@ \ 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 +636,12 @@ 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 +658,44 @@ 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) @@ -597,10 +707,15 @@ 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 +857,42 @@ 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 +901,24 @@ 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) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/shell/completion.fish new/fzf-0.74.4/shell/completion.fish --- old/fzf-0.74.3/shell/completion.fish 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/shell/completion.fish 2026-09-12 02:21:00.000000000 +0200 @@ -124,10 +124,8 @@ # 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) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/shell/key-bindings.fish new/fzf-0.74.4/shell/key-bindings.fish --- old/fzf-0.74.3/shell/key-bindings.fish 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/shell/key-bindings.fish 2026-09-12 02:21:00.000000000 +0200 @@ -104,7 +104,7 @@ # 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 @@ "--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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/shell/key-bindings.nu new/fzf-0.74.4/shell/key-bindings.nu --- old/fzf-0.74.3/shell/key-bindings.nu 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/shell/key-bindings.nu 2026-09-12 02:21:00.000000000 +0200 @@ -47,6 +47,20 @@ ['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 @@ } # 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 @@ } # 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 @@ } # 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 @@ 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 + } " } ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/src/result.go new/fzf-0.74.4/src/result.go --- old/fzf-0.74.3/src/result.go 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/src/result.go 2026-09-12 02:21:00.000000000 +0200 @@ -82,10 +82,10 @@ 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 } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/src/result_test.go new/fzf-0.74.4/src/result_test.go --- old/fzf-0.74.3/src/result_test.go 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/src/result_test.go 2026-09-12 02:21:00.000000000 +0200 @@ -272,3 +272,28 @@ } } } + +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) +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/src/terminal.go new/fzf-0.74.4/src/terminal.go --- old/fzf-0.74.3/src/terminal.go 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/src/terminal.go 2026-09-12 02:21:00.000000000 +0200 @@ -1547,6 +1547,14 @@ // 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 +1570,16 @@ } 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 +2059,9 @@ 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 @@ -8589,6 +8599,7 @@ reload := changed || newCommand != nil if reload { t.wait.searching = true + t.progress = 0 } var reloadRequest *searchRequest if reload { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/src/terminal_test.go new/fzf-0.74.4/src/terminal_test.go --- old/fzf-0.74.3/src/terminal_test.go 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/src/terminal_test.go 2026-09-12 02:21:00.000000000 +0200 @@ -29,6 +29,9 @@ } 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 @@ 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() { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/src/tui/light.go new/fzf-0.74.4/src/tui/light.go --- old/fzf-0.74.3/src/tui/light.go 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/src/tui/light.go 2026-09-12 02:21:00.000000000 +0200 @@ -26,7 +26,7 @@ offsetPollTries = 10 queryTimeout = 500 * time.Millisecond maxInputBuffer = 1024 * 1024 - maxSelectTries = 100 + escapeLookback = 256 ) const DefaultTtyDevice string = "/dev/tty" @@ -175,6 +175,7 @@ width int height int yoffset int + xoffset int tabstop int escDelay int fullscreen bool @@ -277,6 +278,7 @@ // 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,45 @@ 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 +419,13 @@ 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, @@ -1132,10 +1180,13 @@ 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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/src/tui/light_escape_test.go new/fzf-0.74.4/src/tui/light_escape_test.go --- old/fzf-0.74.3/src/tui/light_escape_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/fzf-0.74.4/src/tui/light_escape_test.go 2026-09-12 02:21:00.000000000 +0200 @@ -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) + } + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/src/tui/light_unix.go new/fzf-0.74.4/src/tui/light_unix.go --- old/fzf-0.74.3/src/tui/light_unix.go 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/src/tui/light_unix.go 2026-09-12 02:21:00.000000000 +0200 @@ -240,7 +240,7 @@ }() 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 @@ 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 @@ return getter() } } - return 0, getCharError } func (r *LightRenderer) Size() TermSize { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/test/test_layout.rb new/fzf-0.74.4/test/test_layout.rb --- old/fzf-0.74.3/test/test_layout.rb 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/test/test_layout.rb 2026-09-12 02:21:00.000000000 +0200 @@ -882,6 +882,28 @@ 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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/test/test_server.rb new/fzf-0.74.4/test/test_server.rb --- old/fzf-0.74.3/test/test_server.rb 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/test/test_server.rb 2026-09-12 02:21:00.000000000 +0200 @@ -55,6 +55,29 @@ 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 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/test/test_shell_integration.rb new/fzf-0.74.4/test/test_shell_integration.rb --- old/fzf-0.74.3/test/test_shell_integration.rb 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/test/test_shell_integration.rb 2026-09-12 02:21:00.000000000 +0200 @@ -1273,6 +1273,43 @@ 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. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/fzf-0.74.3/test/vim/fzf.vader new/fzf-0.74.4/test/vim/fzf.vader --- old/fzf-0.74.3/test/vim/fzf.vader 2026-08-17 05:05:05.000000000 +0200 +++ new/fzf-0.74.4/test/vim/fzf.vader 2026-09-12 02:21:00.000000000 +0200 @@ -4,15 +4,44 @@ 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 @@ 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 (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 (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 @@ 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')
