Compare commits

..
22 Commits
Author SHA1 Message Date
Junegunn Choi 26ff936df0 Check walker ancestor symlinks against the current root only
- A symlink in one root was skipped when its target was an ancestor of
  another root, hiding files reachable only through it
- Fix and tests by @mabunemeh

Close #4944
2026-10-25 22:33:38 +09:00
Junegunn Choi b412a45053 Avoid O(n^2) string concatenation in maskActionContents
- `masked +=` per token copied the whole prefix on every append
- ~1 MB POST to --listen blocked the server for ~30 s
- switch to strings.Builder

Reported by Zachariah Mithani (@zachariah-mithani, AploSec).
2026-10-22 08:24:40 +09:00
Robby Russell ac28b8c3ba docs: add commit goods badge and utm tags to merch links 2026-10-21 20:00:06 +09:00
Junegunn ChoiandNavpreetSSidhu 2adb741c33 Respect --no-sort in non-streaming filter mode
- --sync, --tac, --bench disable streaming filter, and the result was sorted
- Matcher.scan already checks pattern.sortable

Problem analyzed and patch submitted by @NavpreetSSidhu

Fix #4940

Co-authored-by: NavpreetSSidhu <116211004+NavpreetSSidhu@users.noreply.github.com>
2026-10-21 15:05:38 +09:00
Junegunn Choi a38ab117c5 Add CHANGELOG entry for #4941 2026-10-21 15:05:17 +09:00
Junegunn Choi 903a4da7ab Avoid control characters in Neovim terminal buffer name
- With 'title', run command from temp script if it has control chars
- Raw ESC in buffer name leaked through title and drew garbage

Fix #4941
2026-10-21 14:47:16 +09:00
Junegunn Choi d3d6e40962 change-nth: empty value restores default --nth
- Same rule as change-with-nth and transform-with-nth
    - Empty: default
    - '..': all fields
    - Non-empty invalid: ignored
- Previously, change-nth() and empty transform-nth output searched
  all fields, and transform-nth could not restore default
- Partly reverts af0014ab. Use change-nth(..) to unset default
2026-10-21 08:29:43 +09:00
Junegunn Choi c943963281 change-(with-)nth: apply value after current one
- Find current value in bound list and apply next valid,
  non-current one
- Keep rotating bound list as a position hint, so repeated
  values in the list still reach every position
- Treat empty --nth and '..' as the same value
- Ignore invalid nth values, also in transform-nth, as with-nth
  does. Previously, they restored default --nth
- Previously, first value of rotated list was applied blindly
    - Key press spent on value already applied, e.g. first value
      equal to initial --nth
    - Stuck on change-nth printed by transform (new action on
      each run), as it always applied first value
- Add nextValue helper for reuse by change-layout
2026-10-20 16:19:06 +09:00
Junegunn Choi a0f6bac731 transform-(with-)nth: use whole output, no cycling
- Rotation of '|'-separated values applied to transform and
  bg-transform variants, where a.a is the command template
- Output '1|2' turned the binding into '2|1', run as a shell
  command on the next key press
- Output was also cut at '|', breaking --with-nth templates
  like '{2}|{1}'
- Split and rotate only for change-nth and change-with-nth
2026-10-20 14:09:55 +09:00
Simon Désaulniers 55c122f1a0 fix(nushell): adapt the completer to the 0.116 contract (#4934)
* fix(nushell): adapt the completer to the 0.116 contract

Nushell 0.116 runs an external completer on a background worker
without the terminal unless the registered closure reaches a
command carrying the @interactive attribute. Interactivity is
seen through the closure to the command it calls, so wrap the
completion logic in an @interactive entry point and register a
closure that ends with the call to it.

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

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

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

Fixes #4932

* fix(nushell): retry the previous completer with the legacy spans input

The fallback to the previously configured external completer always
handed it the new `place` input. A completer that still declares the
legacy `spans` parameter errors on a record, so the fallback silently
produced no completions for pre-0.116 completers (e.g. older Carapace
installs).

Try `place` first, then retry with the token list, which is what the
old positional bridge handed such completers.

* docs(nushell): note the 0.116 requirement and load order

Requires Nushell 0.116.1+, where a completion can take the terminal
through the @interactive attribute, and the wrapper captures the
external completer present at load time.
2026-10-17 11:10:52 +09:00
Daehyun KimandJunegunn Choi c8c2d0828a Fix Windows shell detection with explicit --with-shell
Close #4936

Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2026-10-15 10:31:52 +09:00
Junegunn Choi c0a193e031 0.74.5 2026-10-11 11:29:16 +09:00
Junegunn Choi cbc11e4d95 completion(zsh): use private mktemp dir for completion FIFO
FIFO path was predictable (${TMPDIR:-/tmp}/fzf-complete-fifo-$$) and
_fzf_feed_fifo ignored rm/mkfifo failures, so on a shared /tmp another
user could pre-create the path (symlink or FIFO) and have the victim's
redirect follow it.

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

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

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

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

Fix #4927
Fix junegunn/fzf.vim#1625

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

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

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

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

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

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

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

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

Fix #4920
2026-09-13 19:31:36 +09:00
32 changed files with 1485 additions and 173 deletions

No files matched your search

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