Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3900dd17e4 | ||
|
|
5e73c2ddd3 | ||
|
|
18e5009e0f |
No files matched your search
@@ -3,6 +3,10 @@ name: Issue Template
|
||||
description: Report a problem or bug related to fzf to help us improve
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: ISSUES NOT FOLLOWING THIS TEMPLATE WILL BE CLOSED AND DELETED
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
@@ -28,6 +32,7 @@ body:
|
||||
- label: Linux
|
||||
- label: macOS
|
||||
- label: Windows
|
||||
- label: Etc.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
@@ -36,23 +41,9 @@ body:
|
||||
- label: bash
|
||||
- label: zsh
|
||||
- label: fish
|
||||
- label: nushell
|
||||
- label: PowerShell
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem / Steps to reproduce
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: How did you run into this?
|
||||
description: |
|
||||
What were you actually trying to do? Include the command line or
|
||||
configuration from your real setup.
|
||||
|
||||
If you did not hit this in actual use, say how you found it
|
||||
(reading the code, automated analysis, etc.).
|
||||
validations:
|
||||
required: true
|
||||
@@ -38,11 +38,9 @@ jobs:
|
||||
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
|
||||
# Pinned: the Nushell scripts now require 0.116+, where an external
|
||||
# completer reaches the terminal through the @interactive attribute.
|
||||
# Before that (e.g. 0.115.0), completers run on a background thread, so
|
||||
# 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.116.1-0
|
||||
sudo apt-get install --yes zsh fish tmux shfmt nushell=0.114.1-0
|
||||
|
||||
- name: Install Ruby gems
|
||||
run: bundle install
|
||||
|
||||
@@ -1,62 +1,8 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
0.74.6
|
||||
------
|
||||
- Fixed `--with-shell` on Windows not detecting cmd, PowerShell, and pwsh, which made fzf quote commands and entries for a POSIX shell (#4936) (@vimkim)
|
||||
- e.g. With `--with-shell "cmd.exe /d /c"`, `--preview '"C:\Program Files\nu\bin\nu.exe" --version'` failed
|
||||
- `transform-nth` and `transform-with-nth` (and their `bg-` variants) no longer split the output at `|`
|
||||
- Only `change-nth` and `change-with-nth` rotate through `|`-separated values
|
||||
- Fixed the bound command being replaced with the output. e.g. With `transform-nth(echo '1|2')`, the next key press ran `2|1` as a shell command.
|
||||
- A `--with-nth` template in the output can now contain `|`. e.g. `{2}|{1}` was cut to `{2}`.
|
||||
- `change-nth` and `transform-nth` now handle empty and invalid values the same way as `change-with-nth` and `transform-with-nth`
|
||||
- An empty value restores the default `--nth`. Previously, `change-nth()` and an empty `transform-nth` output searched all fields. Use `change-nth(..)` for that.
|
||||
- A non-empty invalid value is ignored. Previously, it restored the default `--nth`.
|
||||
- `change-nth` and `change-with-nth` now apply the value after the current one in the `|`-separated list
|
||||
- A value equal to the current one is skipped, e.g. the first value when it is the initial `--nth`
|
||||
- Invalid values in the list are skipped
|
||||
- The rotation follows the current value even when another binding changed it, or when the action is printed by `transform` (unless the list contains the same value more than once)
|
||||
- You can list the values in their natural order, starting with the initial one
|
||||
```sh
|
||||
# Previously, you would have to write:
|
||||
ls -al | fzf --with-nth .. --bind 'space:change-with-nth:1|2|..'
|
||||
|
||||
# Now you can write:
|
||||
ls -al | fzf --with-nth .. --bind 'space:change-with-nth:..|1|2'
|
||||
```
|
||||
- nushell
|
||||
- The completion integration now requires nushell 0.116.1 or above, where an interactive completer can take the terminal through the `@interactive` attribute (#4934) (@sim590)
|
||||
- Fixed `--no-sort` being ignored in filter mode with `--sync`, `--tac`, or `--bench` (#4940) (@NavpreetSSidhu)
|
||||
- Vim plugin: Fixed garbage text on screen in Neovim with `set title` when the fzf command contains ANSI escape sequences (#4941)
|
||||
- Fixed `--walker=follow` skipping a symlink in one `--walker-root` when its target is an ancestor of another root (#4944) (@mabunemeh)
|
||||
- Fixed O(n^2) action parsing in `--listen`. A single ~1 MB request could block the server for ~30 s (@zachariah-mithani, AploSec)
|
||||
|
||||
0.74.5
|
||||
------
|
||||
- Fixed `--popup` unzooming the window when opened over a zoomed pane on tmux 3.8 or above (#4927, junegunn/fzf.vim#1625) (@jereviikari)
|
||||
- On tmux 3.7, the window is still unzoomed, as tmux 3.7b crashes when a floating pane is created over a zoomed window
|
||||
- Fixed `--gap-line` cutting a grapheme cluster when filling the last cells of the line (#4920)
|
||||
- Fixed an escape sequence fzf does not recognize being partly consumed, which leaked the rest into the query (#4926)
|
||||
- Fixed a key lost when it arrived in the same read as an escape sequence
|
||||
- Fixed `focus` event not triggered after `reload` when the new item is at the same position as the previous one (#4930)
|
||||
- With `--id-nth`, the event is not triggered if the focus moves directly from an item to another item with the same key
|
||||
- A `focus` binding that runs `reload` now triggers itself again after each reload, unless `--id-nth` is set and the key does not change
|
||||
- Security fixes
|
||||
- fzf-tmux no longer evaluates an attached layout size (e.g. `-l50`) via bash arithmetic, which could run commands or inject tmux flags (GHSA-w6x2-f5m4-xw82)
|
||||
- zsh completion now creates its FIFO in a private `mktemp -d` directory instead of a predictable `/tmp` path (GHSA-7rj6-7pqx-v7rm)
|
||||
- Reported by Sai Sreewathsa Kovalluri, Shon Babu, and Kannan K (Researchers) of Innspark Solutions
|
||||
|
||||
0.74.4
|
||||
------
|
||||
- Fixed an escape sequence split across reads being parsed as a fragment, which leaked the rest into the query (#4899)
|
||||
- e.g. A terminal answering the startup `DECRQM` query late left `?2004;2$y`, CTRL-UP left `5A`, and SGR mouse input left `0;1;1M`
|
||||
- Fixed `--tiebreak=pathname` not detecting the last path separator when the line contains a non-ASCII character before it (#4902)
|
||||
- Fixed `progress` in the `--listen` status payload staying at 100 while a new search was running, which made a snapshot with a new query and the previous result set look complete (#4903)
|
||||
- It is now reset when a search starts and reaches 100 on the final result, so `progress` of 100 means the matches belong to the query reported next to them
|
||||
- Fixed adaptive height not reserving a line for the divider of an inline header or footer border, so the list came up one line short for each of them (#4904)
|
||||
- e.g. `seq 10 | fzf --height=~100% --list-border --header-lines=1 --header-lines-border=inline`
|
||||
- Fixed fzf erasing the line the prompt was on when it exits, which made the last line of the prompt flicker in fish, bash, and nushell (#4913)
|
||||
- Fixed fzf exiting with status 2 while waiting for a key, when `--listen` is used and 100+ signals interrupt the wait (#4917)
|
||||
- Vim plugin
|
||||
- fzf no longer blocks the editor, so live previews keep working while fzf is open
|
||||
- `fzf#run` returns an empty list when it runs fzf asynchronously. Use `sink`, `sinklist`, or `exit` to get the result
|
||||
@@ -65,16 +11,6 @@ CHANGELOG
|
||||
```vim
|
||||
let g:fzf_layout = { 'popup': '90%,70%' }
|
||||
```
|
||||
- fzf now opens in a tmux or Zellij floating pane by default, so the window it was started from stays visible and can be used while fzf is running
|
||||
- Requires tmux 3.7+ or Zellij 0.44+
|
||||
- Set `g:fzf_layout` to pick a different layout
|
||||
- fish
|
||||
- Fixed custom CTRL-T command not using the prefixed target directory in some cases (#4498) (@bitraid)
|
||||
- Optimized description alignment of completion items (#4910) (@bitraid)
|
||||
- nushell
|
||||
- Added key bindings for Helix editing modes, on nushell 0.115.0 or above (#4914) (@sim590)
|
||||
- Fixed CTRL-T inserting the selected paths unquoted
|
||||
- p4p3r (@P4P3R-HAK) reported the security vulnerability and suggested the fix
|
||||
|
||||
0.74.3
|
||||
------
|
||||
|
||||
@@ -94,12 +94,8 @@ test: $(SOURCES)
|
||||
github.com/junegunn/fzf/src/tui \
|
||||
github.com/junegunn/fzf/src/util
|
||||
|
||||
# Run in a separate tmux server without user config, like CI (80x24)
|
||||
itest:
|
||||
@dir=$$(mktemp -d) && \
|
||||
env -u TMUX TMUX_TMPDIR=$$dir sh -c \
|
||||
'tmux -f /dev/null new-session -d && ruby test/runner.rb; s=$$?; tmux kill-server; exit $$s'; \
|
||||
s=$$?; rm -rf $$dir; exit $$s
|
||||
ruby test/runner.rb
|
||||
|
||||
# Actively fuzz the matcher fast paths against the general algorithm.
|
||||
# Go fuzzes one target at a time, so iterate. Override duration with
|
||||
|
||||
+19
-73
@@ -1,26 +1,6 @@
|
||||
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
|
||||
------------
|
||||
|
||||
@@ -153,35 +133,19 @@ let g:fzf_action = {
|
||||
\ 'ctrl-v': 'vsplit' }
|
||||
|
||||
" Default fzf layout
|
||||
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 screen)
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
|
||||
" 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)
|
||||
" - Popup window (center of the current window)
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } }
|
||||
|
||||
" - Vim popup window: anchored to the bottom of the current window
|
||||
" - 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 } }
|
||||
|
||||
" - Vim split window: down / up / left / right
|
||||
" - down / up / left / right
|
||||
let g:fzf_layout = { 'down': '40%' }
|
||||
|
||||
" - Vim window using a Vim command
|
||||
" - Window using a Vim command
|
||||
let g:fzf_layout = { 'window': 'enew' }
|
||||
let g:fzf_layout = { 'window': '-tabnew' }
|
||||
let g:fzf_layout = { 'window': '10new' }
|
||||
@@ -444,10 +408,10 @@ Tips
|
||||
|
||||
### fzf inside terminal buffer
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```vim
|
||||
" Terminal colors for seoul256 color scheme
|
||||
@@ -478,10 +442,7 @@ else
|
||||
endif
|
||||
```
|
||||
|
||||
### 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`.
|
||||
### Starting fzf in a popup window
|
||||
|
||||
```vim
|
||||
" Required:
|
||||
@@ -497,32 +458,18 @@ in `g:fzf_layout`.
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
```
|
||||
|
||||
### 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.
|
||||
Alternatively, you can make fzf open in a popup window (requires tmux 3.3 or
|
||||
above, or Zellij 0.44 or above) by putting `--popup` option value in `popup`
|
||||
key. `tmux` is accepted as a synonym, just as `--tmux` is an alias of
|
||||
`--popup`.
|
||||
|
||||
```vim
|
||||
" See `--popup` option in `man fzf` for available options
|
||||
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
|
||||
if exists('$TMUX') || exists('$ZELLIJ')
|
||||
" See `--popup` option in `man fzf` for available options
|
||||
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
|
||||
let g:fzf_layout = { 'popup': '90%,70%' }
|
||||
else
|
||||
" Configure the Vim popup window in case not on the multiplexer
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } }
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
endif
|
||||
```
|
||||
|
||||
@@ -530,8 +477,7 @@ 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. This applies to the layouts that open inside Vim, not to the tmux
|
||||
or Zellij pane the default uses, which is not a buffer.
|
||||
the window.
|
||||
|
||||
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
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
|
||||
<kbd align="center">
|
||||
<br/>
|
||||
<a href="https://commitgoods.com/collections/fzf?utm_source=github&utm_medium=referral"><img src="https://junegunn.github.io/fzf/images/fzf-mugs.jpg" width="80%" alt="fzf merch"></a>
|
||||
<a href="https://commitgoods.com/collections/fzf"><img src="https://junegunn.github.io/fzf/images/fzf-mugs.jpg" width="80%" alt="fzf merch"></a>
|
||||
<br/>
|
||||
<br/>
|
||||
Show your love for fzf -- T-shirts, mugs, and stickers now available!
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://commitgoods.com/collections/fzf?utm_source=github&utm_medium=referral"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.shopify.com/s/files/1/0101/8752/files/commitgoods-badge-stacked-navy-dark.svg"><img src="https://cdn.shopify.com/s/files/1/0101/8752/files/commitgoods-badge-stacked-navy.svg" height="28" alt="Official merch on commitgoods.com"></picture></a>
|
||||
<a href="https://commitgoods.com/collections/fzf">commitgoods.com/collections/fzf</a>
|
||||
<br/>
|
||||
<br/>
|
||||
</kbd>
|
||||
@@ -841,13 +841,6 @@ Fuzzy completion in Nushell works via the
|
||||
[external completer](https://www.nushell.sh/cookbook/external_completers.html)
|
||||
mechanism. There are some differences compared to bash and zsh:
|
||||
|
||||
- Nushell 0.116.1 or newer is required. The completion runs on the line-editor
|
||||
thread so fzf can take the terminal; a previously configured external
|
||||
completer (e.g. Carapace) is therefore invoked inline when the `**` trigger
|
||||
is absent.
|
||||
- The script wraps whichever external completer is configured when it loads,
|
||||
so load it after any other external completer setup. The autoload setup
|
||||
described above runs after `config.nu`, so it already does this.
|
||||
- On Nushell >= 0.103.0, the external completer is no longer called for
|
||||
built-in commands (e.g. `cd`, `ls`). Fuzzy completion with `**<TAB>` only
|
||||
works for external commands.
|
||||
@@ -1150,7 +1143,7 @@ Copyright (c) 2013-2026 Junegunn Choi
|
||||
Goods
|
||||
-----
|
||||
|
||||
Grab fzf T-shirts, mugs, and stickers here: [commitgoods.com/collections/fzf](https://commitgoods.com/collections/fzf?utm_source=github&utm_medium=referral)
|
||||
Grab fzf T-shirts, mugs, and stickers here: https://commitgoods.com/collections/fzf
|
||||
|
||||
Sponsors :heart:
|
||||
----------------
|
||||
|
||||
+5
-9
@@ -7,11 +7,7 @@ triggered by a tag push.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Sign in at https://developer.apple.com/account and accept any pending
|
||||
agreements (e.g. an updated Apple Developer Program License Agreement).
|
||||
Otherwise notarization fails.
|
||||
|
||||
2. Update version in the following files and commit on `master`:
|
||||
1. Update version in the following files and commit on `master`:
|
||||
- `CHANGELOG.md`
|
||||
- `main.go`
|
||||
- `install`
|
||||
@@ -19,10 +15,10 @@ triggered by a tag push.
|
||||
- `man/man1/fzf.1`
|
||||
- `man/man1/fzf-tmux.1`
|
||||
|
||||
3. Verify file consistency, sign the tag, and push the tag.
|
||||
2. Verify file consistency, sign the tag, and push the tag.
|
||||
|
||||
```sh
|
||||
make tag VERSION=0.74.5
|
||||
make tag VERSION=0.74.3
|
||||
```
|
||||
|
||||
`make tag` runs `prerelease` first (checks that the version
|
||||
@@ -33,10 +29,10 @@ triggered by a tag push.
|
||||
old version, so `/master/install` keeps resolving against existing
|
||||
binaries during the publish window.
|
||||
|
||||
4. The workflow fires on the tag push and pauses on the `release`
|
||||
3. The workflow fires on the tag push and pauses on the `release`
|
||||
environment gate. Approve it in the Actions tab to release.
|
||||
|
||||
5. After the GitHub release is published, fast-forward `master`:
|
||||
4. After the GitHub release is published, fast-forward `master`:
|
||||
|
||||
```sh
|
||||
git push origin master
|
||||
|
||||
@@ -78,7 +78,6 @@ while [[ $# -gt 0 ]]; do
|
||||
fi
|
||||
if [[ ${#arg} -gt 2 ]]; then
|
||||
size="${arg:2}"
|
||||
[[ $size =~ ^[0-9%,]+$ || $size =~ ^[A-Z]$ ]] || continue
|
||||
else
|
||||
if [[ $1 =~ ^[0-9%,]+$ ]] || [[ $1 =~ ^[A-Z]$ ]]; then
|
||||
size="$1"
|
||||
|
||||
+18
-56
@@ -14,8 +14,7 @@ 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 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|
|
||||
Starting fzf in a popup window |fzf-starting-fzf-in-a-popup-window|
|
||||
Hide statusline |fzf-hide-statusline|
|
||||
License |fzf-license|
|
||||
|
||||
@@ -162,35 +161,19 @@ Examples~
|
||||
\ 'ctrl-v': 'vsplit' }
|
||||
|
||||
" Default fzf layout
|
||||
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 screen)
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
|
||||
" 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)
|
||||
" - Popup window (center of the current window)
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6, 'relative': v:true } }
|
||||
|
||||
" - Vim popup window: anchored to the bottom of the current window
|
||||
" - 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 } }
|
||||
|
||||
" - Vim split window: down / up / left / right
|
||||
" - down / up / left / right
|
||||
let g:fzf_layout = { 'down': '40%' }
|
||||
|
||||
" - Vim window using a Vim command
|
||||
" - Window using a Vim command
|
||||
let g:fzf_layout = { 'window': 'enew' }
|
||||
let g:fzf_layout = { 'window': '-tabnew' }
|
||||
let g:fzf_layout = { 'window': '10new' }
|
||||
@@ -435,8 +418,8 @@ TIPS *fzf-tips*
|
||||
*fzf-inside-terminal-buffer*
|
||||
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -469,11 +452,8 @@ in Neovim.
|
||||
endif
|
||||
<
|
||||
|
||||
< 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`.
|
||||
< Starting fzf in a popup window >____________________________________________~
|
||||
*fzf-starting-fzf-in-a-popup-window*
|
||||
>
|
||||
" Required:
|
||||
" - width [float range [0 ~ 1]] or [integer range [8 ~ ]]
|
||||
@@ -487,33 +467,16 @@ key in `g:fzf_layout`.
|
||||
" - 'rounded' / 'sharp' / 'horizontal' / 'vertical' / 'top' / 'bottom' / 'left' / 'right'
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
<
|
||||
|
||||
< 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.
|
||||
Alternatively, you can make fzf open in a popup window (requires tmux 3.3 or
|
||||
above, or Zellij 0.44 or above) by putting `--popup` options in `popup` key.
|
||||
`tmux` is accepted as a synonym, just as `--tmux` is an alias of `--popup`.
|
||||
>
|
||||
" See `--popup` option in `man fzf` for available options
|
||||
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
|
||||
if exists('$TMUX') || exists('$ZELLIJ')
|
||||
" See `--popup` option in `man fzf` for available options
|
||||
" [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
|
||||
let g:fzf_layout = { 'popup': '90%,70%' }
|
||||
else
|
||||
" Configure the Vim popup window in case not on the multiplexer
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.7 } }
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
endif
|
||||
<
|
||||
|
||||
@@ -522,8 +485,7 @@ one.
|
||||
|
||||
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. This applies to the layouts that open inside Vim, not to the tmux
|
||||
or Zellij pane the default uses, which is not a buffer.
|
||||
the window.
|
||||
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -u
|
||||
|
||||
version=0.74.5
|
||||
version=0.74.3
|
||||
auto_completion=
|
||||
key_bindings=
|
||||
update_config=2
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
$version="0.74.5"
|
||||
$version="0.74.3"
|
||||
|
||||
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
..
|
||||
.TH fzf\-tmux 1 "Oct 2026" "fzf 0.74.5" "fzf\-tmux - open fzf in tmux split pane"
|
||||
.TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.3" "fzf\-tmux - open fzf in tmux split pane"
|
||||
|
||||
.SH NAME
|
||||
fzf\-tmux - open fzf in tmux split pane
|
||||
|
||||
+6
-34
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
..
|
||||
.TH fzf 1 "Oct 2026" "fzf 0.74.5" "fzf - a command-line fuzzy finder"
|
||||
.TH fzf 1 "Aug 2026" "fzf 0.74.3" "fzf - a command-line fuzzy finder"
|
||||
|
||||
.SH NAME
|
||||
fzf - a command-line fuzzy finder
|
||||
@@ -408,9 +408,6 @@ Adaptive height has the following limitations:
|
||||
* Cannot be used with top/bottom margin and padding given in percent size
|
||||
.br
|
||||
* It will not find the right size when there are multi-line items
|
||||
.br
|
||||
* fzf cannot start until the input ends or exceeds the height, so a stream
|
||||
that stays below it will delay the first render
|
||||
|
||||
.TP
|
||||
.BI "\-\-min\-height=" "HEIGHT[+]"
|
||||
@@ -1909,10 +1906,7 @@ e.g.
|
||||
\fIfocus\fR
|
||||
.RS
|
||||
Triggered when the focus changes due to a vertical cursor movement or a search
|
||||
result update. After a reload, the focused item is considered new unless
|
||||
\fB\-\-id\-nth\fR is set and the item has the same key as the previously
|
||||
focused item. Because of this, binding \fBreload\fR to this event can cause an
|
||||
infinite loop.
|
||||
result update.
|
||||
|
||||
e.g.
|
||||
\fBfzf \-\-bind 'focus:transform\-preview\-label:echo [ {} ]' \-\-preview 'cat {}'
|
||||
@@ -2060,8 +2054,8 @@ A key or an event can be bound to one or more of the following actions.
|
||||
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
|
||||
\fBchange\-multi\fR (enable multi-select mode with no limit)
|
||||
\fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0)
|
||||
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|')
|
||||
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; rotate through the multiple options separated by '|')
|
||||
\fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option)
|
||||
\fBchange\-preview(...)\fR (change \fB\-\-preview\fR option)
|
||||
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
|
||||
@@ -2166,8 +2160,8 @@ A key or an event can be bound to one or more of the following actions.
|
||||
\fBtransform\-header\-label(...)\fR (transform header label using an external command)
|
||||
\fBtransform\-input\-label(...)\fR (transform input label using an external command)
|
||||
\fBtransform\-list\-label(...)\fR (transform list label using an external command)
|
||||
\fBtransform\-nth(...)\fR (transform nth using an external command; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBtransform\-nth(...)\fR (transform nth using an external command)
|
||||
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command)
|
||||
\fBtransform\-pointer(...)\fR (transform pointer using an external command)
|
||||
\fBtransform\-preview\-label(...)\fR (transform preview label using an external command)
|
||||
\fBtransform\-prompt(...)\fR (transform prompt string using an external command)
|
||||
@@ -2405,28 +2399,6 @@ e.g.
|
||||
# This is equivalent to toggle\-preview action
|
||||
fzf \-\-preview 'cat {}' \-\-bind 'ctrl\-/:change\-preview\-window(hidden|)'
|
||||
|
||||
.SS CHANGE NTH AND WITH\-NTH
|
||||
|
||||
\fBchange\-nth\fR and \fBchange\-with\-nth\fR actions change \fB\-\-nth\fR and
|
||||
\fB\-\-with\-nth\fR options. You can specify multiple values separated by '|'
|
||||
characters. Each time the action runs, fzf applies the value after the current
|
||||
one in the list, skipping the values equal to the current one.
|
||||
|
||||
\fBtransform\-nth\fR and \fBtransform\-with\-nth\fR actions (and their
|
||||
\fBbg\-\fR variants) take the output of the command as a single value. The
|
||||
following rules apply to all of these actions.
|
||||
|
||||
* An empty value restores the default given by the option
|
||||
* \fB..\fR selects all fields
|
||||
* A non\-empty invalid value is ignored
|
||||
|
||||
e.g.
|
||||
# All fields, then fields 1, 2, and 3
|
||||
fzf \-\-bind 'ctrl\-/:change\-nth(..|1|2|3)'
|
||||
|
||||
# An empty string in the list is interpreted as the default
|
||||
fzf \-\-nth 1 \-\-bind 'ctrl\-/:change\-nth(2|)'
|
||||
|
||||
.SH AUTHOR
|
||||
Junegunn Choi (\fIjunegunn.c@gmail.com\fR)
|
||||
|
||||
|
||||
+12
-87
@@ -140,15 +140,6 @@ function! s:popup_support()
|
||||
endfunction
|
||||
|
||||
function! s:default_layout()
|
||||
" A floating pane leaves the window fzf was started from visible and
|
||||
" reachable while fzf is open. A popup covers it, inside Vim or not.
|
||||
" Without a job, s:execute_tmux() blocks and freezes Vim. fzf also wants a
|
||||
" tmux window of at least 3x3, and Vim's pane is never larger than the
|
||||
" window, so asking Vim is free and survives a resize
|
||||
if (has('nvim') || has('job')) && &columns >= 3 && &lines >= 3
|
||||
\ && s:tmux_enabled() && get(s:, 'tmux_floating', 0)
|
||||
return { 'tmux': '90%,60%' }
|
||||
endif
|
||||
return s:popup_support()
|
||||
\ ? { 'window' : { 'width': 0.9, 'height': 0.6 } }
|
||||
\ : { 'down': '~40%' }
|
||||
@@ -174,12 +165,6 @@ 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 = {}
|
||||
@@ -272,7 +257,7 @@ function! fzf#exec(...)
|
||||
endfunction
|
||||
|
||||
" Path to the fzf-tmux script, or an empty string if it is not available. Only
|
||||
" the legacy options still need it. --popup is handled by fzf itself.
|
||||
" the legacy options still need it. --tmux is handled by fzf itself.
|
||||
function! s:fzf_tmux_script()
|
||||
if !executable(s:fzf_tmux)
|
||||
if !executable('fzf-tmux')
|
||||
@@ -288,7 +273,14 @@ function! s:tmux_enabled()
|
||||
return 0
|
||||
endif
|
||||
|
||||
if empty($TMUX) && empty($ZELLIJ)
|
||||
" --tmux covers Zellij as well, where the fzf-tmux script and the tmux
|
||||
" version are irrelevant, but the binary only learned it in 0.71.0
|
||||
if exists('$ZELLIJ')
|
||||
return exists('s:exec')
|
||||
\ && s:compare_versions(s:get_version(s:exec), '0.71.0') >= 0
|
||||
endif
|
||||
|
||||
if !exists('$TMUX')
|
||||
return 0
|
||||
endif
|
||||
|
||||
@@ -296,21 +288,7 @@ function! s:tmux_enabled()
|
||||
return s:tmux
|
||||
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 s:tmux = 0
|
||||
let output = system('tmux -V')
|
||||
if v:shell_error
|
||||
return s:tmux
|
||||
@@ -318,15 +296,9 @@ function! s:tmux_enabled()
|
||||
" 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
|
||||
" --tmux requires tmux 3.3 or above, and needs no fzf-tmux script
|
||||
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
|
||||
|
||||
@@ -479,10 +451,7 @@ function! fzf#wrap(...)
|
||||
if !exists('g:fzf_layout') && exists('g:fzf_height')
|
||||
let opts.down = g:fzf_height
|
||||
else
|
||||
" 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()))
|
||||
let opts = extend(opts, s:validate_layout(get(g:, 'fzf_layout', s:default_layout())))
|
||||
endif
|
||||
endif
|
||||
|
||||
@@ -658,44 +627,6 @@ function! s:present(dict, ...)
|
||||
return 0
|
||||
endfunction
|
||||
|
||||
" The binary fzf#exec() would choose, without its prompting or installing.
|
||||
" Layout selection runs before fzf#exec() has resolved one
|
||||
function! s:fzf_binary()
|
||||
if exists('s:exec')
|
||||
return s:exec
|
||||
endif
|
||||
let bins = filter(['fzf', s:fzf_go], 'executable(v:val)')
|
||||
if empty(bins)
|
||||
return ''
|
||||
endif
|
||||
return len(bins) > 1 ? sort(bins, 's:compare_binary_versions')[-1] : bins[0]
|
||||
endfunction
|
||||
|
||||
function! s:zellij_version()
|
||||
if !exists('s:zellij_ver')
|
||||
let output = systemlist('zellij --version')
|
||||
let s:zellij_ver = v:shell_error || empty(output)
|
||||
\ ? '' : matchstr(output[0], '[0-9.]\+')
|
||||
endif
|
||||
return s:zellij_ver
|
||||
endfunction
|
||||
|
||||
" Whether the running server can put fzf in a floating pane. fzf decides on
|
||||
" the server, not on the version the tmux client reports, so ask it the same
|
||||
" question rather than predicting the answer. See tmuxFloatingPaneInfo in
|
||||
" src/tmux.go, which also requires the tmux window to be at least 3x3; that
|
||||
" one changes with a resize, so s:default_layout() reads it from Vim instead
|
||||
" of caching it here
|
||||
function! s:tmux_floating_pane_support()
|
||||
" fzf does not use a floating pane when it was not started from a pane
|
||||
if empty($TMUX_PANE)
|
||||
return 0
|
||||
endif
|
||||
let out = system('tmux list-commands new-pane')
|
||||
" A server that does not know the command exits normally with no output
|
||||
return !v:shell_error && out =~# 'new-pane'
|
||||
endfunction
|
||||
|
||||
function! s:fzf_tmux(dict)
|
||||
let size = get(a:dict, 'tmux', '')
|
||||
if empty(size)
|
||||
@@ -1104,12 +1035,6 @@ function! s:execute_term(dict, command, temps) abort
|
||||
let fzf.temps.batchfile = s:fzf_tempname().'.bat'
|
||||
call s:writefile(s:wrap_cmds(a:command), fzf.temps.batchfile)
|
||||
let command = fzf.temps.batchfile
|
||||
elseif has('nvim') && &title && a:command =~ '[[:cntrl:]]'
|
||||
" Neovim puts the command in the buffer name, which can end up raw in
|
||||
" the terminal title (#4941)
|
||||
let fzf.temps.shellscript = s:fzf_tempname()
|
||||
call s:writefile([a:command], fzf.temps.shellscript)
|
||||
let command = 'sh '.fzf#shellescape(fzf.temps.shellscript)
|
||||
else
|
||||
let command = a:command
|
||||
endif
|
||||
|
||||
@@ -124,8 +124,10 @@ function fzf_complete -w fzf -d 'fzf command completion and wildcard expansion s
|
||||
|
||||
# Determine the tabstop length for description alignment
|
||||
set -l -- max_columns (math $COLUMNS - 40)
|
||||
for len in (string match -r -- '^[^\\t]*(?=\\t)' $list[1..500] | string length -V)
|
||||
test "$len" -gt "$tabstop" -a "$len" -lt "$max_columns"
|
||||
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"
|
||||
and set -- tabstop $len
|
||||
end
|
||||
set -- tabstop (math $tabstop + 4)
|
||||
|
||||
+21
-54
@@ -8,15 +8,6 @@
|
||||
# An implementation of completion.nu
|
||||
# This loads FZF as a Nushell External Completer
|
||||
# https://www.nushell.sh/cookbook/external_completers.html
|
||||
#
|
||||
# Requires Nushell 0.116.0 or newer.
|
||||
#
|
||||
# On 0.116+, the registered completer closure must reach a command carrying the
|
||||
# `@interactive` attribute: interactivity is seen through the closure to that
|
||||
# command (a closure cannot carry the attribute itself), and that is what makes
|
||||
# the completion run on the REPL thread with the terminal, so fzf can take it.
|
||||
# A closure that does not reach such a command runs on a background worker
|
||||
# without the terminal, where fzf cannot run.
|
||||
|
||||
|
||||
# --- Default Environment Variables ---
|
||||
@@ -376,9 +367,9 @@ def _fzf_complete_kill [query: string] {
|
||||
|
||||
# --- Main FZF External Completer ---
|
||||
|
||||
# Main completion logic. It receives the token list of the command being
|
||||
# completed (the legacy `spans`). Called directly, so no attribute is needed.
|
||||
def __fzf_completer_logic [spans] {
|
||||
# This function is registered with Nushell's external completion system.
|
||||
# It gets called when Tab is pressed.
|
||||
let fzf_external_completer = {|spans|
|
||||
let trigger: string = $env.FZF_COMPLETION_TRIGGER? | default '**'
|
||||
|
||||
if ($trigger | is-empty) { return null } # Cannot work with empty trigger
|
||||
@@ -455,40 +446,6 @@ def __fzf_completer_logic [spans] {
|
||||
}
|
||||
}
|
||||
|
||||
# Nushell 0.116+ entry point. The registered closure must END with a call to
|
||||
# this command for the completion to run on the REPL thread with the terminal.
|
||||
#
|
||||
# It declares `place` (a named input) rather than the legacy `spans`, so the
|
||||
# completer never goes through the deprecated positional bridge. `$place.command`
|
||||
# is the token list of the command being completed, the same value the old
|
||||
# `spans` held (with a trailing "" when the cursor sits on an empty slot).
|
||||
@interactive
|
||||
def __fzf_external_completer [place] {
|
||||
let result = (__fzf_completer_logic $place.command)
|
||||
if $result != null {
|
||||
$result
|
||||
} else {
|
||||
# Fall back to the previously configured external completer, if any. It is
|
||||
# handed the new `place` input first. A completer that still declares the
|
||||
# legacy `spans` parameter errors on a record, so it is retried with the
|
||||
# token list, which is what the old positional bridge handed it.
|
||||
let previous = ($env.__fzf_previous_completer? | default null)
|
||||
let previous_result = if $previous != null {
|
||||
try {
|
||||
do $previous $place
|
||||
} catch {
|
||||
try { do $previous $place.command } catch { null }
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
# An @interactive completer cannot decline with null: null reads as a
|
||||
# dismissed picker with an empty, final answer. Request the fallback
|
||||
# explicitly so file completion still runs when the trigger is absent.
|
||||
if $previous_result != null { $previous_result } else { {completions: [], fallback: true} }
|
||||
}
|
||||
}
|
||||
|
||||
# --- WRAPPER AND REGISTRATION ---
|
||||
|
||||
# Guard against re-sourcing: wrapping the completer multiple times would
|
||||
@@ -498,15 +455,25 @@ if ($env.__fzf_completer_registered? | default false) != true {
|
||||
# Get the currently configured external completer, if any exists
|
||||
let previous_external_completer = $env.config? | get completions? | get external? | get completer?
|
||||
|
||||
# Stash it for `__fzf_external_completer`: a def cannot capture a local
|
||||
# variable, and the fallback to the previous completer happens inside it.
|
||||
$env.__fzf_previous_completer = $previous_external_completer
|
||||
# Define the new wrapper completer
|
||||
let fzf_wrapper_completer = {|spans|
|
||||
# 1. Try the FZF completer logic first
|
||||
let fzf_result = do $fzf_external_completer $spans
|
||||
|
||||
# The registered closure must END with a call to an `@interactive` command so
|
||||
# that Nushell 0.116+ runs the completion on the REPL thread with the terminal
|
||||
# (it inspects the last pipeline element of the closure). Declaring `place`
|
||||
# instead of `spans` keeps it off the deprecated positional bridge.
|
||||
let fzf_wrapper_completer = {|place| __fzf_external_completer $place }
|
||||
# 2. If FZF returned a result (a list, even an empty one), return it.
|
||||
# `null` means FZF didn't handle it because the trigger wasn't present.
|
||||
if $fzf_result != null {
|
||||
$fzf_result
|
||||
} else {
|
||||
# 3. FZF didn't handle it, so call the previous completer (if it exists).
|
||||
if $previous_external_completer != null {
|
||||
do $previous_external_completer $spans
|
||||
} else {
|
||||
# 4. No previous completer, and FZF didn't handle it. Return null.
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Register the new wrapper completer
|
||||
# This ensures external completions are enabled and sets our wrapper.
|
||||
|
||||
@@ -215,7 +215,7 @@ _fzf_dir_completion() {
|
||||
|
||||
_fzf_feed_fifo() {
|
||||
command rm -f "$1"
|
||||
command mkfifo "$1" || return
|
||||
mkfifo "$1"
|
||||
cat <&0 > "$1" &|
|
||||
}
|
||||
|
||||
@@ -242,9 +242,8 @@ _fzf_complete() {
|
||||
rest=("$@")
|
||||
fi
|
||||
|
||||
local fifo fifo_dir lbuf matches post
|
||||
fifo_dir=$(command mktemp -d "${TMPDIR:-/tmp}/fzf-completion-XXXXXX") || return
|
||||
fifo="$fifo_dir/fifo"
|
||||
local fifo lbuf matches post
|
||||
fifo="${TMPDIR:-/tmp}/fzf-complete-fifo-$$"
|
||||
lbuf=${rest[0]}
|
||||
post="${funcstack[1]}_post"
|
||||
type $post > /dev/null 2>&1 || post=cat
|
||||
@@ -257,7 +256,7 @@ _fzf_complete() {
|
||||
if [ -n "$matches" ]; then
|
||||
LBUFFER="$lbuf$matches"
|
||||
fi
|
||||
command rm -rf "$fifo_dir"
|
||||
command rm -f "$fifo"
|
||||
}
|
||||
|
||||
# To use custom hostname lists, override __fzf_list_hosts.
|
||||
|
||||
+3
-10
@@ -104,7 +104,7 @@ function fzf_key_bindings
|
||||
# Store current token in $dir as root for the 'find' command
|
||||
function fzf-file-widget -d "List files and folders"
|
||||
set -l commandline (__fzf_parse_commandline)
|
||||
set -l dir $commandline[1]
|
||||
set -lx dir $commandline[1]
|
||||
set -l fzf_query $commandline[2]
|
||||
set -l prefix $commandline[3]
|
||||
|
||||
@@ -112,17 +112,10 @@ function fzf_key_bindings
|
||||
"--reverse --walker=file,dir,follow,hidden --scheme=path" \
|
||||
"--multi $FZF_CTRL_T_OPTS --print0")
|
||||
|
||||
set -lx FZF_DEFAULT_COMMAND "$FZF_CTRL_T_COMMAND"
|
||||
set -lx FZF_DEFAULT_OPTS_FILE
|
||||
|
||||
set -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"
|
||||
set -l result (eval (__fzfcmd) --walker-root=$dir --query=$fzf_query | string split0)
|
||||
and commandline -rt -- (string join -- ' ' $prefix(string escape -n -- $result))' '
|
||||
|
||||
commandline -f repaint
|
||||
|
||||
+10
-34
@@ -47,20 +47,6 @@ def __fzfcmd []: nothing -> list<string> {
|
||||
['fzf']
|
||||
}
|
||||
|
||||
# Keybinding modes to activate. The Helix modes only exist since Nushell
|
||||
# 0.115.0, so they are only included when running a version that supports
|
||||
# them (major > 0 covers a hypothetical 1.0+ where the minor resets).
|
||||
def __fzf_modes []: nothing -> list<string> {
|
||||
let v = version
|
||||
let major = ($v.major | into int)
|
||||
let minor = ($v.minor | into int)
|
||||
if ($major > 0) or ($minor >= 115) {
|
||||
['emacs', 'vi_normal', 'vi_insert', 'helix_normal', 'helix_select', 'helix_insert']
|
||||
} else {
|
||||
['emacs', 'vi_normal', 'vi_insert']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export-env {
|
||||
$env.FZF_CTRL_T_OPTS = $env.FZF_CTRL_T_OPTS? | default ""
|
||||
@@ -69,11 +55,11 @@ export-env {
|
||||
}
|
||||
|
||||
# Directories
|
||||
let alt_c = {
|
||||
const alt_c = {
|
||||
name: fzf_dirs
|
||||
modifier: alt
|
||||
keycode: char_c
|
||||
mode: (__fzf_modes)
|
||||
mode: [emacs, vi_normal, vi_insert]
|
||||
event: [
|
||||
{
|
||||
send: executehostcommand
|
||||
@@ -96,11 +82,11 @@ let alt_c = {
|
||||
}
|
||||
|
||||
# History
|
||||
let ctrl_r = {
|
||||
const ctrl_r = {
|
||||
name: fzf_history
|
||||
modifier: control
|
||||
keycode: char_r
|
||||
mode: (__fzf_modes)
|
||||
mode: [emacs, vi_insert, vi_normal]
|
||||
event: [
|
||||
{
|
||||
send: executehostcommand
|
||||
@@ -129,16 +115,16 @@ let ctrl_r = {
|
||||
}
|
||||
|
||||
# Files
|
||||
let ctrl_t = {
|
||||
const ctrl_t = {
|
||||
name: fzf_files
|
||||
modifier: control
|
||||
keycode: char_t
|
||||
mode: (__fzf_modes)
|
||||
mode: [emacs, vi_normal, vi_insert]
|
||||
event: [
|
||||
{
|
||||
send: executehostcommand
|
||||
cmd: "
|
||||
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m --print0');
|
||||
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m');
|
||||
let fzfcmd = (__fzfcmd);
|
||||
let fzf_args = ($fzfcmd | skip 1);
|
||||
let ctrl_t_cmd = ($env.FZF_CTRL_T_COMMAND? | default null);
|
||||
@@ -149,19 +135,9 @@ let ctrl_t = {
|
||||
let sh_cmd = [$ctrl_t_cmd '|' $fzf_cmd_str] | str join ' ';
|
||||
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
|
||||
};
|
||||
# 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
|
||||
}
|
||||
let result = ($result | str replace --all (char newline) ' ' | str trim);
|
||||
commandline edit --append $result;
|
||||
commandline set-cursor --end
|
||||
"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -264,6 +264,7 @@ func Run(opts *Options) (int, error) {
|
||||
}
|
||||
|
||||
pattern := patternBuilder([]rune(*opts.Filter))
|
||||
matcher.sort = pattern.sortable
|
||||
|
||||
transformer := buildItemTransformer(opts)
|
||||
|
||||
|
||||
+6
-7
@@ -1678,15 +1678,15 @@ func init() {
|
||||
}
|
||||
|
||||
func maskActionContents(action string) string {
|
||||
var sb strings.Builder
|
||||
masked := ""
|
||||
Loop:
|
||||
for len(action) > 0 {
|
||||
loc := argActionRegexp.FindStringIndex(action)
|
||||
if loc == nil {
|
||||
sb.WriteString(action)
|
||||
masked += action
|
||||
break
|
||||
}
|
||||
sb.WriteString(action[:loc[1]])
|
||||
masked += action[:loc[1]]
|
||||
action = action[loc[1]:]
|
||||
if len(action) == 0 {
|
||||
break
|
||||
@@ -1695,7 +1695,7 @@ Loop:
|
||||
var ce string
|
||||
switch action[0] {
|
||||
case ':':
|
||||
sb.WriteString(strings.Repeat(" ", len(action)))
|
||||
masked += strings.Repeat(" ", len(action))
|
||||
break Loop
|
||||
case '(':
|
||||
ce = ")"
|
||||
@@ -1716,7 +1716,7 @@ Loop:
|
||||
// @$ or @+
|
||||
loc = regexp.MustCompile(fmt.Sprintf(`(?s)^%s.*?(%s[+,]|%s$)`, cs, ce, ce)).FindStringIndex(action)
|
||||
if loc == nil {
|
||||
sb.WriteString(action)
|
||||
masked += action
|
||||
break
|
||||
}
|
||||
// Keep + or , at the end
|
||||
@@ -1724,10 +1724,9 @@ Loop:
|
||||
if lastChar == '+' || lastChar == ',' {
|
||||
loc[1]--
|
||||
}
|
||||
sb.WriteString(strings.Repeat(" ", loc[1]))
|
||||
masked += strings.Repeat(" ", loc[1])
|
||||
action = action[loc[1]:]
|
||||
}
|
||||
masked := sb.String()
|
||||
masked = strings.ReplaceAll(masked, ",,,", string([]rune{',', escapedComma, ','}))
|
||||
masked = strings.ReplaceAll(masked, ",:,", string([]rune{',', escapedColon, ','}))
|
||||
masked = strings.ReplaceAll(masked, "::", string([]rune{escapedColon, ':'}))
|
||||
|
||||
+22
-20
@@ -275,12 +275,22 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
||||
Sort: fastwalk.SortFilesFirst,
|
||||
}
|
||||
|
||||
// When following symlinks, skip targets that point to an ancestor of the
|
||||
// current walker root. fastwalk's built-in loop detection (shouldTraverse)
|
||||
// catches loops on the second pass, but a single pass through a symlink
|
||||
// like z: -> / already traverses the entire root filesystem, causing
|
||||
// severe resource exhaustion. Skipping ancestor symlinks prevents this.
|
||||
var absRoot string
|
||||
// When following symlinks, precompute the absolute real paths of walker
|
||||
// roots so we can skip symlinks that point to an ancestor. fastwalk's
|
||||
// built-in loop detection (shouldTraverse) catches loops on the second
|
||||
// pass, but a single pass through a symlink like z: -> / already
|
||||
// traverses the entire root filesystem, causing severe resource
|
||||
// exhaustion. Skipping ancestor symlinks prevents this entirely.
|
||||
var absRoots []string
|
||||
if opts.follow {
|
||||
for _, root := range roots {
|
||||
if real, err := filepath.EvalSymlinks(root); err == nil {
|
||||
if abs, err := filepath.Abs(real); err == nil {
|
||||
absRoots = append(absRoots, filepath.Clean(abs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ignoresBase := []string{}
|
||||
ignoresFull := []string{}
|
||||
@@ -316,17 +326,19 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// Skip symlinks whose target is an ancestor of (or equal to)
|
||||
// the current walker root. Following such symlinks would traverse a
|
||||
// any walker root. Following such symlinks would traverse a
|
||||
// superset of the tree we're already walking.
|
||||
if isDirSymlink && absRoot != "" {
|
||||
if isDirSymlink && len(absRoots) > 0 {
|
||||
if target, err := filepath.EvalSymlinks(path); err == nil {
|
||||
if abs, err := filepath.Abs(target); err == nil {
|
||||
abs = filepath.Clean(abs)
|
||||
if abs == string(os.PathSeparator) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if absRoot == abs || strings.HasPrefix(absRoot, abs+string(os.PathSeparator)) {
|
||||
return filepath.SkipDir
|
||||
for _, absRoot := range absRoots {
|
||||
if absRoot == abs || strings.HasPrefix(absRoot, abs+string(os.PathSeparator)) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,16 +377,6 @@ func (r *Reader) readFiles(roots []string, opts walkerOpts, ignores []string) bo
|
||||
}
|
||||
noerr := true
|
||||
for _, root := range roots {
|
||||
// Compare against this root only. Safe to update here because
|
||||
// fastwalk.Walk returns after all its workers finish.
|
||||
absRoot = ""
|
||||
if opts.follow {
|
||||
if real, err := filepath.EvalSymlinks(root); err == nil {
|
||||
if abs, err := filepath.Abs(real); err == nil {
|
||||
absRoot = filepath.Clean(abs)
|
||||
}
|
||||
}
|
||||
}
|
||||
noerr = noerr && (fastwalk.Walk(&conf, root, fn) == nil)
|
||||
}
|
||||
return noerr
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package fzf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -70,70 +66,3 @@ func TestReadFromCommand(t *testing.T) {
|
||||
t.Error("EvtReadFin should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func walkFiles(t *testing.T, roots ...string) []string {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
files := []string{}
|
||||
reader := NewReader(func(data []byte) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
files = append(files, filepath.FromSlash(string(data)))
|
||||
return true
|
||||
}, util.NewEventBox(), nil, false, false)
|
||||
if !reader.readFiles(roots, walkerOpts{file: true, follow: true}, nil) {
|
||||
t.Fatal("file walk failed")
|
||||
}
|
||||
slices.Sort(files)
|
||||
return files
|
||||
}
|
||||
|
||||
func TestReadFilesSymlinkAcrossRoots(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
app := filepath.Join(base, "app")
|
||||
shared := filepath.Join(base, "shared")
|
||||
frontend := filepath.Join(shared, "frontend")
|
||||
os.MkdirAll(app, 0755)
|
||||
os.MkdirAll(frontend, 0755)
|
||||
os.WriteFile(filepath.Join(shared, "docs.txt"), nil, 0644)
|
||||
os.WriteFile(filepath.Join(frontend, "index.js"), nil, 0644)
|
||||
if err := os.Symlink(filepath.Join("..", "shared"), filepath.Join(app, "shared")); err != nil {
|
||||
t.Skipf("cannot create symlink: %v", err)
|
||||
}
|
||||
|
||||
viaApp := []string{
|
||||
filepath.Join(app, "shared", "docs.txt"),
|
||||
filepath.Join(app, "shared", "frontend", "index.js"),
|
||||
}
|
||||
both := append(slices.Clone(viaApp), filepath.Join(frontend, "index.js"))
|
||||
slices.Sort(both)
|
||||
for _, tc := range []struct {
|
||||
roots []string
|
||||
want []string
|
||||
}{
|
||||
{[]string{app}, viaApp},
|
||||
{[]string{app, frontend}, both},
|
||||
{[]string{frontend, app}, both},
|
||||
} {
|
||||
if files := walkFiles(t, tc.roots...); !slices.Equal(files, tc.want) {
|
||||
t.Errorf("roots = %v, files = %v, want %v", tc.roots, files, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFilesSkipsAncestorSymlinks(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
project := filepath.Join(base, "project")
|
||||
os.Mkdir(project, 0755)
|
||||
file := filepath.Join(project, "file.txt")
|
||||
os.WriteFile(file, nil, 0644)
|
||||
os.WriteFile(filepath.Join(base, "outside.txt"), nil, 0644)
|
||||
for name, target := range map[string]string{"parent": "..", "self": "."} {
|
||||
if err := os.Symlink(target, filepath.Join(project, name)); err != nil {
|
||||
t.Skipf("cannot create symlink: %v", err)
|
||||
}
|
||||
}
|
||||
if files := walkFiles(t, project); !slices.Equal(files, []string{file}) {
|
||||
t.Errorf("files = %v, want [%s]", files, file)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
for i := numChars - 1; i >= 0; i-- {
|
||||
if r := item.text.Get(i); r == '/' || r == '\\' {
|
||||
s := item.text.ToString()
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == '/' || s[i] == '\\' {
|
||||
lastDelim = i
|
||||
break
|
||||
}
|
||||
|
||||
@@ -272,28 +272,3 @@ 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)
|
||||
}
|
||||
+32
-126
@@ -467,7 +467,7 @@ type Terminal struct {
|
||||
termSize tui.TermSize
|
||||
lastAction actionType
|
||||
lastKey string
|
||||
lastFocus focusedItem
|
||||
lastFocus int32
|
||||
areaLines int
|
||||
areaColumns int
|
||||
forcePreview bool
|
||||
@@ -492,11 +492,6 @@ type selectedItem struct {
|
||||
item *Item
|
||||
}
|
||||
|
||||
type focusedItem struct {
|
||||
major int // revision.major; changes on reload
|
||||
item *Item
|
||||
}
|
||||
|
||||
type byTimeOrder []selectedItem
|
||||
|
||||
func (a byTimeOrder) Len() int {
|
||||
@@ -1191,7 +1186,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
|
||||
initFunc: func() error { return renderer.Init() },
|
||||
executing: util.NewAtomicBool(false),
|
||||
lastAction: actStart,
|
||||
lastFocus: focusedItem{},
|
||||
lastFocus: minItem.Index(),
|
||||
lastActivity: time.Now(),
|
||||
numLinesCache: make(map[int32]numLinesCacheValue),
|
||||
// The initial load counts as a search in progress ('start:wait').
|
||||
@@ -1552,14 +1547,6 @@ func (t *Terminal) visibleInputLinesInList() int {
|
||||
|
||||
// Extra number of lines needed to display fzf
|
||||
func (t *Terminal) extraLines() int {
|
||||
// borderLines() reports zero for BorderInline, but addInline() still
|
||||
// reserves a divider line for it
|
||||
sectionLines := func(shape tui.BorderShape) int {
|
||||
if shape == tui.BorderInline {
|
||||
return 1
|
||||
}
|
||||
return borderLines(shape)
|
||||
}
|
||||
extra := 0
|
||||
if !t.inputless {
|
||||
extra++
|
||||
@@ -1575,16 +1562,16 @@ func (t *Terminal) extraLines() int {
|
||||
}
|
||||
if t.headerVisible {
|
||||
if t.hasHeaderWindow() {
|
||||
extra += sectionLines(t.headerBorderShape)
|
||||
extra += borderLines(t.headerBorderShape)
|
||||
}
|
||||
extra += len(t.header0)
|
||||
if w, shape := t.determineHeaderLinesShape(); w {
|
||||
extra += sectionLines(shape)
|
||||
extra += borderLines(shape)
|
||||
}
|
||||
extra += t.headerLines
|
||||
}
|
||||
if len(t.footer) > 0 {
|
||||
extra += sectionLines(t.footerBorderShape)
|
||||
extra += borderLines(t.footerBorderShape)
|
||||
extra += len(t.footer)
|
||||
}
|
||||
return extra
|
||||
@@ -2064,9 +2051,7 @@ func (t *Terminal) UpdateList(result MatchResult) {
|
||||
prevIndex = t.targetIndex
|
||||
t.targetIndex = minItem.Index()
|
||||
}
|
||||
if result.final() {
|
||||
t.progress = 100
|
||||
}
|
||||
t.progress = 100
|
||||
t.merger = merger
|
||||
t.resultMerger = merger
|
||||
t.passMerger = result.passMerger
|
||||
@@ -6071,26 +6056,6 @@ func (t *Terminal) currentIndex() int32 {
|
||||
return minItem.Index()
|
||||
}
|
||||
|
||||
func (t *Terminal) currentFocus() focusedItem {
|
||||
return focusedItem{t.revision.major, t.currentItem()}
|
||||
}
|
||||
|
||||
// Records the current focus and returns whether it differs from the last one.
|
||||
// Indexes restart after a reload, so items are compared by --id-nth key, if
|
||||
// set. Without it, any focused item after a reload is a new one.
|
||||
func (t *Terminal) updateFocus() bool {
|
||||
curr := t.currentFocus()
|
||||
last := t.lastFocus
|
||||
t.lastFocus = curr
|
||||
if curr.item == nil || last.item == nil {
|
||||
return curr.item != last.item
|
||||
}
|
||||
if curr.major == last.major {
|
||||
return curr.item.Index() != last.item.Index()
|
||||
}
|
||||
return len(t.idNth) == 0 || t.trackKeyFor(curr.item, t.idNth) != t.trackKeyFor(last.item, t.idNth)
|
||||
}
|
||||
|
||||
func (t *Terminal) trackKeyFor(item *Item, nth []Range) string {
|
||||
tokens := Tokenize(item.AsString(t.ansi), t.delimiter)
|
||||
return StripLastDelimiter(JoinTokens(Transform(tokens, nth)), t.delimiter)
|
||||
@@ -6304,43 +6269,6 @@ func (t *Terminal) startTimers(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// nextValue returns the first valid, non-current value after the current one
|
||||
// in a '|'-separated list, and the list rotated to end with it.
|
||||
// isValid can be nil.
|
||||
func nextValue(expr string, isCurrent func(string) bool, isValid func(string) bool) (string, string) {
|
||||
valid := func(value string) bool {
|
||||
return isValid == nil || isValid(value)
|
||||
}
|
||||
// An invalid value is never current, even if it resolves to the current one
|
||||
current := func(value string) bool {
|
||||
return valid(value) && isCurrent(value)
|
||||
}
|
||||
values := strings.Split(expr, "|")
|
||||
start := 0
|
||||
// Last value is the one applied by the previous call
|
||||
if !current(values[len(values)-1]) {
|
||||
for i, value := range values {
|
||||
if current(value) {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range values {
|
||||
pick := (start + i) % len(values)
|
||||
if value := values[pick]; valid(value) && !current(value) {
|
||||
return value, strings.Join(slices.Concat(values[pick+1:], values[:pick+1]), "|")
|
||||
}
|
||||
}
|
||||
// Nothing to pick
|
||||
for _, value := range values {
|
||||
if valid(value) {
|
||||
return value, expr
|
||||
}
|
||||
}
|
||||
return values[0], expr
|
||||
}
|
||||
|
||||
// Loop is called to start Terminal I/O
|
||||
func (t *Terminal) Loop() error {
|
||||
// prof := profile.Start(profile.ProfilePath("/tmp/"))
|
||||
@@ -6693,7 +6621,8 @@ func (t *Terminal) Loop() error {
|
||||
info = true
|
||||
}
|
||||
focusChanged := focusedIndex != currentIndex
|
||||
if (t.hasFocusActions || t.infoCommand != "") && !t.trackBlocked && t.updateFocus() {
|
||||
if (t.hasFocusActions || t.infoCommand != "") && focusChanged && currentIndex != t.lastFocus {
|
||||
t.lastFocus = currentIndex
|
||||
t.eventChan <- tui.Focus.AsEvent()
|
||||
if t.infoCommand != "" {
|
||||
info = true
|
||||
@@ -7078,11 +7007,9 @@ func (t *Terminal) Loop() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Actions are ignored while blocked. The render loop will
|
||||
// report the focus after the reload.
|
||||
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents && !t.trackBlocked {
|
||||
if t.currentIndex() != currentIndex {
|
||||
t.lastFocus = t.currentFocus()
|
||||
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents {
|
||||
if newIndex := t.currentIndex(); newIndex != currentIndex {
|
||||
t.lastFocus = newIndex
|
||||
if t.infoCommand != "" {
|
||||
req(reqInfo)
|
||||
}
|
||||
@@ -7341,35 +7268,22 @@ func (t *Terminal) Loop() error {
|
||||
req(reqList, reqInfo)
|
||||
case actChangeNth, actTransformNth, actBgTransformNth:
|
||||
capture(true, func(expr string) {
|
||||
resolve := func(value string) ([]Range, bool) {
|
||||
// Empty value restores the default --nth
|
||||
if len(value) == 0 {
|
||||
return t.nth, true
|
||||
}
|
||||
nth, err := splitNth(value)
|
||||
return nth, err == nil
|
||||
}
|
||||
// Empty range list or a single '..' means all fields
|
||||
allFields := func(nth []Range) bool {
|
||||
return len(nth) == 0 || len(nth) == 1 && nth[0].IsFull()
|
||||
}
|
||||
isCurrent := func(nth []Range) bool {
|
||||
return compareRanges(t.nthCurrent, nth) || allFields(t.nthCurrent) && allFields(nth)
|
||||
}
|
||||
// Only change-nth cycles through '|'-separated values
|
||||
if a.t == actChangeNth {
|
||||
expr, a.a = nextValue(expr, func(value string) bool {
|
||||
nth, _ := resolve(value)
|
||||
return isCurrent(nth)
|
||||
}, func(value string) bool {
|
||||
_, ok := resolve(value)
|
||||
return ok
|
||||
})
|
||||
}
|
||||
if nth, ok := resolve(expr); ok && !isCurrent(nth) {
|
||||
// Split nth expression
|
||||
tokens := strings.Split(expr, "|")
|
||||
if nth, err := splitNth(tokens[0]); err == nil || len(expr) == 0 {
|
||||
// Changed
|
||||
newNth = &nth
|
||||
} else {
|
||||
// The default
|
||||
newNth = &t.nth
|
||||
}
|
||||
// Cycle
|
||||
if len(tokens) > 1 {
|
||||
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
|
||||
}
|
||||
if !compareRanges(t.nthCurrent, *newNth) {
|
||||
changed = true
|
||||
t.nthCurrent = nth
|
||||
t.nthCurrent = *newNth
|
||||
t.forceRerenderList()
|
||||
}
|
||||
})
|
||||
@@ -7378,22 +7292,15 @@ func (t *Terminal) Loop() error {
|
||||
break Action
|
||||
}
|
||||
capture(true, func(expr string) {
|
||||
resolve := func(value string) string {
|
||||
// Empty value restores the default --with-nth
|
||||
if len(value) == 0 {
|
||||
return t.withNthDefault
|
||||
}
|
||||
return value
|
||||
tokens := strings.Split(expr, "|")
|
||||
withNthExpr := tokens[0]
|
||||
if len(tokens) > 1 {
|
||||
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
|
||||
}
|
||||
if a.t == actChangeWithNth {
|
||||
expr, a.a = nextValue(expr, func(value string) bool {
|
||||
return resolve(value) == t.withNthExpr
|
||||
}, func(value string) bool {
|
||||
_, err := nthTransformer(resolve(value))
|
||||
return err == nil
|
||||
})
|
||||
// Empty value restores the default --with-nth
|
||||
if len(withNthExpr) == 0 {
|
||||
withNthExpr = t.withNthDefault
|
||||
}
|
||||
withNthExpr := resolve(expr)
|
||||
if withNthExpr != t.withNthExpr {
|
||||
if factory, err := nthTransformer(withNthExpr); err == nil {
|
||||
newWithNth = &withNthSpec{fn: factory(t.delimiter)}
|
||||
@@ -8682,7 +8589,6 @@ func (t *Terminal) Loop() error {
|
||||
reload := changed || newCommand != nil
|
||||
if reload {
|
||||
t.wait.searching = true
|
||||
t.progress = 0
|
||||
}
|
||||
var reloadRequest *searchRequest
|
||||
if reload {
|
||||
|
||||
@@ -29,9 +29,6 @@ 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{
|
||||
@@ -258,9 +255,6 @@ 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() {
|
||||
@@ -927,56 +921,3 @@ func TestSplitOnIND(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
current string
|
||||
want string
|
||||
rotated string
|
||||
}{
|
||||
{"a", "x", "a", "a"},
|
||||
{"a", "a", "a", "a"},
|
||||
{"a|b|c", "x", "a", "b|c|a"},
|
||||
{"a|b|c", "a", "b", "c|a|b"},
|
||||
{"a|b|c", "b", "c", "a|b|c"},
|
||||
{"a|b|c", "c", "a", "b|c|a"},
|
||||
{"a|a|b", "a", "b", "a|a|b"},
|
||||
{"a|b|b", "b", "a", "b|b|a"},
|
||||
{"a|a", "a", "a", "a|a"},
|
||||
{"a|b|", "", "a", "b||a"},
|
||||
{"|a", "", "a", "|a"},
|
||||
{"a|bad|b", "a", "b", "a|bad|b"},
|
||||
{"bad|a", "x", "a", "bad|a"},
|
||||
{"a|bad", "a", "a", "a|bad"},
|
||||
// An invalid value is never current
|
||||
{"a|bad|b", "bad", "a", "bad|b|a"},
|
||||
// Nothing to pick: the first valid value
|
||||
{"bad|a", "a", "a", "bad|a"},
|
||||
{"bad", "x", "bad", "bad"},
|
||||
// The last value is checked first
|
||||
{"b|a|c|a", "a", "b", "a|c|a|b"},
|
||||
}
|
||||
isValid := func(v string) bool { return v != "bad" }
|
||||
for _, tc := range tests {
|
||||
got, rotated := nextValue(tc.expr, func(v string) bool { return v == tc.current }, isValid)
|
||||
if got != tc.want || rotated != tc.rotated {
|
||||
t.Errorf("nextValue(%q) with current %q: got (%q, %q), want (%q, %q)",
|
||||
tc.expr, tc.current, got, rotated, tc.want, tc.rotated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextValueRepeated(t *testing.T) {
|
||||
// Presses with a rotated list reach every position, even with a repeated value
|
||||
expr := "1|2|1|3"
|
||||
current := "1"
|
||||
got := []string{}
|
||||
for range 6 {
|
||||
current, expr = nextValue(expr, func(v string) bool { return v == current }, nil)
|
||||
got = append(got, current)
|
||||
}
|
||||
if want := "2 1 3 1 2 1"; strings.Join(got, " ") != want {
|
||||
t.Errorf("got %q, want %q", strings.Join(got, " "), want)
|
||||
}
|
||||
}
|
||||
+16
-23
@@ -5,19 +5,14 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/junegunn/fzf/src/tui"
|
||||
)
|
||||
|
||||
// The flag group of new-pane usage includes -A (tmux 3.8 or above)
|
||||
var tmuxOverZoomFlag = regexp.MustCompile(`new-pane.*\[-[[:alpha:]]*A`)
|
||||
|
||||
// Returns the size of the current window if the tmux server supports
|
||||
// floating panes (tmux 3.7 or above), and whether a floating pane can stay
|
||||
// above a zoomed pane
|
||||
func tmuxFloatingPaneInfo() (int, int, bool, bool) {
|
||||
// floating panes (tmux 3.7 or above)
|
||||
func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
// TMUX_PANE is not set when fzf is not started from within a pane
|
||||
// (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating
|
||||
// pane there; a blocking run-shell suspends key processing for the
|
||||
@@ -30,7 +25,7 @@ func tmuxFloatingPaneInfo() (int, int, bool, bool) {
|
||||
// https://github.com/tmux/tmux/issues/5384
|
||||
target := os.Getenv("TMUX_PANE")
|
||||
if target == "" {
|
||||
return 0, 0, false, false
|
||||
return 0, 0, false
|
||||
}
|
||||
// A single invocation for both checks. Cannot rely on the exit status;
|
||||
// tmux versions before 3.7 exit normally with empty output for an
|
||||
@@ -38,17 +33,17 @@ func tmuxFloatingPaneInfo() (int, int, bool, bool) {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "-t", target,
|
||||
"#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output()
|
||||
if err != nil || !strings.Contains(string(out), "new-pane") {
|
||||
return 0, 0, false, false
|
||||
return 0, 0, false
|
||||
}
|
||||
var width, height int
|
||||
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
|
||||
return 0, 0, false, false
|
||||
return 0, 0, false
|
||||
}
|
||||
// The window is too small to fit a floating pane of the minimum size
|
||||
if width < 3 || height < 3 {
|
||||
return 0, 0, false, false
|
||||
return 0, 0, false
|
||||
}
|
||||
return width, height, tmuxOverZoomFlag.Match(out), true
|
||||
return width, height, true
|
||||
}
|
||||
|
||||
// tmux ends a command at an argument ending in ';', so a trailing one is
|
||||
@@ -90,7 +85,7 @@ func tmuxDim(spec sizeSpec, window int) int {
|
||||
return max(3, min(dim, window))
|
||||
}
|
||||
|
||||
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, overZoom bool, opts *Options) (int, error) {
|
||||
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
|
||||
// Unlike display-popup, the size of a floating pane does not account for
|
||||
// the border around it, and the position is that of the content area. To
|
||||
// stay consistent with popups, treat the requested size as the total
|
||||
@@ -171,15 +166,13 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh
|
||||
}
|
||||
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
|
||||
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
|
||||
// Unzoom the window first; creating a floating pane over a zoomed
|
||||
// window crashes the tmux server on 3.7b, and newer versions of
|
||||
// tmux unzoom the window anyway.
|
||||
target := os.Getenv("TMUX_PANE")
|
||||
newPane := "tmux new-pane -A"
|
||||
if !overZoom {
|
||||
// Unzoom the window first; creating a floating pane over a
|
||||
// zoomed window crashes the tmux server on 3.7b
|
||||
newPane = fmt.Sprintf("tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane",
|
||||
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target))
|
||||
}
|
||||
newPane += fmt.Sprintf(" -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
|
||||
newPane := fmt.Sprintf(
|
||||
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
|
||||
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
|
||||
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
|
||||
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
|
||||
|
||||
@@ -229,10 +222,10 @@ func runTmux(args []string, opts *Options) (int, error) {
|
||||
// popup is used instead so that the fzf-drawn border is the only border
|
||||
// shown; the native border of a tmux floating pane cannot be removed.
|
||||
if nativeBorder(opts) {
|
||||
if windowWidth, windowHeight, overZoom, ok := tmuxFloatingPaneInfo(); ok {
|
||||
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok {
|
||||
opts.Tmux.border = true
|
||||
argStr, dir := popupArgStr(args, opts)
|
||||
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, overZoom, opts)
|
||||
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-249
@@ -26,7 +26,7 @@ const (
|
||||
offsetPollTries = 10
|
||||
queryTimeout = 500 * time.Millisecond
|
||||
maxInputBuffer = 1024 * 1024
|
||||
escapeLookback = 256
|
||||
maxSelectTries = 100
|
||||
)
|
||||
|
||||
const DefaultTtyDevice string = "/dev/tty"
|
||||
@@ -175,7 +175,6 @@ type LightRenderer struct {
|
||||
width int
|
||||
height int
|
||||
yoffset int
|
||||
xoffset int
|
||||
tabstop int
|
||||
escDelay int
|
||||
fullscreen bool
|
||||
@@ -278,7 +277,6 @@ func (r *LightRenderer) Init() error {
|
||||
// increased and we're left with unwanted extra new line.
|
||||
if x > 0 && r.clearOnExit {
|
||||
r.upOneLine = true
|
||||
r.xoffset = x
|
||||
r.makeSpace()
|
||||
}
|
||||
// We assume that --no-clear is used for repetitive relaunching of fzf.
|
||||
@@ -340,150 +338,6 @@ func getEnv(name string, defaultValue int) int {
|
||||
return atoi(env, defaultValue)
|
||||
}
|
||||
|
||||
// Bytes of a CSI sequence: parameter and intermediate bytes continue it, a
|
||||
// final byte ends it. Order is not enforced. Strictness would only make fzf
|
||||
// give up on a sequence it could have framed.
|
||||
//
|
||||
// https://vt100.net/emu/dec_ansi_parser
|
||||
func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f }
|
||||
func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
|
||||
|
||||
// csiEnd returns the length of the CSI or SS3 sequence at the start of the
|
||||
// buffer, 0 if it has no final byte yet, or -1 if it is malformed.
|
||||
func csiEnd(buffer []byte) int {
|
||||
digits := true
|
||||
for i := 2; i < len(buffer); i++ {
|
||||
b := buffer[i]
|
||||
// rxvt ends keys with $ after at least one digit, as in \e[7$ for
|
||||
// SHIFT-HOME and \e[23$ for SHIFT-F11. Elsewhere $ is an intermediate byte.
|
||||
if csiFinal(b) || b == '$' && digits && i > 2 {
|
||||
return i + 1
|
||||
}
|
||||
if !csiContinues(b) {
|
||||
return -1
|
||||
}
|
||||
digits = digits && b >= '0' && b <= '9'
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stringEnd returns the length of the string sequence (DCS, OSC or APC) at the
|
||||
// start of the buffer, or 0 while nothing has ended it. A terminator is
|
||||
// included, an ESC that introduces another sequence is not.
|
||||
func stringEnd(buffer []byte) int {
|
||||
if len(buffer) < 2 {
|
||||
return 0
|
||||
}
|
||||
// Every one of them ends with ST. BEL ends an OSC as well, because xterm has
|
||||
// always allowed it, but stopping at a BEL inside a DCS or APC payload would
|
||||
// frame only its first half and leave the rest to be typed into the query.
|
||||
bel := buffer[1] == ']'
|
||||
for i := 2; i < len(buffer); i++ {
|
||||
switch buffer[i] {
|
||||
case '\a':
|
||||
if bel {
|
||||
return i + 1
|
||||
}
|
||||
case Esc.Byte():
|
||||
if i+1 == len(buffer) {
|
||||
return 0 // ST may still be arriving
|
||||
}
|
||||
if buffer[i+1] == '\\' {
|
||||
return i + 2
|
||||
}
|
||||
// Any other ESC ends the string and introduces a sequence of its
|
||||
// own, so frame only what precedes it and leave the ESC to be
|
||||
// parsed again. Scanning past it would swallow that sequence too.
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// rxvtKeyEnd reports whether the byte ends a key such as \e[7~ for Home.
|
||||
// rxvt marks modifiers with the final byte: $ for SHIFT, ^ for CTRL, @ for both.
|
||||
func rxvtKeyEnd(b byte) bool {
|
||||
return b == '~' || b == '$' || b == '^' || b == '@'
|
||||
}
|
||||
|
||||
// csiIntroducer reports whether the byte after ESC starts a sequence that is
|
||||
// framed by a final byte. The parser routes CSI and SS3 through the same
|
||||
// parameterized subcases, so both are framed by csiEnd.
|
||||
func csiIntroducer(b byte) bool {
|
||||
return b == '[' || b == 'O'
|
||||
}
|
||||
|
||||
// stringIntroducer reports whether the byte after ESC starts a string sequence.
|
||||
// Only the three that terminals reply with: OSC for colors, title and clipboard,
|
||||
// DCS for XTVERSION, DECRQSS and XTGETTCAP, APC for Kitty graphics. Nothing
|
||||
// sends SOS or PM.
|
||||
func stringIntroducer(b byte) bool {
|
||||
switch b {
|
||||
case 'P', ']', '_':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stringReply reports whether a framed string sequence starts the way a
|
||||
// terminal's reply does. Its introducer is also ALT-], ALT-P or ALT-_, and typed
|
||||
// text after one, ended by CTRL-G or another key, frames like a reply when it
|
||||
// all arrives in one read.
|
||||
func stringReply(buffer []byte) bool {
|
||||
payload := buffer[2:]
|
||||
switch buffer[1] {
|
||||
case ']': // Ps ; ...
|
||||
i := 0
|
||||
for i < len(payload) && payload[i] >= '0' && payload[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
return i > 0 && i < len(payload) && payload[i] == ';'
|
||||
case 'P': // >| or !| for the version, [01]$r or [01]+r for settings
|
||||
if len(payload) < 2 {
|
||||
return false
|
||||
}
|
||||
if payload[1] == '|' {
|
||||
return payload[0] == '>' || payload[0] == '!'
|
||||
}
|
||||
return len(payload) >= 3 && (payload[0] == '0' || payload[0] == '1') &&
|
||||
(payload[1] == '$' || payload[1] == '+') && payload[2] == 'r'
|
||||
case '_': // G, then key=value
|
||||
return len(payload) >= 3 && payload[0] == 'G' &&
|
||||
(payload[1] >= 'a' && payload[1] <= 'z' || payload[1] >= 'A' && payload[1] <= 'Z') &&
|
||||
payload[2] == '='
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillArriving reports whether the CSI or SS3 sequence at the start of the
|
||||
// buffer may still be completed by more input. It must be the only one in the buffer:
|
||||
// whatever follows it, another sequence or a byte that ended it, means it is not
|
||||
// waiting for anything.
|
||||
func stillArriving(buffer []byte) bool {
|
||||
return bytes.IndexByte(buffer[1:], Esc.Byte()) < 0 && incompleteEscape(buffer)
|
||||
}
|
||||
|
||||
// incompleteEscape reports whether the buffer ends in a CSI or SS3 sequence that
|
||||
// has not been terminated yet. The read loop keeps waiting while it does, so a
|
||||
// fragment reaches the parser only once that wait has run out. A string sequence
|
||||
// is never waited for, because its introducer is also ALT-], ALT-P or ALT-_.
|
||||
func incompleteEscape(buffer []byte) bool {
|
||||
// Only the tail can hold a sequence still arriving. This runs once per byte
|
||||
// read, so scanning all of a large paste would make the read quadratic.
|
||||
tail := buffer
|
||||
if len(tail) > escapeLookback {
|
||||
tail = tail[len(tail)-escapeLookback:]
|
||||
}
|
||||
start := bytes.LastIndexByte(tail, Esc.Byte())
|
||||
if start < 0 || len(tail)-start < 2 {
|
||||
return false
|
||||
}
|
||||
// SS3 is judged like CSI: declaring it finished after one byte made the
|
||||
// parser give up on a split \eO1;5A. A malformed sequence is not waited for,
|
||||
// as nothing can complete it.
|
||||
return csiIntroducer(tail[start+1]) && csiEnd(tail[start:]) == 0
|
||||
}
|
||||
|
||||
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
|
||||
return r.getBytesInternal(cancellable, r.buffer, false)
|
||||
}
|
||||
@@ -524,13 +378,6 @@ 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,
|
||||
@@ -588,27 +435,17 @@ func (r *LightRenderer) GetChar(cancellable bool) Event {
|
||||
return Event{CtrlSlash, 0, nil}
|
||||
case Esc.Byte():
|
||||
ev := r.escSequence(&sz)
|
||||
// Second chance, but only for an unfinished sequence. Re-reading
|
||||
// otherwise blocks until the next keystroke, holding back whatever
|
||||
// follows in the buffer.
|
||||
if ev.Type == Invalid && stillArriving(r.buffer) {
|
||||
waited := len(r.buffer)
|
||||
var buffer []byte
|
||||
buffer, result, err = r.getBytes(true)
|
||||
// Second chance
|
||||
if ev.Type == Invalid {
|
||||
r.buffer, result, err = r.getBytes(true)
|
||||
if err != nil {
|
||||
return Event{Fatal, 0, nil}
|
||||
}
|
||||
r.buffer = buffer
|
||||
if result == getCharCancelled {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
ev = r.escSequence(&sz)
|
||||
// This read has no time limit, so what it returned may be the next
|
||||
// keystroke rather than the rest of the sequence. Never drop it.
|
||||
if ev.Type == Invalid && sz > waited {
|
||||
sz = waited
|
||||
}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
@@ -640,41 +477,7 @@ func (r *LightRenderer) setCancel(f func()) {
|
||||
r.mutex.Unlock()
|
||||
}
|
||||
|
||||
// escSequence parses an escape sequence, then checks a CSI or SS3 result
|
||||
// against its frame. parseEscSequence can stop after a few bytes, and consuming
|
||||
// only those would leave the rest to be typed into the query. Complete
|
||||
// sequences it does not recognize are dropped there, unless they could be an
|
||||
// ALT key followed by typed text.
|
||||
func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
ev := r.parseEscSequence(sz)
|
||||
if len(r.buffer) < 3 || !csiIntroducer(r.buffer[1]) {
|
||||
return ev
|
||||
}
|
||||
// A frame missing its final byte may still be arriving
|
||||
end := csiEnd(r.buffer)
|
||||
if end <= 0 {
|
||||
return ev
|
||||
}
|
||||
// The parser can also set the size before checking the bytes and then give
|
||||
// up, counting typed input past the frame, as in \e[3;1u followed by ~
|
||||
if ev.Type == Invalid && *sz > end {
|
||||
*sz = end
|
||||
}
|
||||
// Four bytes or fewer could be ALT-[ or ALT-O and typed text, and rxvt sends
|
||||
// keys of that size fzf does not know, such as \e[3^ for CTRL-DELETE
|
||||
if end <= 4 {
|
||||
return ev
|
||||
}
|
||||
// A key matched on a prefix, such as Home for \e[70;5u, or a sequence given
|
||||
// up on partway
|
||||
if end > *sz {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
if len(r.buffer) < 2 {
|
||||
return Event{Esc, 0, nil}
|
||||
}
|
||||
@@ -755,11 +558,6 @@ func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
if len(r.buffer) < 4 {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
// A byte that cannot continue the sequence, such as Enter after
|
||||
// ALT-[ and a digit, is a key of its own
|
||||
if csiEnd(r.buffer) < 0 {
|
||||
break
|
||||
}
|
||||
*sz = 4
|
||||
switch r.buffer[2] {
|
||||
case '2':
|
||||
@@ -792,7 +590,7 @@ func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{Delete, 0, nil}
|
||||
}
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -811,7 +609,7 @@ func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftDelete, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -834,14 +632,12 @@ func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
}
|
||||
return Event{Invalid, 0, nil}
|
||||
case '4':
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
return Event{End, 0, nil}
|
||||
case '5':
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{PageUp, 0, nil}
|
||||
}
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -860,7 +656,7 @@ func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftPageUp, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -886,7 +682,7 @@ func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{PageDown, 0, nil}
|
||||
}
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -905,7 +701,7 @@ func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftPageDown, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -928,19 +724,15 @@ func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
}
|
||||
return Event{Invalid, 0, nil}
|
||||
case '7':
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{Home, 0, nil}
|
||||
}
|
||||
return Event{Home, 0, nil}
|
||||
case '8':
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
return Event{End, 0, nil}
|
||||
case '1':
|
||||
switch r.buffer[3] {
|
||||
case '~':
|
||||
return Event{Home, 0, nil}
|
||||
case '1', '2', '3', '4', '5', '7', '8', '9':
|
||||
if len(r.buffer) > 4 && r.buffer[4] == '~' {
|
||||
if len(r.buffer) == 5 && r.buffer[4] == '~' {
|
||||
*sz = 5
|
||||
switch r.buffer[3] {
|
||||
case '1':
|
||||
@@ -1147,24 +939,6 @@ func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
} // r.buffer[2]
|
||||
} // r.buffer[2]
|
||||
} // r.buffer[1]
|
||||
// Nothing matched. A framed sequence is dropped whole: reading its
|
||||
// introducer as an ALT-key below would type the rest into the query.
|
||||
// Unterminated ones are left alone, as that is how ALT-[, ALT-O, ALT-],
|
||||
// ALT-P and ALT-_ arrive.
|
||||
if csiIntroducer(r.buffer[1]) {
|
||||
// ALT-[ or ALT-O and one or two typed characters can look like a short
|
||||
// sequence, so ask for at least two bytes before the final byte
|
||||
if end := csiEnd(r.buffer); end > 4 {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
} else if stringIntroducer(r.buffer[1]) {
|
||||
if end := stringEnd(r.buffer); end > 0 && stringReply(r.buffer) {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
}
|
||||
|
||||
rest := bytes.NewBuffer(r.buffer[1:])
|
||||
c, size, err := rest.ReadRune()
|
||||
if err == nil {
|
||||
@@ -1181,13 +955,11 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
// Not past the frame, where M or m can be a key typed later
|
||||
frame := csiEnd(r.buffer)
|
||||
if frame <= 0 || r.buffer[frame-1] != 'm' && r.buffer[frame-1] != 'M' {
|
||||
rest := r.buffer[*sz:]
|
||||
end := bytes.IndexAny(rest, "mM")
|
||||
if end == -1 {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
rest := r.buffer[*sz:]
|
||||
end := frame - 1 - *sz
|
||||
|
||||
elems := strings.SplitN(string(rest[:end]), ";", 3)
|
||||
if len(elems) != 3 {
|
||||
@@ -1360,13 +1132,10 @@ func (r *LightRenderer) Close() {
|
||||
r.rmcup()
|
||||
} else {
|
||||
r.origin()
|
||||
// Erase our own area first, then step back onto the line the
|
||||
// prompt was on, so nothing on that line is touched
|
||||
r.csi("J")
|
||||
if r.upOneLine {
|
||||
r.csi("A")
|
||||
r.csi(fmt.Sprintf("%dG", r.xoffset+1))
|
||||
}
|
||||
r.csi("J")
|
||||
}
|
||||
} else if !r.fullscreen {
|
||||
r.stderr("\x1b8") // DECRC: restore cursor position
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
// An unrecognized escape sequence must be consumed whole, CSI and string
|
||||
// sequences alike. Consuming only part of one leaves the rest to be read as
|
||||
// input and typed into the query.
|
||||
func TestUnknownEscapeSequence(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
sequence string
|
||||
event EventType
|
||||
size int
|
||||
}{
|
||||
// Key encodings fzf does not implement
|
||||
{"\x1b[97;5u", Invalid, 7},
|
||||
{"\x1b[70;5u", Invalid, 7}, // not Home, which its prefix \e[7 matches
|
||||
{"\x1b[42;5u", Invalid, 7}, // nor End
|
||||
{"\x1b[4;5~", Invalid, 6},
|
||||
{"\x1b[127;5u", Invalid, 8},
|
||||
{"\x1b[27;5;127~", Invalid, 11},
|
||||
{"\x1b[57441;1u", Invalid, 10},
|
||||
{"\x1b\x1b[97;5u", Invalid, 7}, // ALT prefixed, the first ESC is dropped
|
||||
|
||||
// Replies to queries fzf did not send, or sent and stopped waiting for
|
||||
{"\x1b[?1;2c", Invalid, 7},
|
||||
{"\x1b[>0;95;0c", Invalid, 10},
|
||||
|
||||
// Mouse report arriving while mouse input is off
|
||||
{"\x1b[<0;1;1M", Invalid, 9},
|
||||
|
||||
// String sequences
|
||||
{"\x1b]11;rgb:4a4a/4a4a/4a4a\x1b\\", Invalid, 25}, // background color reply
|
||||
{"\x1b]0;a title\a", Invalid, 12}, // BEL terminated
|
||||
{"\x1bP>|kitty(0.48.2)\x1b\\", Invalid, 19}, // XTVERSION reply
|
||||
{"\x1b_Gi=1;OK\x1b\\", Invalid, 11}, // Kitty graphics reply
|
||||
{"\x1bP1$r\a\x1b\\", Invalid, 8}, // BEL is payload in a DCS
|
||||
{"\x1b]11;x\x1bX\x1b\\", Invalid, 6}, // ESC ends it, framing "\e]11;x"
|
||||
|
||||
// SS3 is framed like CSI, since the parser routes both the same way
|
||||
{"\x1bO9;5u", Invalid, 6},
|
||||
{"\x1bO1;5X", Invalid, 6}, // unknown final byte
|
||||
{"\x1bO1;5A", CtrlUp, 6}, // recognized, unchanged
|
||||
{"\x1bOP", F1, 3},
|
||||
|
||||
// Left alone: this is how ALT-[, ALT-O, ALT-], ALT-P and ALT-_ arrive
|
||||
{"\x1b[a", Alt, 2},
|
||||
{"\x1b[9A", Alt, 2}, // ALT-[ and two characters can look like a CSI
|
||||
{"\x1b[1m", Alt, 2},
|
||||
{"\x1b[ x", Alt, 2},
|
||||
{"\x1bOx", Alt, 2},
|
||||
{"\x1bO9A", Alt, 2},
|
||||
|
||||
// rxvt keys fzf does not know: dropped, not typed
|
||||
{"\x1b[3^", Invalid, 4}, // CTRL-DELETE
|
||||
{"\x1b[5^", Invalid, 4}, // CTRL-PAGEUP
|
||||
{"\x1b[3@", Invalid, 4}, // CTRL-SHIFT-DELETE
|
||||
{"\x1b[11^", Invalid, 5},
|
||||
|
||||
// rxvt ends keys with $ after digits, so what follows is typed
|
||||
{"\x1b[7$x", Home, 4}, // SHIFT-HOME, then x
|
||||
{"\x1b[7$1x", Home, 4},
|
||||
{"\x1b[3$x", Invalid, 4},
|
||||
{"\x1b[3$1x", Invalid, 4},
|
||||
{"\x1b[23$x", Invalid, 5}, // SHIFT-F11, then x
|
||||
{"\x1b[24$1x", Invalid, 5},
|
||||
|
||||
// A parse that gives up past the frame consumes only the frame, so the
|
||||
// typed ~ after these stays in the buffer
|
||||
{"\x1b[3;1u~", Invalid, 6},
|
||||
{"\x1b[5;1u~", Invalid, 6},
|
||||
{"\x1b[2$~", Invalid, 4}, // rxvt SHIFT-INSERT
|
||||
{"\x1b[2^~", Invalid, 4}, // rxvt CTRL-INSERT
|
||||
{"\x1b[2A~", Invalid, 4},
|
||||
|
||||
// Elsewhere $ and other intermediate bytes do not end a sequence
|
||||
{"\x1b[4;0;10;20;0&w", Invalid, 15}, // not End, which \e[4 matches
|
||||
{"\x1b[4;2$y", Invalid, 7},
|
||||
{"\x1b[2 q", Invalid, 5},
|
||||
{"\x1b]abc", Alt, 2},
|
||||
{"\x1b]11;rgb:", Alt, 2}, // terminator has not arrived
|
||||
{"\x1b]\x1b]", Alt, 2}, // a second sequence must not swallow the ALT key
|
||||
{"\x1b]\x1b[A", Alt, 2},
|
||||
{"\x1bP\x1bP", Alt, 2},
|
||||
{"\x1b_\x1b_", Alt, 2},
|
||||
{"\x1b]\x1b\\", Alt, 2}, // ALT-] then ALT-backslash, not an empty OSC
|
||||
{"\x1b]\a", Alt, 2}, // ALT-] then CTRL-G, which must still abort
|
||||
|
||||
// Typed text after the introducer is not a reply, even when a
|
||||
// terminator follows in the same read
|
||||
{"\x1b]a\a", Alt, 2},
|
||||
{"\x1b]1\a", Alt, 2},
|
||||
{"\x1b]a\x1b[A", Alt, 2},
|
||||
{"\x1bPx\x1b[A", Alt, 2},
|
||||
{"\x1b_ab\x1bb", Alt, 2},
|
||||
|
||||
// SOS and PM are not framed
|
||||
{"\x1bXsos\x1b\\", Alt, 2},
|
||||
{"\x1b^status\x1b\\", Alt, 2},
|
||||
|
||||
// Left alone: no final byte yet, so the sequence may still be arriving
|
||||
{"\x1b[", Invalid, 2},
|
||||
|
||||
// Recognized sequences keep their existing parsing
|
||||
{"\x1b[1;5A", CtrlUp, 6},
|
||||
{"\x1b[3;5~", CtrlDelete, 6},
|
||||
{"\x1b[2~", Insert, 4},
|
||||
{"\x1b[200~", BracketedPasteBegin, 6},
|
||||
{"\x1b[Z", ShiftTab, 3},
|
||||
{"\x1bOA", Up, 3},
|
||||
{"\x1b[12;34R", Invalid, 8},
|
||||
{"\x1b[?2004;2$y", Invalid, 11},
|
||||
} {
|
||||
r := &LightRenderer{buffer: []byte(c.sequence)}
|
||||
sz := 1
|
||||
event := r.escSequence(&sz)
|
||||
if event.Type != c.event {
|
||||
t.Errorf("escSequence(%q) = %s, want %s",
|
||||
c.sequence, event.Type.String(), c.event.String())
|
||||
}
|
||||
if sz != c.size {
|
||||
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringEnd(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want int
|
||||
}{
|
||||
// Terminated
|
||||
{"\x1b]0;t\a", 6},
|
||||
{"\x1b]0;t\x1b\\", 7},
|
||||
{"\x1b_G\x1b\\", 5},
|
||||
{"\x1bP1$r\a\x1b\\", 8}, // BEL is payload in a DCS, ST ends it
|
||||
|
||||
// BEL terminates an OSC only
|
||||
{"\x1bP\a", 0},
|
||||
{"\x1b_Gi=1\a", 0},
|
||||
|
||||
// An ESC ends the string and introduces a sequence of its own
|
||||
{"\x1b]foo\x1bX\x1b\\", 5},
|
||||
{"\x1b]foo\x1bP", 5},
|
||||
{"\x1b]\x1b]", 2},
|
||||
|
||||
// Nothing has ended it yet
|
||||
{"\x1b]0;t", 0},
|
||||
{"\x1b]0;t\x1b", 0}, // ST half arrived
|
||||
{"\x1b]foo\x1b", 0},
|
||||
{"\x1b]", 0},
|
||||
{"\x1b", 0}, // shorter than an introducer
|
||||
|
||||
} {
|
||||
if got := stringEnd([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stringEnd(%q) = %d, want %d", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A dropped sequence followed by ALT-[ is not waiting for anything, so the
|
||||
// bytes in between must not be held back until the next keystroke
|
||||
func TestStillArriving(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1b[97;5u\a\x1b[", false},
|
||||
{"\x1b[97;5u\x1b[", false},
|
||||
{"\x1b[3;\a\x1b[", false}, // the BEL ended it, the trailing \e[ is another one
|
||||
{"\x1b[1;\a", false}, // the BEL has already ended it
|
||||
{"\x1b[1;", true},
|
||||
{"\x1b[", true},
|
||||
{"\x1bO1", true},
|
||||
{"\x1b[<0;1", true},
|
||||
} {
|
||||
if got := stillArriving([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stillArriving(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringReply(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1b]11;rgb:1/2/3\a", true},
|
||||
{"\x1b]0;title\a", true},
|
||||
{"\x1b]52;c;YWJj\x1b\\", true},
|
||||
{"\x1bP>|kitty(0.48.2)\x1b\\", true},
|
||||
{"\x1bP!|0\x1b\\", true},
|
||||
{"\x1bP1$r0m\x1b\\", true},
|
||||
{"\x1bP0+r\x1b\\", true},
|
||||
{"\x1b_Gi=1;OK\x1b\\", true},
|
||||
|
||||
// What typed text after ALT-], ALT-P or ALT-_ looks like
|
||||
{"\x1b]\a", false},
|
||||
{"\x1b]a\a", false},
|
||||
{"\x1b]1\a", false},
|
||||
{"\x1b];\a", false},
|
||||
{"\x1bPx\x1b\\", false},
|
||||
{"\x1bP1\x1b\\", false},
|
||||
{"\x1b_ab\x1b\\", false},
|
||||
{"\x1b_G\x1b\\", false},
|
||||
} {
|
||||
if got := stringReply([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stringReply(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsiEnd(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want int
|
||||
}{
|
||||
{"\x1b[97;5u", 7},
|
||||
{"\x1b[A", 3},
|
||||
{"\x1b[<0;1;1M", 9},
|
||||
{"\x1b[?2004;2$y", 11},
|
||||
{"\x1b[7$x", 4}, // rxvt ends keys with $ after digits
|
||||
{"\x1b[23$", 5},
|
||||
{"\x1b[4;2$y", 7}, // but $ is intermediate elsewhere
|
||||
{"\x1b[97;5", 0}, // no final byte
|
||||
{"\x1b[", 0}, // no final byte
|
||||
{"\x1b[1\x01A", -1}, // malformed, do not frame it
|
||||
{"\x1b[\a", -1},
|
||||
} {
|
||||
if got := csiEnd([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("csiEnd(%q) = %d, want %d", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key that follows a sequence in the same read must not be consumed with it
|
||||
func TestKeyAfterSequence(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
sequence string
|
||||
mouse bool
|
||||
event EventType
|
||||
size int
|
||||
}{
|
||||
// Recognized when more input follows, as from a paste or tmux send-keys
|
||||
{"\x1b[15~x", false, F5, 5},
|
||||
{"\x1b[3;5~x", false, CtrlDelete, 6},
|
||||
{"\x1b[5;13~x", false, CtrlAltPageUp, 7},
|
||||
{"\x1b[6;2~\x1b[A", false, ShiftPageDown, 6},
|
||||
|
||||
// ALT-[ or ALT-O and a digit, then a key that cannot continue a sequence
|
||||
{"\x1b[2\r", false, Alt, 2},
|
||||
{"\x1b[12\x03", false, Alt, 2},
|
||||
{"\x1bO2\a", false, Alt, 2},
|
||||
{"\x1b[4x", false, Alt, 2}, // not End
|
||||
{"\x1b[7\r", false, Alt, 2}, // not Home
|
||||
{"\x1b[7~x", false, Home, 4},
|
||||
{"\x1b[8^x", false, End, 4}, // rxvt CTRL-END
|
||||
|
||||
// A mouse report ends at its own final byte
|
||||
{"\x1b[<0;5;3Mx", true, Mouse, 9},
|
||||
{"\x1b[<0;1;\x1b[AM", true, Invalid, 3},
|
||||
{"\x1b[<0;1;1xM", true, Invalid, 9},
|
||||
} {
|
||||
r := &LightRenderer{buffer: []byte(c.sequence), mouse: c.mouse}
|
||||
sz := 1
|
||||
event := r.escSequence(&sz)
|
||||
if event.Type != c.event {
|
||||
t.Errorf("escSequence(%q) = %s, want %s",
|
||||
c.sequence, event.Type.String(), c.event.String())
|
||||
}
|
||||
if sz != c.size {
|
||||
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIncompleteEscape(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
// Complete sequences: nothing to wait for
|
||||
{"\x1b[A", false},
|
||||
{"\x1bOA", false},
|
||||
{"\x1b[1;5A", false},
|
||||
{"\x1b[200~", false},
|
||||
{"\x1b[<0;1;1M", false},
|
||||
{"\x1b[12;34R", false},
|
||||
{"\x1b[?2004;2$y", false},
|
||||
{"\x1b[?1;2c", false},
|
||||
|
||||
// Fragments: keep waiting
|
||||
{"\x1b[", true},
|
||||
{"\x1b[?", true},
|
||||
{"\x1b[1;", true},
|
||||
{"\x1b[?2004;2$", true},
|
||||
{"\x1bO", true},
|
||||
{"\x1b[<0;1;", true},
|
||||
|
||||
// Only the trailing sequence matters
|
||||
{"ab\x1b[?2004;2$", true},
|
||||
{"\x1b[A\x1b[", true},
|
||||
{"\x1b[A\x1b[B", false},
|
||||
|
||||
// Long buffers: only the tail is scanned, so an introducer further
|
||||
// back than escapeLookback is not waited for
|
||||
{strings.Repeat("a", 100000), false},
|
||||
{"\x1b[" + strings.Repeat("a", 100000), false},
|
||||
{strings.Repeat("a", 100000) + "\x1b[1;", true},
|
||||
|
||||
// Not a sequence fzf waits on
|
||||
{"", false},
|
||||
{"abc", false},
|
||||
{"\x1b", false}, // lone ESC, handled by the existing escDelay branch
|
||||
{"\x1ba", false}, // ALT-a
|
||||
{"\x1b[\x01", false}, // malformed, do not stall on it
|
||||
} {
|
||||
if got := incompleteEscape([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A string sequence is not waited for. Its introducer is also ALT-], ALT-P or
|
||||
// ALT-_, and holding the read open on those both delayed the key and let typed
|
||||
// bytes accumulate into something that looked like a sequence.
|
||||
func TestIncompleteStringEscape(t *testing.T) {
|
||||
for _, buffer := range []string{
|
||||
"\x1b]11;rgb:",
|
||||
"\x1bP>|kitty",
|
||||
"\x1b_Gi=1",
|
||||
"\x1b]0;t\x1b",
|
||||
"\x1b]0;title\a",
|
||||
"ab\x1b]11;rgb:",
|
||||
} {
|
||||
if incompleteEscape([]byte(buffer)) {
|
||||
t.Errorf("incompleteEscape(%q) = true, want false", buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SS3 reaches the same parameterized subcases as CSI in the parser, so it has to
|
||||
// be judged the same way here. Declaring it finished after one byte made the
|
||||
// parser give up on a split \eO1;5A and type its tail into the query.
|
||||
func TestIncompleteSS3(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1bO", true},
|
||||
{"\x1bO1", true},
|
||||
{"\x1bO1;", true},
|
||||
{"\x1bO1;5", true},
|
||||
{"\x1bOA", false},
|
||||
{"\x1bOP", false},
|
||||
{"\x1bO1;5A", false},
|
||||
{"\x1b[23$", false}, // rxvt SHIFT-F11 is complete
|
||||
{"\x1b[7$", false},
|
||||
{"\x1b[4;2$", true}, // $ after a ; is intermediate
|
||||
} {
|
||||
if got := incompleteEscape([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Drives queryStartup against a terminal simulated by pipes, with the replies
|
||||
@@ -129,54 +126,3 @@ func TestQueryStartup(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The second-chance read has no time limit, so a key typed long after an
|
||||
// unfinished sequence must not be dropped with it.
|
||||
func TestLateKeyAfterUnfinishedSequence(t *testing.T) {
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inR.Close(); inW.Close() })
|
||||
inW.WriteString("\x1b[12")
|
||||
go func() {
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.WriteString("x")
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.WriteString("y") // ends the wait if x was dropped
|
||||
}()
|
||||
|
||||
r := &LightRenderer{ttyin: inR, escDelay: defaultEscDelay}
|
||||
for range 3 {
|
||||
if ev := r.GetChar(false); ev.Type == Rune {
|
||||
if ev.Char != 'x' {
|
||||
t.Errorf("got %q, want 'x'", ev.Char)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("the late key was dropped")
|
||||
}
|
||||
|
||||
// A read error during the second-chance read must end fzf cleanly, not panic.
|
||||
func TestSecondChanceReadError(t *testing.T) {
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inR.Close() })
|
||||
inW.WriteString("\x1b[12")
|
||||
go func() {
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.Close()
|
||||
}()
|
||||
|
||||
out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &LightRenderer{ttyin: inR, ttyout: out, escDelay: defaultEscDelay, origState: &term.State{}}
|
||||
if ev := r.GetChar(false); ev.Type != Fatal {
|
||||
t.Errorf("got %s, want Fatal", ev.Type.String())
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ func TestLightRenderer(t *testing.T) {
|
||||
assertEscSequence("\x1b[1;1R", "!Invalid")
|
||||
assertEscSequence("\x1b[", "!Invalid")
|
||||
assertEscSequence("\x1b[1", "!Invalid")
|
||||
assertEscSequence("\x1b[3;3~1", "!Invalid")
|
||||
assertEscSequence("\x1b[13", "!Invalid")
|
||||
assertEscSequence("\x1b[1;3", "!Invalid")
|
||||
assertEscSequence("\x1b[1;10", "!Invalid")
|
||||
@@ -199,7 +200,6 @@ func TestLightRenderer(t *testing.T) {
|
||||
assertEscSequence("\x1b[1;3H", "alt-home")
|
||||
assertEscSequence("\x1b[1;3F", "alt-end")
|
||||
assertEscSequence("\x1b[3;3~", "alt-delete")
|
||||
assertEscSequence("\x1b[3;3~1", "alt-delete") // then 1
|
||||
assertEscSequence("\x1b[5;3~", "alt-page-up")
|
||||
assertEscSequence("\x1b[6;3~", "alt-page-down")
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResu
|
||||
}()
|
||||
|
||||
cancelFd := int(rpipe.Fd())
|
||||
for {
|
||||
for range maxSelectTries {
|
||||
var rfds unix.FdSet
|
||||
limit := len(rfds.Bits) * unix.NFDBITS
|
||||
if fd >= limit || cancelFd >= limit {
|
||||
@@ -251,8 +251,6 @@ func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResu
|
||||
rfds.Set(cancelFd)
|
||||
_, err := unix.Select(max(fd, cancelFd)+1, &rfds, nil, nil, nil)
|
||||
if err != nil {
|
||||
// An interrupted wait is not a failed read, so it must not count
|
||||
// against anything. Retry until the fd is ready or the wait fails.
|
||||
if err == syscall.EINTR {
|
||||
continue
|
||||
}
|
||||
@@ -267,6 +265,7 @@ func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResu
|
||||
return getter()
|
||||
}
|
||||
}
|
||||
return 0, getCharError
|
||||
}
|
||||
|
||||
func (r *LightRenderer) Size() TermSize {
|
||||
|
||||
+7
-9
@@ -105,18 +105,16 @@ func RepeatToFill(str string, length int, limit int) string {
|
||||
rest := limit % length
|
||||
output := strings.Repeat(str, times)
|
||||
if rest > 0 {
|
||||
// Iterate over grapheme clusters so that we don't cut a cluster in half
|
||||
end := 0
|
||||
graphemes := uniseg.NewGraphemes(str)
|
||||
for rest > 0 && graphemes.Next() {
|
||||
width := graphemes.Width()
|
||||
if width > rest {
|
||||
for _, r := range str {
|
||||
rest -= uniseg.StringWidth(string(r))
|
||||
if rest < 0 {
|
||||
break
|
||||
}
|
||||
output += string(r)
|
||||
if rest == 0 {
|
||||
break
|
||||
}
|
||||
rest -= width
|
||||
_, end = graphemes.Positions()
|
||||
}
|
||||
output += str[:end]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -109,27 +109,6 @@ func TestRepeatToFill(t *testing.T) {
|
||||
if RepeatToFill("abcde", 10, 42) != strings.Repeat("abcde", 4)+"abcde"[:2] {
|
||||
t.Error("Expected:", strings.Repeat("abcde", 4)+"abcde"[:2])
|
||||
}
|
||||
|
||||
// Should not cut a grapheme cluster in half
|
||||
for _, test := range []struct {
|
||||
str string
|
||||
limit int
|
||||
expected string
|
||||
}{
|
||||
{"a\u0301b", 1, "a\u0301"},
|
||||
{"a\u0301b", 3, "a\u0301ba\u0301"},
|
||||
{"a\u0301b", 4, "a\u0301ba\u0301b"},
|
||||
{"a\u4e00", 2, "a"},
|
||||
{"a\u4e00", 4, "a\u4e00a"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 1, "-"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 2, "-"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 4, "-\U0001f468\u200d\U0001f469\u200d\U0001f467-"},
|
||||
} {
|
||||
actual := RepeatToFill(test.str, StringWidth(test.str), test.limit)
|
||||
if actual != test.expected {
|
||||
t.Errorf("Expected: %q, actual: %q", test.expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringWidth(t *testing.T) {
|
||||
|
||||
+10
-12
@@ -44,21 +44,19 @@ func NewExecutor(withShell string) *Executor {
|
||||
|
||||
shellType := shellTypeUnknown
|
||||
basename := filepath.Base(shell)
|
||||
defaultArgs := []string{"-c"}
|
||||
if strings.HasPrefix(basename, "cmd") {
|
||||
shellType = shellTypeCmd
|
||||
defaultArgs = []string{"/s/c"}
|
||||
} else if strings.HasPrefix(basename, "pwsh") {
|
||||
shellType = shellTypePwsh
|
||||
defaultArgs = []string{"-NoProfile", "-Command"}
|
||||
} else if strings.HasPrefix(basename, "powershell") {
|
||||
shellType = shellTypePowerShell
|
||||
defaultArgs = []string{"-NoProfile", "-Command"}
|
||||
}
|
||||
if len(args) > 0 {
|
||||
args = args[1:]
|
||||
} else if strings.HasPrefix(basename, "cmd") {
|
||||
shellType = shellTypeCmd
|
||||
args = []string{"/s/c"}
|
||||
} else if strings.HasPrefix(basename, "pwsh") {
|
||||
shellType = shellTypePwsh
|
||||
args = []string{"-NoProfile", "-Command"}
|
||||
} else if strings.HasPrefix(basename, "powershell") {
|
||||
shellType = shellTypePowerShell
|
||||
args = []string{"-NoProfile", "-Command"}
|
||||
} else {
|
||||
args = defaultArgs
|
||||
args = []string{"-c"}
|
||||
}
|
||||
return &Executor{shell: shell, shellType: shellType, args: args}
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestNewWindowsExecutor(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
envShell string
|
||||
withShell string
|
||||
shell string
|
||||
shellType shellType
|
||||
args []string
|
||||
}{
|
||||
{"default", "", "", "cmd", shellTypeCmd, []string{"/s/c"}},
|
||||
{"cmd_env", "cmd.exe", "", "cmd.exe", shellTypeCmd, []string{"/s/c"}},
|
||||
{"cmd_path_env", `C:\Windows\System32\cmd.exe`, "", `C:\Windows\System32\cmd.exe`, shellTypeCmd, []string{"/s/c"}},
|
||||
{"powershell_env", "powershell.exe", "", "powershell.exe", shellTypePowerShell, []string{"-NoProfile", "-Command"}},
|
||||
{"pwsh_env", "pwsh.exe", "", "pwsh.exe", shellTypePwsh, []string{"-NoProfile", "-Command"}},
|
||||
{"custom_env", "custom-shell", "", "custom-shell", shellTypeUnknown, []string{"-c"}},
|
||||
{"cmd_explicit", "powershell.exe", "cmd.exe /d /c", "cmd.exe", shellTypeCmd, []string{"/d", "/c"}},
|
||||
{"cmd_path_explicit", "", `C:\Windows\System32\cmd.exe /d /s /c`, `C:\Windows\System32\cmd.exe`, shellTypeCmd, []string{"/d", "/s", "/c"}},
|
||||
{"powershell_explicit", "cmd.exe", "powershell.exe -NoLogo -NonInteractive -Command", "powershell.exe", shellTypePowerShell, []string{"-NoLogo", "-NonInteractive", "-Command"}},
|
||||
{"pwsh_explicit", "cmd.exe", "pwsh.exe -NoLogo -Command", "pwsh.exe", shellTypePwsh, []string{"-NoLogo", "-Command"}},
|
||||
{"custom_explicit", "cmd.exe", "custom-shell --custom -e", "custom-shell", shellTypeUnknown, []string{"--custom", "-e"}},
|
||||
{"cmd_no_flags", "", "cmd.exe", "cmd.exe", shellTypeCmd, []string{}},
|
||||
{"powershell_no_flags", "", "powershell.exe", "powershell.exe", shellTypePowerShell, []string{}},
|
||||
{"pwsh_no_flags", "", "pwsh.exe", "pwsh.exe", shellTypePwsh, []string{}},
|
||||
{"custom_no_flags", "", "custom-shell", "custom-shell", shellTypeUnknown, []string{}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("SHELL", test.envShell)
|
||||
x := NewExecutor(test.withShell)
|
||||
if x.shell != test.shell || x.shellType != test.shellType || !reflect.DeepEqual(x.args, test.args) {
|
||||
t.Errorf("got shell=%q type=%v args=%q; want shell=%q type=%v args=%q",
|
||||
x.shell, x.shellType, x.args, test.shell, test.shellType, test.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsExecutorQuoteEntry(t *testing.T) {
|
||||
entry := `hello 한글 O'Brien & "quoted"`
|
||||
for _, test := range []struct {
|
||||
withShell string
|
||||
want string
|
||||
}{
|
||||
{"cmd.exe /d /c", `^"hello 한글 O'Brien ^& \^"quoted\^"^"`},
|
||||
{"powershell.exe -NoProfile -Command", `'hello 한글 O''Brien & \"quoted\"'`},
|
||||
{"pwsh.exe -NoProfile -Command", `'hello 한글 O''Brien & \"quoted\"'`},
|
||||
{"custom-shell -c", `'hello 한글 O'\''Brien & "quoted"'`},
|
||||
} {
|
||||
t.Run(test.withShell, func(t *testing.T) {
|
||||
if got := NewExecutor(test.withShell).QuoteEntry(entry); got != test.want {
|
||||
t.Errorf("got %q; want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsExecutorProcessGroup(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
withShell string
|
||||
flags uint32
|
||||
args []string
|
||||
}{
|
||||
{"cmd.exe /d /c", 0, nil},
|
||||
{"powershell.exe -NoLogo -Command", 0, []string{"-NoLogo", "-Command"}},
|
||||
{"pwsh.exe -NoLogo -Command", windows.CREATE_NEW_PROCESS_GROUP, []string{"-NonInteractive", "-NoLogo", "-Command"}},
|
||||
{"custom-shell -e", windows.CREATE_NEW_PROCESS_GROUP, []string{"-e"}},
|
||||
} {
|
||||
t.Run(test.withShell, func(t *testing.T) {
|
||||
x := NewExecutor(test.withShell)
|
||||
originalArgs := append([]string{}, x.args...)
|
||||
for _, setpgid := range []bool{false, true} {
|
||||
cmd := x.ExecCommand("echo hello", setpgid)
|
||||
var wantFlags uint32
|
||||
if setpgid {
|
||||
wantFlags = test.flags
|
||||
}
|
||||
if cmd.SysProcAttr.CreationFlags != wantFlags {
|
||||
t.Errorf("setpgid=%v: got flags=%v; want %v", setpgid, cmd.SysProcAttr.CreationFlags, wantFlags)
|
||||
}
|
||||
if strings.HasPrefix(test.withShell, "cmd") {
|
||||
if cmd.SysProcAttr.CmdLine != `/d /c "echo hello"` {
|
||||
t.Errorf("got CMD command line %q", cmd.SysProcAttr.CmdLine)
|
||||
}
|
||||
} else {
|
||||
wantArgs := test.args
|
||||
if !setpgid && strings.HasPrefix(test.withShell, "pwsh") {
|
||||
wantArgs = wantArgs[1:]
|
||||
}
|
||||
want := append(append([]string{cmd.Args[0]}, wantArgs...), "echo hello")
|
||||
if !reflect.DeepEqual(cmd.Args, want) {
|
||||
t.Errorf("setpgid=%v: got args=%q; want %q", setpgid, cmd.Args, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(x.args, originalArgs) {
|
||||
t.Errorf("ExecCommand changed executor arguments: %q", x.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsExecutorQuotedCommand(t *testing.T) {
|
||||
// Run the test binary as a helper so this regression needs no external tools.
|
||||
// Both its path and its argument exercise Windows shell quoting.
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(executable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "quoted path 한글 & O'Brien")
|
||||
if err := os.Mkdir(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
helper := filepath.Join(dir, "shell-probe.exe")
|
||||
if err := os.WriteFile(helper, data, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
shell string
|
||||
withShell string
|
||||
prefix string
|
||||
}{
|
||||
{"cmd.exe", "", ""},
|
||||
{"cmd.exe", "cmd.exe /d /c", ""},
|
||||
{"powershell.exe", "", "& "},
|
||||
{"powershell.exe", "powershell.exe -NoProfile -NonInteractive -Command", "& "},
|
||||
{"pwsh.exe", "", "& "},
|
||||
{"pwsh.exe", "pwsh.exe -NoProfile -Command", "& "},
|
||||
} {
|
||||
name := "default"
|
||||
if test.withShell != "" {
|
||||
name = "explicit"
|
||||
}
|
||||
t.Run(test.shell+"/"+name, func(t *testing.T) {
|
||||
if _, err := exec.LookPath(test.shell); err != nil {
|
||||
t.Skipf("%s is unavailable: %v", test.shell, err)
|
||||
}
|
||||
t.Setenv("SHELL", test.shell)
|
||||
x := NewExecutor(test.withShell)
|
||||
entry := `hello 한글 O'Brien &|<>()^%!`
|
||||
if test.prefix == "" {
|
||||
entry += ` "quoted" C:\trailing\`
|
||||
}
|
||||
command := test.prefix + `"` + helper + `" ` + x.QuoteEntry("-test.run=TestWindowsExecutorHelper") + " -- " + x.QuoteEntry(entry)
|
||||
cmd := x.ExecCommand(command, true)
|
||||
cmd.Env = append(os.Environ(), "FZF_WINDOWS_EXECUTOR_HELPER=1")
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
timer := time.AfterFunc(10*time.Second, func() { _ = cmd.Process.Kill() })
|
||||
defer timer.Stop()
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("command failed: %v\n%s", err, output.String())
|
||||
}
|
||||
var got []string
|
||||
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid helper output: %v\n%s", err, output.String())
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{entry}) {
|
||||
t.Errorf("got arguments %q; want %q", got, []string{entry})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsExecutorHelper(t *testing.T) {
|
||||
if os.Getenv("FZF_WINDOWS_EXECUTOR_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
for index, arg := range os.Args {
|
||||
if arg == "--" {
|
||||
if err := json.NewEncoder(os.Stdout).Encode(os.Args[index+1:]); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
+1
-176
@@ -1532,61 +1532,6 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| refute_includes(lines[-1], '[[1]]') }
|
||||
end
|
||||
|
||||
def assert_focus_log(expected)
|
||||
wait do
|
||||
assert_path_exists tempname
|
||||
assert_equal expected, File.readlines(tempname, chomp: true)
|
||||
end
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload
|
||||
# Without --id-nth, the item at the same index after reload is a new item
|
||||
tmux.send_keys %(printf 'a\\nb\\n' | #{FZF} --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "c\\nd\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> a' }
|
||||
assert_focus_log(%w[a])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> c' }
|
||||
assert_focus_log(%w[a c])
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload_id_nth_same_key
|
||||
# Same --id-nth key after reload: no focus event
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n1 aa\\n2 bb\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> 1 aa' }
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 bb' }
|
||||
assert_focus_log(['1 a', '2 bb'])
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload_id_nth_key_not_found
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "3 c\\n4 d\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> 3 c' }
|
||||
assert_focus_log(['1 a', '3 c'])
|
||||
end
|
||||
|
||||
def test_focus_event_after_async_reload_id_nth
|
||||
# No focus event for the items shown before the tracked item arrives.
|
||||
# 'result' binding makes fzf redraw the list while blocked.
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'result:change-footer(R)' --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n"; sleep 1; printf "1 aa\\n2 bb\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
tmux.until do |lines|
|
||||
assert_includes lines, '> 1 aa'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 bb' }
|
||||
assert_focus_log(['1 a', '2 bb'])
|
||||
end
|
||||
|
||||
def test_result_event
|
||||
tmux.send_keys '(echo 0; seq 10) | fzf --bind "result:pos(2)"', :Enter
|
||||
tmux.until { |lines| assert_equal 11, lines.match_count }
|
||||
@@ -2342,94 +2287,6 @@ class TestCore < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_next_value
|
||||
tmux.send_keys %(echo 'a b c d' | #{FZF} --nth 1 --bind 'space:change-nth(1|2|3|4),x:change-nth(3),result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[2]>') }
|
||||
# Changed by another binding. The next value is the one after '3'.
|
||||
tmux.send_keys :x
|
||||
tmux.until { |lines| assert lines.any_include?('[3]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[4]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_all_fields_is_current
|
||||
# '--nth ..' is stored as an empty list, which must match '..'
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth .. --bind 'space:change-nth(..|1|2)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_repeated_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'space:change-nth(1|2|1|3)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
%w[2 1 3 1 2].each do |nth|
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_skips_invalid_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 2 --bind 'space:change-nth(1|x|3)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[2]>') }
|
||||
%w[1 3 1].each do |nth|
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_only_invalid_values_left
|
||||
# 'x' is invalid and '3' is current, so nothing changes
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'a:change-nth(3),space:change-nth(x|3)+transform-prompt:echo "S[$FZF_NTH]> "' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert lines.any_include?('[3]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('S[3]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_invalid_value_ignored
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'a:change-nth(3),space:change-nth(bad)+transform-prompt:echo "S[$FZF_NTH]> "' --bind 'b:transform-nth(echo bad)+transform-prompt:echo "T[$FZF_NTH]> "' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert lines.any_include?('[3]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('S[3]>') }
|
||||
tmux.send_keys :b
|
||||
tmux.until { |lines| assert lines.any_include?('T[3]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_from_transform
|
||||
# A new action is created on each run, so the next value is found from the current state
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'space:transform:echo "change-nth(1|2|3)"' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
%w[2 3 1].each do |nth|
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_with_nth_skips_default_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(|2)'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('> a') }
|
||||
# Empty value is the default (field 1), which is current
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('> b') }
|
||||
end
|
||||
|
||||
def test_change_with_nth_skips_invalid_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(1|foo|2)'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('> a') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('> b') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('> a') }
|
||||
end
|
||||
|
||||
def test_transform_with_nth_search
|
||||
input = [
|
||||
'alpha bravo charlie',
|
||||
@@ -2462,25 +2319,6 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
|
||||
end
|
||||
|
||||
def test_transform_nth_output_does_not_replace_command
|
||||
# '|' in the output must not be treated as a list to cycle through
|
||||
File.write(tempname, '')
|
||||
tmux.send_keys %(echo 'a b' | #{FZF} --with-nth .. --bind "space:transform-nth(echo >> #{tempname}; echo '1|2')+transform-with-nth(echo >> #{tempname}; echo '1|2')"), :Enter
|
||||
tmux.until { |lines| assert_equal 1, lines.item_count }
|
||||
3.times do |i|
|
||||
tmux.send_keys :Space
|
||||
wait { assert_equal 2 * (i + 1), File.readlines(tempname).length }
|
||||
end
|
||||
end
|
||||
|
||||
def test_transform_with_nth_output_with_pipe
|
||||
File.write(tempname, '{2}|{1}')
|
||||
tmux.send_keys %(echo 'a b' | #{FZF} --with-nth .. --bind 'space:transform-with-nth(cat #{tempname})'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('a b') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('b|a') }
|
||||
end
|
||||
|
||||
# Regression: actions emitted by bg-transform must affect the iteration that
|
||||
# processes the async result, not the (no-longer-active) iteration that
|
||||
# scheduled the transform. Covers reload (newCommand) and exclude (denylist).
|
||||
@@ -3033,7 +2871,7 @@ class TestCore < TestInteractive
|
||||
end
|
||||
|
||||
def test_change_nth_unset_default
|
||||
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind space:change-nth:..), :Enter
|
||||
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind space:change-nth:), :Enter
|
||||
tmux.until do
|
||||
assert_equal 1, it.item_count
|
||||
assert_equal 0, it.match_count
|
||||
@@ -3047,19 +2885,6 @@ class TestCore < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_empty_restores_default
|
||||
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind 'a:change-nth(..),space:change-nth(),b:transform-nth(true)'), :Enter
|
||||
tmux.until { |lines| assert_equal 0, lines.match_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert_equal 0, lines.match_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :b
|
||||
tmux.until { |lines| assert_equal 0, lines.match_count }
|
||||
end
|
||||
|
||||
def test_change_header_lines
|
||||
tmux.send_keys %(seq 10 | #{FZF} --header-lines 3 --bind 'space:change-header-lines(5),enter:transform-header-lines(echo 1)'), :Enter
|
||||
tmux.until do |lines|
|
||||
|
||||
@@ -25,15 +25,6 @@ class TestFilter < TestBase
|
||||
assert_equal 1, `echo Foo bar | #{FZF} -x -f "foo Fbar" | wc -l`.to_i
|
||||
end
|
||||
|
||||
def test_filter_no_sort
|
||||
input = "printf 'abz\\nab\\nzab\\n'"
|
||||
['', '--sync'].each do |opts|
|
||||
assert_equal %w[ab abz zab], `#{input} | #{FZF} -f ab #{opts}`.lines(chomp: true)
|
||||
assert_equal %w[abz ab zab], `#{input} | #{FZF} -f ab --no-sort #{opts}`.lines(chomp: true)
|
||||
assert_equal %w[zab ab abz], `#{input} | #{FZF} -f ab --no-sort --tac #{opts}`.lines(chomp: true)
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_exitstatus
|
||||
# filter / streaming filter
|
||||
['', '--no-sort'].each do |opts|
|
||||
|
||||
@@ -882,28 +882,6 @@ class TestLayout < TestInteractive
|
||||
tmux.until { assert_block(block, it) }
|
||||
end
|
||||
|
||||
def test_adaptive_height_with_inline_sections
|
||||
tmux.send_keys %(seq 10 | #{FZF} --height=~100% --list-border --header-lines=1 --header-lines-border=inline), :Enter
|
||||
block = <<~BLOCK
|
||||
╭──────
|
||||
│ 10
|
||||
│ 9
|
||||
│ 8
|
||||
│ 7
|
||||
│ 6
|
||||
│ 5
|
||||
│ 4
|
||||
│ 3
|
||||
│ > 2
|
||||
├──────
|
||||
│ 1
|
||||
╰──────
|
||||
9/9
|
||||
>
|
||||
BLOCK
|
||||
tmux.until { assert_block(block, it) }
|
||||
end
|
||||
|
||||
def test_style_full_adaptive_height
|
||||
tmux.send_keys %(seq 1| #{FZF} --style=full:rounded --height=~100% --header-lines=1 --info=default), :Enter
|
||||
block = <<~BLOCK
|
||||
|
||||
@@ -55,29 +55,6 @@ class TestServer < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_listen_progress
|
||||
tmux.send_keys "seq 10 | #{FZF} --listen 6266", :Enter
|
||||
tmux.until { |lines| assert_equal 10, lines.match_count }
|
||||
uri = URI('http://localhost:6266')
|
||||
state = -> { JSON.parse(Net::HTTP.get(uri), symbolize_names: true) }
|
||||
|
||||
# Idle: the last search is complete
|
||||
assert_equal 100, state.call[:progress]
|
||||
|
||||
# While a search is running, progress is not left at 100 from the
|
||||
# previous one, so it can tell a settled snapshot from a stale one
|
||||
Net::HTTP.post(uri, 'reload(sleep 1; seq 100)')
|
||||
tmux.until { assert_equal 0, state.call[:progress] }
|
||||
|
||||
# Settled: matches belong to the snapshot that reports 100
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
tmux.until do
|
||||
st = state.call
|
||||
assert_equal 100, st[:progress]
|
||||
assert_equal 100, st[:matchCount]
|
||||
end
|
||||
end
|
||||
|
||||
def test_listen_with_api_key
|
||||
uri = URI('http://localhost:6266')
|
||||
tmux.send_keys 'seq 10 | FZF_API_KEY=123abc fzf --listen 6266', :Enter
|
||||
|
||||
@@ -1273,43 +1273,6 @@ class TestNushell < TestBase
|
||||
FileUtils.rm_rf('/tmp/fzf-test')
|
||||
end
|
||||
|
||||
# Override: paths are inserted as Nushell string literals, so the
|
||||
# selections appear quoted on the command line.
|
||||
def test_ctrl_t
|
||||
set_var('FZF_CTRL_T_COMMAND', 'seq 100')
|
||||
|
||||
tmux.prepare
|
||||
tmux.send_keys 'C-t'
|
||||
tmux.until { |lines| assert_equal 100, lines.match_count }
|
||||
tmux.send_keys :Tab, :Tab, :Tab
|
||||
tmux.until { |lines| assert lines.any_include?(' (3)') }
|
||||
tmux.send_keys :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('"1" "2" "3"') }
|
||||
tmux.send_keys 'C-c'
|
||||
end
|
||||
|
||||
# A path is inserted as a Nushell string literal, so that syntax in a file
|
||||
# name is not evaluated and each path stays a single argument.
|
||||
def test_ctrl_t_quoting
|
||||
marker = "#{tempname}-marker"
|
||||
FileUtils.rm_f(marker)
|
||||
writelines(["fzf-inject$(touch #{marker}).txt", 'fzf-inject space.txt'])
|
||||
set_var('FZF_CTRL_T_COMMAND', "cat #{tempname}")
|
||||
|
||||
tmux.prepare
|
||||
tmux.send_keys '^printf "%s\n" ', 'C-t'
|
||||
tmux.until { |lines| assert_equal 2, lines.match_count }
|
||||
tmux.send_keys :Tab, :Tab
|
||||
tmux.until { |lines| assert_equal 2, lines.select_count }
|
||||
tmux.send_keys :Enter
|
||||
tmux.until { |lines| assert_includes lines[-1].to_s, '"fzf-inject$(touch' }
|
||||
tmux.send_keys :Enter
|
||||
tmux.until do |lines|
|
||||
assert_equal ["fzf-inject$(touch #{marker}).txt", 'fzf-inject space.txt'], lines[-2..]
|
||||
end
|
||||
refute_path_exists marker
|
||||
end
|
||||
|
||||
# Nushell does not support multiline command recall the same way
|
||||
# as bash/zsh/fish, so test_ctrl_r_multiline is omitted.
|
||||
|
||||
|
||||
@@ -102,36 +102,9 @@ Execute (fzf#run with dir option and autochdir when final cwd is same as dir):
|
||||
" Working directory changed due to &acd
|
||||
AssertEqual '/', getcwd()
|
||||
|
||||
Execute (Default layout):
|
||||
unlet! g:fzf_layout g:fzf_height
|
||||
let layout_keys = ['window', 'popup', 'tmux', 'up', 'down', 'left', 'right']
|
||||
|
||||
let opts = fzf#wrap('foobar')
|
||||
Log opts
|
||||
let found = filter(copy(layout_keys), 'has_key(opts, v:val)')
|
||||
AssertEqual 1, len(found)
|
||||
if found[0] ==# 'tmux'
|
||||
" Only where fzf opens a floating pane, which can be left while fzf runs
|
||||
Assert !empty($TMUX) || !empty($ZELLIJ)
|
||||
AssertEqual '90%,60%', opts.tmux
|
||||
elseif found[0] ==# 'window'
|
||||
AssertEqual 0.9, opts.window.width
|
||||
else
|
||||
" No popup support in this build
|
||||
AssertEqual '~40%', opts.down
|
||||
endif
|
||||
|
||||
" Fullscreen strips it, whichever it was
|
||||
let opts = fzf#wrap('foobar', {}, 1)
|
||||
Log opts
|
||||
AssertEqual [], filter(copy(layout_keys), 'has_key(opts, v:val)')
|
||||
|
||||
Execute (fzf#wrap):
|
||||
AssertThrows fzf#wrap({'foo': 'bar'})
|
||||
|
||||
" Pin the layout so the assertions do not depend on the environment
|
||||
let g:fzf_layout = { 'window': { 'width': 0.9, 'height': 0.6 } }
|
||||
|
||||
let opts = fzf#wrap('foobar')
|
||||
Log opts
|
||||
AssertEqual 0.9, opts.window.width
|
||||
|
||||
Reference in new issue
Block a user