Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26ff936df0 | ||
|
|
b412a45053 | ||
|
|
ac28b8c3ba | ||
|
|
2adb741c33 | ||
|
|
a38ab117c5 | ||
|
|
903a4da7ab | ||
|
|
d3d6e40962 | ||
|
|
c943963281 | ||
|
|
a0f6bac731 | ||
|
|
55c122f1a0 | ||
|
|
c8c2d0828a |
No files matched your search
@@ -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
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
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)
|
||||
|
||||
@@ -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&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&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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -264,7 +264,6 @@ func Run(opts *Options) (int, error) {
|
||||
}
|
||||
|
||||
pattern := patternBuilder([]rune(*opts.Filter))
|
||||
matcher.sort = pattern.sortable
|
||||
|
||||
transformer := buildItemTransformer(opts)
|
||||
|
||||
|
||||
+7
-6
@@ -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, ':'}))
|
||||
|
||||
+18
-20
@@ -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,23 +316,21 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
isDir := de.IsDir() || isDirSymlink
|
||||
if isDir {
|
||||
base := filepath.Base(path)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(withNthExpr) == 0 {
|
||||
withNthExpr = t.withNthDefault
|
||||
if len(value) == 0 {
|
||||
return t.withNthDefault
|
||||
}
|
||||
return value
|
||||
}
|
||||
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)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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|
|
||||
|
||||
@@ -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|
|
||||
|
||||
Reference in new issue
Block a user