Compare commits

...
18 Commits
Author SHA1 Message Date
Junegunn Choi 14aacc115d Avoid second environment approval for msi job
- msi job used RELEASE_PAT only so that publishing would trigger
  winget.yml, which required approving the release environment again
- Publish with GITHUB_TOKEN instead and call winget.yml directly
- Skip winget for prerelease versions, as the released event did
2026-10-29 21:41:27 +09:00
Junegunn Choi 5853d57518 Add CHANGELOG entry for #4943 2026-10-26 20:29:14 +09:00
Junegunn Choi e19c2c4c6e Shorten MSI comments 2026-10-26 20:28:46 +09:00
Junegunn Choi 68ef9cf0d0 Skip MSI build for prerelease versions
- MSI versions cannot have a suffix such as -rc1, so wix build fails
- Release is still published
2026-10-26 20:27:47 +09:00
Junegunn Choi 243ccc325b Add MSIs to release checksum file 2026-10-26 20:27:22 +09:00
Junegunn Choi d7138593f2 Publish release only after MSI upload
- goreleaser creates a draft release
- msi job publishes it after uploading the MSIs
- Publishing triggers winget.yml, which previously ran before the MSIs
  existed and left them out of the manifest
- If the msi job fails, the release stays a draft
2026-10-26 20:26:50 +09:00
Marshall Porter 5b2936db82 Add WiX .msi installer alongside portable zip for Windows
winget currently only has a `portable` installer to offer for fzf, which
installs via a symlink into the WinGet Links folder. That link is created
by whoever runs winget, normally a non-elevated terminal, and Windows will
not let an elevated process follow a link that a less-privileged process
created ("The path cannot be traversed because it contains an untrusted
mount point"). An administrator's SSH session is elevated, so fzf, and the
shell integration that runs it at startup, fails there.

Build a real per-machine .msi with the WiX dotnet-tool CLI (this is a Go
project, so cargo-wix doesn't apply) from the goreleaser-built fzf.exe, for
amd64 and arm64 (WiX's -arch has no armv7 target). It installs to Program
Files with its own machine PATH entry, so there is no link. WiX only runs
on Windows, so the release job hands the Windows binaries to a new msi job
on windows-latest. The MSIs are built in both workflow modes, so a
workflow_dispatch validation run checks them, and uploaded alongside the
.zip on a tag push; the winget-releaser installers-regex also matches .msi.
WiX is pinned to 5.0.2, the last release that does not require accepting
its Open Source Maintenance Fee EULA.
2026-10-26 20:26:33 +09:00
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
20 changed files with 847 additions and 92 deletions

No files matched your search

+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
+88
View File
@@ -18,6 +18,8 @@ jobs:
release:
runs-on: macos-latest
environment: release
outputs:
version: ${{ steps.ver.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
@@ -74,3 +76,89 @@ jobs:
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
# For the msi job
- uses: actions/upload-artifact@v7
with:
name: windows-binaries
path: |
dist/*windows_amd64*/fzf.exe
dist/*windows_arm64*/fzf.exe
if-no-files-found: error
# winget's portable install runs fzf through a symlink that admin SSH
# sessions refuse to follow (#4943). The MSI installs a real file instead.
# WiX only runs on Windows.
msi:
needs: release
runs-on: windows-latest
env:
# MSI versions cannot have a suffix such as -rc1
BUILD_MSI: ${{ !contains(needs.release.outputs.version, '-') }}
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
if: env.BUILD_MSI == 'true'
with:
name: windows-binaries
path: dist
- uses: actions/setup-dotnet@v6
if: env.BUILD_MSI == 'true'
with:
dotnet-version: '8.0.x'
# WiX 6 and later fall under the Open Source Maintenance Fee
- name: Install WiX
if: env.BUILD_MSI == 'true'
run: dotnet tool install --global wix --version 5.0.2
- name: Build Windows MSI installers
if: env.BUILD_MSI == 'true'
shell: bash
run: |
set -e
V='${{ needs.release.outputs.version }}'
for pair in "amd64:x64" "arm64:arm64"; do
goarch="${pair%%:*}"
wixarch="${pair##*:}"
bindir=$(dirname "$(find dist -type f -iname fzf.exe -path "*windows_${goarch}*" | head -n1)")
wix build wix/main.wxs -arch "$wixarch" -d Version="$V" -d BinDir="$bindir" -o "dist/fzf-${V}-windows_${goarch}.msi"
done
ls -la dist/*.msi
- uses: actions/upload-artifact@v7
if: env.BUILD_MSI == 'true'
with:
name: windows-msi
path: dist/*.msi
- name: Upload MSI installers and publish release
if: github.event_name == 'push'
shell: bash
run: |
set -e
V='${{ needs.release.outputs.version }}'
if [[ $BUILD_MSI == true ]]; then
sums="fzf_${V}_checksums.txt"
# Drop MSI lines from a previous run of this job
gh release download "v$V" -p "$sums" -O - | grep -v '\.msi$' > "dist/$sums"
# sha256sum on Windows may print '<hash> *<name>'
(cd dist && sha256sum fzf-*.msi) | sed 's/ \*/ /' >> "dist/$sums"
gh release upload "v$V" dist/*.msi "dist/$sums" --clobber
fi
gh release edit "v$V" --draft=false
env:
GITHUB_TOKEN: ${{ github.token }}
# Called directly because a release published with GITHUB_TOKEN does not
# trigger other workflows
winget:
needs: [release, msi]
if: github.event_name == 'push' && !contains(needs.release.outputs.version, '-')
uses: ./.github/workflows/winget.yml
with:
release-tag: v${{ needs.release.outputs.version }}
secrets:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
+10 -4
View File
@@ -1,7 +1,13 @@
name: Publish to Winget
on:
release:
types: [released]
workflow_call:
inputs:
release-tag:
type: string
required: true
secrets:
WINGET_TOKEN:
required: true
workflow_dispatch:
inputs:
release-tag:
@@ -16,6 +22,6 @@ jobs:
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
identifier: junegunn.fzf
release-tag: ${{ inputs.release-tag || github.event.release.tag_name }}
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
release-tag: ${{ inputs.release-tag }}
installers-regex: '-windows_(armv7|arm64|amd64)\.(zip|msi)$'
token: ${{ secrets.WINGET_TOKEN }}
+2
View File
@@ -120,6 +120,8 @@ release:
owner: junegunn
name: fzf
prerelease: auto
# Published by the msi job in release.yml after it uploads the MSIs
draft: true
name_template: '{{ .Version }}'
snapshot:
+33
View File
@@ -1,6 +1,39 @@
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)
- Windows: Added `.msi` installers for amd64 and arm64, also offered through winget (#4943) (@meop)
- The portable package runs fzf through a symlink in the WinGet Links folder, which an administrator's SSH session refuses to follow
- The MSI installs `fzf.exe` under Program Files and adds it to the system PATH
- 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)
+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:
----------------
+26 -4
View File
@@ -2060,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)
@@ -2166,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)
@@ -2405,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.
-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)
}
}
+78 -21
View File
@@ -6304,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/"))
@@ -7304,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()
}
})
@@ -7328,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)
}
}
+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)
}
+121 -1
View File
@@ -2342,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',
@@ -2374,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).
@@ -2926,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
@@ -2940,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|
+43
View File
@@ -0,0 +1,43 @@
<?xml version='1.0' encoding='utf-8'?>
<!--
Per-machine installer for fzf. See the msi job in .github/workflows/release.yml.
wix build main.wxs -arch <x64|arm64> -d Version=<ver> -d BinDir=<dir of fzf.exe>
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="fzf"
Manufacturer="junegunn"
Version="$(var.Version)"
UpgradeCode="C34C8C24-3E05-4D14-BD9A-439C3F6E9942"
Language="1033"
Scope="perMachine">
<SummaryInformation Description="fzf - a command-line fuzzy finder" Manufacturer="junegunn" />
<MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is already installed. Setup will now exit." />
<MediaTemplate EmbedCab="yes" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="APPLICATIONFOLDER" Name="fzf">
<Directory Id="BinFolder" Name="bin" />
</Directory>
</StandardDirectory>
<Feature Id="Main" Title="fzf" Level="1" AllowAbsent="no">
<ComponentGroupRef Id="MainComponents" />
</Feature>
<ComponentGroup Id="MainComponents" Directory="BinFolder">
<Component Id="FzfBinary">
<File Id="FzfExe" Name="fzf.exe" Source="$(var.BinDir)\fzf.exe" KeyPath="yes" />
</Component>
<Component Id="LicenseFile" Directory="APPLICATIONFOLDER">
<File Id="License" Name="LICENSE" Source="LICENSE" KeyPath="yes" />
</Component>
<Component Id="PathEnv" Guid="8B7C2C6A-F4A0-4E3E-9E37-2E8F3E4E5B6A">
<Environment Id="PATH" Name="PATH" Value="[BinFolder]" Permanent="no" Part="last" Action="set" System="yes" />
</Component>
</ComponentGroup>
</Package>
</Wix>