Compare commits
16
Commits
v0.74.4
...
nth-cycling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3d6e40962 | ||
|
|
c943963281 | ||
|
|
a0f6bac731 | ||
|
|
55c122f1a0 | ||
|
|
c8c2d0828a | ||
|
|
c0a193e031 | ||
|
|
cbc11e4d95 | ||
|
|
f126ff2954 | ||
|
|
d8aeec3058 | ||
|
|
981f1539b0 | ||
|
|
25adb67453 | ||
|
|
33a3456921 | ||
|
|
b563e208d6 | ||
|
|
c009c106b9 | ||
|
|
b1be3a8be1 | ||
|
|
961793cf39 |
No files matched your search
@@ -3,10 +3,6 @@ name: Issue Template
|
||||
description: Report a problem or bug related to fzf to help us improve
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: ISSUES NOT FOLLOWING THIS TEMPLATE WILL BE CLOSED AND DELETED
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Checklist
|
||||
@@ -32,7 +28,6 @@ body:
|
||||
- label: Linux
|
||||
- label: macOS
|
||||
- label: Windows
|
||||
- label: Etc.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
@@ -41,9 +36,23 @@ body:
|
||||
- label: bash
|
||||
- label: zsh
|
||||
- label: fish
|
||||
- label: nushell
|
||||
- label: PowerShell
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem / Steps to reproduce
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: How did you run into this?
|
||||
description: |
|
||||
What were you actually trying to do? Include the command line or
|
||||
configuration from your real setup.
|
||||
|
||||
If you did not hit this in actual use, say how you found it
|
||||
(reading the code, automated analysis, etc.).
|
||||
validations:
|
||||
required: true
|
||||
@@ -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,47 @@
|
||||
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)
|
||||
|
||||
0.74.5
|
||||
------
|
||||
- Fixed `--popup` unzooming the window when opened over a zoomed pane on tmux 3.8 or above (#4927, junegunn/fzf.vim#1625) (@jereviikari)
|
||||
- On tmux 3.7, the window is still unzoomed, as tmux 3.7b crashes when a floating pane is created over a zoomed window
|
||||
- Fixed `--gap-line` cutting a grapheme cluster when filling the last cells of the line (#4920)
|
||||
- Fixed an escape sequence fzf does not recognize being partly consumed, which leaked the rest into the query (#4926)
|
||||
- Fixed a key lost when it arrived in the same read as an escape sequence
|
||||
- Fixed `focus` event not triggered after `reload` when the new item is at the same position as the previous one (#4930)
|
||||
- With `--id-nth`, the event is not triggered if the focus moves directly from an item to another item with the same key
|
||||
- A `focus` binding that runs `reload` now triggers itself again after each reload, unless `--id-nth` is set and the key does not change
|
||||
- Security fixes
|
||||
- fzf-tmux no longer evaluates an attached layout size (e.g. `-l50`) via bash arithmetic, which could run commands or inject tmux flags (GHSA-w6x2-f5m4-xw82)
|
||||
- zsh completion now creates its FIFO in a private `mktemp -d` directory instead of a predictable `/tmp` path (GHSA-7rj6-7pqx-v7rm)
|
||||
- Reported by Sai Sreewathsa Kovalluri, Shon Babu, and Kannan K (Researchers) of Innspark Solutions
|
||||
|
||||
0.74.4
|
||||
------
|
||||
- Fixed an escape sequence split across reads being parsed as a fragment, which leaked the rest into the query (#4899)
|
||||
|
||||
@@ -94,8 +94,12 @@ test: $(SOURCES)
|
||||
github.com/junegunn/fzf/src/tui \
|
||||
github.com/junegunn/fzf/src/util
|
||||
|
||||
# Run in a separate tmux server without user config, like CI (80x24)
|
||||
itest:
|
||||
ruby test/runner.rb
|
||||
@dir=$$(mktemp -d) && \
|
||||
env -u TMUX TMUX_TMPDIR=$$dir sh -c \
|
||||
'tmux -f /dev/null new-session -d && ruby test/runner.rb; s=$$?; tmux kill-server; exit $$s'; \
|
||||
s=$$?; rm -rf $$dir; exit $$s
|
||||
|
||||
# Actively fuzz the matcher fast paths against the general algorithm.
|
||||
# Go fuzzes one target at a time, so iterate. Override duration with
|
||||
|
||||
@@ -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.
|
||||
|
||||
+9
-5
@@ -7,7 +7,11 @@ triggered by a tag push.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Update version in the following files and commit on `master`:
|
||||
1. Sign in at https://developer.apple.com/account and accept any pending
|
||||
agreements (e.g. an updated Apple Developer Program License Agreement).
|
||||
Otherwise notarization fails.
|
||||
|
||||
2. Update version in the following files and commit on `master`:
|
||||
- `CHANGELOG.md`
|
||||
- `main.go`
|
||||
- `install`
|
||||
@@ -15,10 +19,10 @@ triggered by a tag push.
|
||||
- `man/man1/fzf.1`
|
||||
- `man/man1/fzf-tmux.1`
|
||||
|
||||
2. Verify file consistency, sign the tag, and push the tag.
|
||||
3. Verify file consistency, sign the tag, and push the tag.
|
||||
|
||||
```sh
|
||||
make tag VERSION=0.74.4
|
||||
make tag VERSION=0.74.5
|
||||
```
|
||||
|
||||
`make tag` runs `prerelease` first (checks that the version
|
||||
@@ -29,10 +33,10 @@ triggered by a tag push.
|
||||
old version, so `/master/install` keeps resolving against existing
|
||||
binaries during the publish window.
|
||||
|
||||
3. The workflow fires on the tag push and pauses on the `release`
|
||||
4. The workflow fires on the tag push and pauses on the `release`
|
||||
environment gate. Approve it in the Actions tab to release.
|
||||
|
||||
4. After the GitHub release is published, fast-forward `master`:
|
||||
5. After the GitHub release is published, fast-forward `master`:
|
||||
|
||||
```sh
|
||||
git push origin master
|
||||
|
||||
@@ -78,6 +78,7 @@ while [[ $# -gt 0 ]]; do
|
||||
fi
|
||||
if [[ ${#arg} -gt 2 ]]; then
|
||||
size="${arg:2}"
|
||||
[[ $size =~ ^[0-9%,]+$ || $size =~ ^[A-Z]$ ]] || continue
|
||||
else
|
||||
if [[ $1 =~ ^[0-9%,]+$ ]] || [[ $1 =~ ^[A-Z]$ ]]; then
|
||||
size="$1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -u
|
||||
|
||||
version=0.74.4
|
||||
version=0.74.5
|
||||
auto_completion=
|
||||
key_bindings=
|
||||
update_config=2
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
$version="0.74.4"
|
||||
$version="0.74.5"
|
||||
|
||||
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
..
|
||||
.TH fzf\-tmux 1 "Sep 2026" "fzf 0.74.4" "fzf\-tmux - open fzf in tmux split pane"
|
||||
.TH fzf\-tmux 1 "Oct 2026" "fzf 0.74.5" "fzf\-tmux - open fzf in tmux split pane"
|
||||
|
||||
.SH NAME
|
||||
fzf\-tmux - open fzf in tmux split pane
|
||||
|
||||
+31
-6
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
..
|
||||
.TH fzf 1 "Sep 2026" "fzf 0.74.4" "fzf - a command-line fuzzy finder"
|
||||
.TH fzf 1 "Oct 2026" "fzf 0.74.5" "fzf - a command-line fuzzy finder"
|
||||
|
||||
.SH NAME
|
||||
fzf - a command-line fuzzy finder
|
||||
@@ -1909,7 +1909,10 @@ e.g.
|
||||
\fIfocus\fR
|
||||
.RS
|
||||
Triggered when the focus changes due to a vertical cursor movement or a search
|
||||
result update.
|
||||
result update. After a reload, the focused item is considered new unless
|
||||
\fB\-\-id\-nth\fR is set and the item has the same key as the previously
|
||||
focused item. Because of this, binding \fBreload\fR to this event can cause an
|
||||
infinite loop.
|
||||
|
||||
e.g.
|
||||
\fBfzf \-\-bind 'focus:transform\-preview\-label:echo [ {} ]' \-\-preview 'cat {}'
|
||||
@@ -2057,8 +2060,8 @@ A key or an event can be bound to one or more of the following actions.
|
||||
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
|
||||
\fBchange\-multi\fR (enable multi-select mode with no limit)
|
||||
\fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0)
|
||||
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|')
|
||||
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; rotate through the multiple options separated by '|')
|
||||
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option)
|
||||
\fBchange\-preview(...)\fR (change \fB\-\-preview\fR option)
|
||||
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
|
||||
@@ -2163,8 +2166,8 @@ A key or an event can be bound to one or more of the following actions.
|
||||
\fBtransform\-header\-label(...)\fR (transform header label using an external command)
|
||||
\fBtransform\-input\-label(...)\fR (transform input label using an external command)
|
||||
\fBtransform\-list\-label(...)\fR (transform list label using an external command)
|
||||
\fBtransform\-nth(...)\fR (transform nth using an external command)
|
||||
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command)
|
||||
\fBtransform\-nth(...)\fR (transform nth using an external command; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command; see CHANGE NTH AND WITH\-NTH)
|
||||
\fBtransform\-pointer(...)\fR (transform pointer using an external command)
|
||||
\fBtransform\-preview\-label(...)\fR (transform preview label using an external command)
|
||||
\fBtransform\-prompt(...)\fR (transform prompt string using an external command)
|
||||
@@ -2402,6 +2405,28 @@ e.g.
|
||||
# This is equivalent to toggle\-preview action
|
||||
fzf \-\-preview 'cat {}' \-\-bind 'ctrl\-/:change\-preview\-window(hidden|)'
|
||||
|
||||
.SS CHANGE NTH AND WITH\-NTH
|
||||
|
||||
\fBchange\-nth\fR and \fBchange\-with\-nth\fR actions change \fB\-\-nth\fR and
|
||||
\fB\-\-with\-nth\fR options. You can specify multiple values separated by '|'
|
||||
characters. Each time the action runs, fzf applies the value after the current
|
||||
one in the list, skipping the values equal to the current one.
|
||||
|
||||
\fBtransform\-nth\fR and \fBtransform\-with\-nth\fR actions (and their
|
||||
\fBbg\-\fR variants) take the output of the command as a single value. The
|
||||
following rules apply to all of these actions.
|
||||
|
||||
* An empty value restores the default given by the option
|
||||
* \fB..\fR selects all fields
|
||||
* A non\-empty invalid value is ignored
|
||||
|
||||
e.g.
|
||||
# All fields, then fields 1, 2, and 3
|
||||
fzf \-\-bind 'ctrl\-/:change\-nth(..|1|2|3)'
|
||||
|
||||
# An empty string in the list is interpreted as the default
|
||||
fzf \-\-nth 1 \-\-bind 'ctrl\-/:change\-nth(2|)'
|
||||
|
||||
.SH AUTHOR
|
||||
Junegunn Choi (\fIjunegunn.c@gmail.com\fR)
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
@@ -215,7 +215,7 @@ _fzf_dir_completion() {
|
||||
|
||||
_fzf_feed_fifo() {
|
||||
command rm -f "$1"
|
||||
mkfifo "$1"
|
||||
command mkfifo "$1" || return
|
||||
cat <&0 > "$1" &|
|
||||
}
|
||||
|
||||
@@ -242,8 +242,9 @@ _fzf_complete() {
|
||||
rest=("$@")
|
||||
fi
|
||||
|
||||
local fifo lbuf matches post
|
||||
fifo="${TMPDIR:-/tmp}/fzf-complete-fifo-$$"
|
||||
local fifo fifo_dir lbuf matches post
|
||||
fifo_dir=$(command mktemp -d "${TMPDIR:-/tmp}/fzf-completion-XXXXXX") || return
|
||||
fifo="$fifo_dir/fifo"
|
||||
lbuf=${rest[0]}
|
||||
post="${funcstack[1]}_post"
|
||||
type $post > /dev/null 2>&1 || post=cat
|
||||
@@ -256,7 +257,7 @@ _fzf_complete() {
|
||||
if [ -n "$matches" ]; then
|
||||
LBUFFER="$lbuf$matches"
|
||||
fi
|
||||
command rm -f "$fifo"
|
||||
command rm -rf "$fifo_dir"
|
||||
}
|
||||
|
||||
# To use custom hostname lists, override __fzf_list_hosts.
|
||||
|
||||
+111
-28
@@ -467,7 +467,7 @@ type Terminal struct {
|
||||
termSize tui.TermSize
|
||||
lastAction actionType
|
||||
lastKey string
|
||||
lastFocus int32
|
||||
lastFocus focusedItem
|
||||
areaLines int
|
||||
areaColumns int
|
||||
forcePreview bool
|
||||
@@ -492,6 +492,11 @@ type selectedItem struct {
|
||||
item *Item
|
||||
}
|
||||
|
||||
type focusedItem struct {
|
||||
major int // revision.major; changes on reload
|
||||
item *Item
|
||||
}
|
||||
|
||||
type byTimeOrder []selectedItem
|
||||
|
||||
func (a byTimeOrder) Len() int {
|
||||
@@ -1186,7 +1191,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
|
||||
initFunc: func() error { return renderer.Init() },
|
||||
executing: util.NewAtomicBool(false),
|
||||
lastAction: actStart,
|
||||
lastFocus: minItem.Index(),
|
||||
lastFocus: focusedItem{},
|
||||
lastActivity: time.Now(),
|
||||
numLinesCache: make(map[int32]numLinesCacheValue),
|
||||
// The initial load counts as a search in progress ('start:wait').
|
||||
@@ -6066,6 +6071,26 @@ func (t *Terminal) currentIndex() int32 {
|
||||
return minItem.Index()
|
||||
}
|
||||
|
||||
func (t *Terminal) currentFocus() focusedItem {
|
||||
return focusedItem{t.revision.major, t.currentItem()}
|
||||
}
|
||||
|
||||
// Records the current focus and returns whether it differs from the last one.
|
||||
// Indexes restart after a reload, so items are compared by --id-nth key, if
|
||||
// set. Without it, any focused item after a reload is a new one.
|
||||
func (t *Terminal) updateFocus() bool {
|
||||
curr := t.currentFocus()
|
||||
last := t.lastFocus
|
||||
t.lastFocus = curr
|
||||
if curr.item == nil || last.item == nil {
|
||||
return curr.item != last.item
|
||||
}
|
||||
if curr.major == last.major {
|
||||
return curr.item.Index() != last.item.Index()
|
||||
}
|
||||
return len(t.idNth) == 0 || t.trackKeyFor(curr.item, t.idNth) != t.trackKeyFor(last.item, t.idNth)
|
||||
}
|
||||
|
||||
func (t *Terminal) trackKeyFor(item *Item, nth []Range) string {
|
||||
tokens := Tokenize(item.AsString(t.ansi), t.delimiter)
|
||||
return StripLastDelimiter(JoinTokens(Transform(tokens, nth)), t.delimiter)
|
||||
@@ -6279,6 +6304,43 @@ func (t *Terminal) startTimers(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// nextValue returns the first valid, non-current value after the current one
|
||||
// in a '|'-separated list, and the list rotated to end with it.
|
||||
// isValid can be nil.
|
||||
func nextValue(expr string, isCurrent func(string) bool, isValid func(string) bool) (string, string) {
|
||||
valid := func(value string) bool {
|
||||
return isValid == nil || isValid(value)
|
||||
}
|
||||
// An invalid value is never current, even if it resolves to the current one
|
||||
current := func(value string) bool {
|
||||
return valid(value) && isCurrent(value)
|
||||
}
|
||||
values := strings.Split(expr, "|")
|
||||
start := 0
|
||||
// Last value is the one applied by the previous call
|
||||
if !current(values[len(values)-1]) {
|
||||
for i, value := range values {
|
||||
if current(value) {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range values {
|
||||
pick := (start + i) % len(values)
|
||||
if value := values[pick]; valid(value) && !current(value) {
|
||||
return value, strings.Join(slices.Concat(values[pick+1:], values[:pick+1]), "|")
|
||||
}
|
||||
}
|
||||
// Nothing to pick
|
||||
for _, value := range values {
|
||||
if valid(value) {
|
||||
return value, expr
|
||||
}
|
||||
}
|
||||
return values[0], expr
|
||||
}
|
||||
|
||||
// Loop is called to start Terminal I/O
|
||||
func (t *Terminal) Loop() error {
|
||||
// prof := profile.Start(profile.ProfilePath("/tmp/"))
|
||||
@@ -6631,8 +6693,7 @@ func (t *Terminal) Loop() error {
|
||||
info = true
|
||||
}
|
||||
focusChanged := focusedIndex != currentIndex
|
||||
if (t.hasFocusActions || t.infoCommand != "") && focusChanged && currentIndex != t.lastFocus {
|
||||
t.lastFocus = currentIndex
|
||||
if (t.hasFocusActions || t.infoCommand != "") && !t.trackBlocked && t.updateFocus() {
|
||||
t.eventChan <- tui.Focus.AsEvent()
|
||||
if t.infoCommand != "" {
|
||||
info = true
|
||||
@@ -7017,9 +7078,11 @@ func (t *Terminal) Loop() error {
|
||||
}
|
||||
}
|
||||
|
||||
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents {
|
||||
if newIndex := t.currentIndex(); newIndex != currentIndex {
|
||||
t.lastFocus = newIndex
|
||||
// Actions are ignored while blocked. The render loop will
|
||||
// report the focus after the reload.
|
||||
if onFocus, prs := t.keymap[tui.Focus.AsEvent()]; prs && iter < maxFocusEvents && !t.trackBlocked {
|
||||
if t.currentIndex() != currentIndex {
|
||||
t.lastFocus = t.currentFocus()
|
||||
if t.infoCommand != "" {
|
||||
req(reqInfo)
|
||||
}
|
||||
@@ -7278,22 +7341,35 @@ func (t *Terminal) Loop() error {
|
||||
req(reqList, reqInfo)
|
||||
case actChangeNth, actTransformNth, actBgTransformNth:
|
||||
capture(true, func(expr string) {
|
||||
// Split nth expression
|
||||
tokens := strings.Split(expr, "|")
|
||||
if nth, err := splitNth(tokens[0]); err == nil || len(expr) == 0 {
|
||||
// Changed
|
||||
resolve := func(value string) ([]Range, bool) {
|
||||
// Empty value restores the default --nth
|
||||
if len(value) == 0 {
|
||||
return t.nth, true
|
||||
}
|
||||
nth, err := splitNth(value)
|
||||
return nth, err == nil
|
||||
}
|
||||
// Empty range list or a single '..' means all fields
|
||||
allFields := func(nth []Range) bool {
|
||||
return len(nth) == 0 || len(nth) == 1 && nth[0].IsFull()
|
||||
}
|
||||
isCurrent := func(nth []Range) bool {
|
||||
return compareRanges(t.nthCurrent, nth) || allFields(t.nthCurrent) && allFields(nth)
|
||||
}
|
||||
// Only change-nth cycles through '|'-separated values
|
||||
if a.t == actChangeNth {
|
||||
expr, a.a = nextValue(expr, func(value string) bool {
|
||||
nth, _ := resolve(value)
|
||||
return isCurrent(nth)
|
||||
}, func(value string) bool {
|
||||
_, ok := resolve(value)
|
||||
return ok
|
||||
})
|
||||
}
|
||||
if nth, ok := resolve(expr); ok && !isCurrent(nth) {
|
||||
newNth = &nth
|
||||
} else {
|
||||
// The default
|
||||
newNth = &t.nth
|
||||
}
|
||||
// Cycle
|
||||
if len(tokens) > 1 {
|
||||
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
|
||||
}
|
||||
if !compareRanges(t.nthCurrent, *newNth) {
|
||||
changed = true
|
||||
t.nthCurrent = *newNth
|
||||
t.nthCurrent = nth
|
||||
t.forceRerenderList()
|
||||
}
|
||||
})
|
||||
@@ -7302,15 +7378,22 @@ func (t *Terminal) Loop() error {
|
||||
break Action
|
||||
}
|
||||
capture(true, func(expr string) {
|
||||
tokens := strings.Split(expr, "|")
|
||||
withNthExpr := tokens[0]
|
||||
if len(tokens) > 1 {
|
||||
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
|
||||
resolve := func(value string) string {
|
||||
// Empty value restores the default --with-nth
|
||||
if len(value) == 0 {
|
||||
return t.withNthDefault
|
||||
}
|
||||
return value
|
||||
}
|
||||
// Empty value restores the default --with-nth
|
||||
if len(withNthExpr) == 0 {
|
||||
withNthExpr = t.withNthDefault
|
||||
if a.t == actChangeWithNth {
|
||||
expr, a.a = nextValue(expr, func(value string) bool {
|
||||
return resolve(value) == t.withNthExpr
|
||||
}, func(value string) bool {
|
||||
_, err := nthTransformer(resolve(value))
|
||||
return err == nil
|
||||
})
|
||||
}
|
||||
withNthExpr := resolve(expr)
|
||||
if withNthExpr != t.withNthExpr {
|
||||
if factory, err := nthTransformer(withNthExpr); err == nil {
|
||||
newWithNth = &withNthSpec{fn: factory(t.delimiter)}
|
||||
|
||||
@@ -927,3 +927,56 @@ func TestSplitOnIND(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
current string
|
||||
want string
|
||||
rotated string
|
||||
}{
|
||||
{"a", "x", "a", "a"},
|
||||
{"a", "a", "a", "a"},
|
||||
{"a|b|c", "x", "a", "b|c|a"},
|
||||
{"a|b|c", "a", "b", "c|a|b"},
|
||||
{"a|b|c", "b", "c", "a|b|c"},
|
||||
{"a|b|c", "c", "a", "b|c|a"},
|
||||
{"a|a|b", "a", "b", "a|a|b"},
|
||||
{"a|b|b", "b", "a", "b|b|a"},
|
||||
{"a|a", "a", "a", "a|a"},
|
||||
{"a|b|", "", "a", "b||a"},
|
||||
{"|a", "", "a", "|a"},
|
||||
{"a|bad|b", "a", "b", "a|bad|b"},
|
||||
{"bad|a", "x", "a", "bad|a"},
|
||||
{"a|bad", "a", "a", "a|bad"},
|
||||
// An invalid value is never current
|
||||
{"a|bad|b", "bad", "a", "bad|b|a"},
|
||||
// Nothing to pick: the first valid value
|
||||
{"bad|a", "a", "a", "bad|a"},
|
||||
{"bad", "x", "bad", "bad"},
|
||||
// The last value is checked first
|
||||
{"b|a|c|a", "a", "b", "a|c|a|b"},
|
||||
}
|
||||
isValid := func(v string) bool { return v != "bad" }
|
||||
for _, tc := range tests {
|
||||
got, rotated := nextValue(tc.expr, func(v string) bool { return v == tc.current }, isValid)
|
||||
if got != tc.want || rotated != tc.rotated {
|
||||
t.Errorf("nextValue(%q) with current %q: got (%q, %q), want (%q, %q)",
|
||||
tc.expr, tc.current, got, rotated, tc.want, tc.rotated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextValueRepeated(t *testing.T) {
|
||||
// Presses with a rotated list reach every position, even with a repeated value
|
||||
expr := "1|2|1|3"
|
||||
current := "1"
|
||||
got := []string{}
|
||||
for range 6 {
|
||||
current, expr = nextValue(expr, func(v string) bool { return v == current }, nil)
|
||||
got = append(got, current)
|
||||
}
|
||||
if want := "2 1 3 1 2 1"; strings.Join(got, " ") != want {
|
||||
t.Errorf("got %q, want %q", strings.Join(got, " "), want)
|
||||
}
|
||||
}
|
||||
+23
-16
@@ -5,14 +5,19 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/junegunn/fzf/src/tui"
|
||||
)
|
||||
|
||||
// The flag group of new-pane usage includes -A (tmux 3.8 or above)
|
||||
var tmuxOverZoomFlag = regexp.MustCompile(`new-pane.*\[-[[:alpha:]]*A`)
|
||||
|
||||
// Returns the size of the current window if the tmux server supports
|
||||
// floating panes (tmux 3.7 or above)
|
||||
func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
// floating panes (tmux 3.7 or above), and whether a floating pane can stay
|
||||
// above a zoomed pane
|
||||
func tmuxFloatingPaneInfo() (int, int, bool, bool) {
|
||||
// TMUX_PANE is not set when fzf is not started from within a pane
|
||||
// (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating
|
||||
// pane there; a blocking run-shell suspends key processing for the
|
||||
@@ -25,7 +30,7 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
// https://github.com/tmux/tmux/issues/5384
|
||||
target := os.Getenv("TMUX_PANE")
|
||||
if target == "" {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
// A single invocation for both checks. Cannot rely on the exit status;
|
||||
// tmux versions before 3.7 exit normally with empty output for an
|
||||
@@ -33,17 +38,17 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
|
||||
out, err := exec.Command("tmux", "display-message", "-p", "-t", target,
|
||||
"#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output()
|
||||
if err != nil || !strings.Contains(string(out), "new-pane") {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
var width, height int
|
||||
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
// The window is too small to fit a floating pane of the minimum size
|
||||
if width < 3 || height < 3 {
|
||||
return 0, 0, false
|
||||
return 0, 0, false, false
|
||||
}
|
||||
return width, height, true
|
||||
return width, height, tmuxOverZoomFlag.Match(out), true
|
||||
}
|
||||
|
||||
// tmux ends a command at an argument ending in ';', so a trailing one is
|
||||
@@ -85,7 +90,7 @@ func tmuxDim(spec sizeSpec, window int) int {
|
||||
return max(3, min(dim, window))
|
||||
}
|
||||
|
||||
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
|
||||
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, overZoom bool, opts *Options) (int, error) {
|
||||
// Unlike display-popup, the size of a floating pane does not account for
|
||||
// the border around it, and the position is that of the content area. To
|
||||
// stay consistent with popups, treat the requested size as the total
|
||||
@@ -166,13 +171,15 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh
|
||||
}
|
||||
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
|
||||
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
|
||||
// Unzoom the window first; creating a floating pane over a zoomed
|
||||
// window crashes the tmux server on 3.7b, and newer versions of
|
||||
// tmux unzoom the window anyway.
|
||||
target := os.Getenv("TMUX_PANE")
|
||||
newPane := fmt.Sprintf(
|
||||
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
|
||||
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
|
||||
newPane := "tmux new-pane -A"
|
||||
if !overZoom {
|
||||
// Unzoom the window first; creating a floating pane over a
|
||||
// zoomed window crashes the tmux server on 3.7b
|
||||
newPane = fmt.Sprintf("tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane",
|
||||
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target))
|
||||
}
|
||||
newPane += fmt.Sprintf(" -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
|
||||
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
|
||||
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
|
||||
|
||||
@@ -222,10 +229,10 @@ func runTmux(args []string, opts *Options) (int, error) {
|
||||
// popup is used instead so that the fzf-drawn border is the only border
|
||||
// shown; the native border of a tmux floating pane cannot be removed.
|
||||
if nativeBorder(opts) {
|
||||
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok {
|
||||
if windowWidth, windowHeight, overZoom, ok := tmuxFloatingPaneInfo(); ok {
|
||||
opts.Tmux.border = true
|
||||
argStr, dir := popupArgStr(args, opts)
|
||||
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
|
||||
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, overZoom, opts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+214
-34
@@ -348,9 +348,125 @@ func getEnv(name string, defaultValue int) int {
|
||||
func csiContinues(b byte) bool { return b >= 0x20 && b <= 0x3f }
|
||||
func csiFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
|
||||
|
||||
// incompleteEscape reports whether the buffer ends in an escape sequence that
|
||||
// has not been terminated yet. The read loop keeps waiting in that case, so the
|
||||
// parser is never handed a fragment to guess at.
|
||||
// csiEnd returns the length of the CSI or SS3 sequence at the start of the
|
||||
// buffer, 0 if it has no final byte yet, or -1 if it is malformed.
|
||||
func csiEnd(buffer []byte) int {
|
||||
digits := true
|
||||
for i := 2; i < len(buffer); i++ {
|
||||
b := buffer[i]
|
||||
// rxvt ends keys with $ after at least one digit, as in \e[7$ for
|
||||
// SHIFT-HOME and \e[23$ for SHIFT-F11. Elsewhere $ is an intermediate byte.
|
||||
if csiFinal(b) || b == '$' && digits && i > 2 {
|
||||
return i + 1
|
||||
}
|
||||
if !csiContinues(b) {
|
||||
return -1
|
||||
}
|
||||
digits = digits && b >= '0' && b <= '9'
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// stringEnd returns the length of the string sequence (DCS, OSC or APC) at the
|
||||
// start of the buffer, or 0 while nothing has ended it. A terminator is
|
||||
// included, an ESC that introduces another sequence is not.
|
||||
func stringEnd(buffer []byte) int {
|
||||
if len(buffer) < 2 {
|
||||
return 0
|
||||
}
|
||||
// Every one of them ends with ST. BEL ends an OSC as well, because xterm has
|
||||
// always allowed it, but stopping at a BEL inside a DCS or APC payload would
|
||||
// frame only its first half and leave the rest to be typed into the query.
|
||||
bel := buffer[1] == ']'
|
||||
for i := 2; i < len(buffer); i++ {
|
||||
switch buffer[i] {
|
||||
case '\a':
|
||||
if bel {
|
||||
return i + 1
|
||||
}
|
||||
case Esc.Byte():
|
||||
if i+1 == len(buffer) {
|
||||
return 0 // ST may still be arriving
|
||||
}
|
||||
if buffer[i+1] == '\\' {
|
||||
return i + 2
|
||||
}
|
||||
// Any other ESC ends the string and introduces a sequence of its
|
||||
// own, so frame only what precedes it and leave the ESC to be
|
||||
// parsed again. Scanning past it would swallow that sequence too.
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// rxvtKeyEnd reports whether the byte ends a key such as \e[7~ for Home.
|
||||
// rxvt marks modifiers with the final byte: $ for SHIFT, ^ for CTRL, @ for both.
|
||||
func rxvtKeyEnd(b byte) bool {
|
||||
return b == '~' || b == '$' || b == '^' || b == '@'
|
||||
}
|
||||
|
||||
// csiIntroducer reports whether the byte after ESC starts a sequence that is
|
||||
// framed by a final byte. The parser routes CSI and SS3 through the same
|
||||
// parameterized subcases, so both are framed by csiEnd.
|
||||
func csiIntroducer(b byte) bool {
|
||||
return b == '[' || b == 'O'
|
||||
}
|
||||
|
||||
// stringIntroducer reports whether the byte after ESC starts a string sequence.
|
||||
// Only the three that terminals reply with: OSC for colors, title and clipboard,
|
||||
// DCS for XTVERSION, DECRQSS and XTGETTCAP, APC for Kitty graphics. Nothing
|
||||
// sends SOS or PM.
|
||||
func stringIntroducer(b byte) bool {
|
||||
switch b {
|
||||
case 'P', ']', '_':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stringReply reports whether a framed string sequence starts the way a
|
||||
// terminal's reply does. Its introducer is also ALT-], ALT-P or ALT-_, and typed
|
||||
// text after one, ended by CTRL-G or another key, frames like a reply when it
|
||||
// all arrives in one read.
|
||||
func stringReply(buffer []byte) bool {
|
||||
payload := buffer[2:]
|
||||
switch buffer[1] {
|
||||
case ']': // Ps ; ...
|
||||
i := 0
|
||||
for i < len(payload) && payload[i] >= '0' && payload[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
return i > 0 && i < len(payload) && payload[i] == ';'
|
||||
case 'P': // >| or !| for the version, [01]$r or [01]+r for settings
|
||||
if len(payload) < 2 {
|
||||
return false
|
||||
}
|
||||
if payload[1] == '|' {
|
||||
return payload[0] == '>' || payload[0] == '!'
|
||||
}
|
||||
return len(payload) >= 3 && (payload[0] == '0' || payload[0] == '1') &&
|
||||
(payload[1] == '$' || payload[1] == '+') && payload[2] == 'r'
|
||||
case '_': // G, then key=value
|
||||
return len(payload) >= 3 && payload[0] == 'G' &&
|
||||
(payload[1] >= 'a' && payload[1] <= 'z' || payload[1] >= 'A' && payload[1] <= 'Z') &&
|
||||
payload[2] == '='
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillArriving reports whether the CSI or SS3 sequence at the start of the
|
||||
// buffer may still be completed by more input. It must be the only one in the buffer:
|
||||
// whatever follows it, another sequence or a byte that ended it, means it is not
|
||||
// waiting for anything.
|
||||
func stillArriving(buffer []byte) bool {
|
||||
return bytes.IndexByte(buffer[1:], Esc.Byte()) < 0 && incompleteEscape(buffer)
|
||||
}
|
||||
|
||||
// incompleteEscape reports whether the buffer ends in a CSI or SS3 sequence that
|
||||
// has not been terminated yet. The read loop keeps waiting while it does, so a
|
||||
// fragment reaches the parser only once that wait has run out. A string sequence
|
||||
// is never waited for, because its introducer is also ALT-], ALT-P or ALT-_.
|
||||
func incompleteEscape(buffer []byte) bool {
|
||||
// Only the tail can hold a sequence still arriving. This runs once per byte
|
||||
// read, so scanning all of a large paste would make the read quadratic.
|
||||
@@ -362,21 +478,10 @@ func incompleteEscape(buffer []byte) bool {
|
||||
if start < 0 || len(tail)-start < 2 {
|
||||
return false
|
||||
}
|
||||
switch tail[start+1] {
|
||||
case '[':
|
||||
for _, b := range tail[start+2:] {
|
||||
if csiFinal(b) {
|
||||
return false
|
||||
}
|
||||
if !csiContinues(b) {
|
||||
return false // malformed, do not wait for a terminator
|
||||
}
|
||||
}
|
||||
return true
|
||||
case 'O':
|
||||
return len(tail)-start < 3
|
||||
}
|
||||
return false
|
||||
// SS3 is judged like CSI: declaring it finished after one byte made the
|
||||
// parser give up on a split \eO1;5A. A malformed sequence is not waited for,
|
||||
// as nothing can complete it.
|
||||
return csiIntroducer(tail[start+1]) && csiEnd(tail[start:]) == 0
|
||||
}
|
||||
|
||||
func (r *LightRenderer) getBytes(cancellable bool) ([]byte, getCharResult, error) {
|
||||
@@ -483,17 +588,27 @@ func (r *LightRenderer) GetChar(cancellable bool) Event {
|
||||
return Event{CtrlSlash, 0, nil}
|
||||
case Esc.Byte():
|
||||
ev := r.escSequence(&sz)
|
||||
// Second chance
|
||||
if ev.Type == Invalid {
|
||||
r.buffer, result, err = r.getBytes(true)
|
||||
// Second chance, but only for an unfinished sequence. Re-reading
|
||||
// otherwise blocks until the next keystroke, holding back whatever
|
||||
// follows in the buffer.
|
||||
if ev.Type == Invalid && stillArriving(r.buffer) {
|
||||
waited := len(r.buffer)
|
||||
var buffer []byte
|
||||
buffer, result, err = r.getBytes(true)
|
||||
if err != nil {
|
||||
return Event{Fatal, 0, nil}
|
||||
}
|
||||
r.buffer = buffer
|
||||
if result == getCharCancelled {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
ev = r.escSequence(&sz)
|
||||
// This read has no time limit, so what it returned may be the next
|
||||
// keystroke rather than the rest of the sequence. Never drop it.
|
||||
if ev.Type == Invalid && sz > waited {
|
||||
sz = waited
|
||||
}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
@@ -525,7 +640,41 @@ func (r *LightRenderer) setCancel(f func()) {
|
||||
r.mutex.Unlock()
|
||||
}
|
||||
|
||||
// escSequence parses an escape sequence, then checks a CSI or SS3 result
|
||||
// against its frame. parseEscSequence can stop after a few bytes, and consuming
|
||||
// only those would leave the rest to be typed into the query. Complete
|
||||
// sequences it does not recognize are dropped there, unless they could be an
|
||||
// ALT key followed by typed text.
|
||||
func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
ev := r.parseEscSequence(sz)
|
||||
if len(r.buffer) < 3 || !csiIntroducer(r.buffer[1]) {
|
||||
return ev
|
||||
}
|
||||
// A frame missing its final byte may still be arriving
|
||||
end := csiEnd(r.buffer)
|
||||
if end <= 0 {
|
||||
return ev
|
||||
}
|
||||
// The parser can also set the size before checking the bytes and then give
|
||||
// up, counting typed input past the frame, as in \e[3;1u followed by ~
|
||||
if ev.Type == Invalid && *sz > end {
|
||||
*sz = end
|
||||
}
|
||||
// Four bytes or fewer could be ALT-[ or ALT-O and typed text, and rxvt sends
|
||||
// keys of that size fzf does not know, such as \e[3^ for CTRL-DELETE
|
||||
if end <= 4 {
|
||||
return ev
|
||||
}
|
||||
// A key matched on a prefix, such as Home for \e[70;5u, or a sequence given
|
||||
// up on partway
|
||||
if end > *sz {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
func (r *LightRenderer) parseEscSequence(sz *int) Event {
|
||||
if len(r.buffer) < 2 {
|
||||
return Event{Esc, 0, nil}
|
||||
}
|
||||
@@ -606,6 +755,11 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if len(r.buffer) < 4 {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
// A byte that cannot continue the sequence, such as Enter after
|
||||
// ALT-[ and a digit, is a key of its own
|
||||
if csiEnd(r.buffer) < 0 {
|
||||
break
|
||||
}
|
||||
*sz = 4
|
||||
switch r.buffer[2] {
|
||||
case '2':
|
||||
@@ -638,7 +792,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{Delete, 0, nil}
|
||||
}
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -657,7 +811,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftDelete, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -680,12 +834,14 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
}
|
||||
return Event{Invalid, 0, nil}
|
||||
case '4':
|
||||
return Event{End, 0, nil}
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
case '5':
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{PageUp, 0, nil}
|
||||
}
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -704,7 +860,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftPageUp, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -730,7 +886,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
if r.buffer[3] == '~' {
|
||||
return Event{PageDown, 0, nil}
|
||||
}
|
||||
if len(r.buffer) == 7 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
if len(r.buffer) > 6 && r.buffer[6] == '~' && r.buffer[4] == '1' {
|
||||
*sz = 7
|
||||
switch r.buffer[5] {
|
||||
case '0':
|
||||
@@ -749,7 +905,7 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
return Event{CtrlAltShiftPageDown, 0, nil}
|
||||
}
|
||||
}
|
||||
if len(r.buffer) == 6 && r.buffer[5] == '~' {
|
||||
if len(r.buffer) > 5 && r.buffer[5] == '~' {
|
||||
*sz = 6
|
||||
switch r.buffer[4] {
|
||||
case '2':
|
||||
@@ -772,15 +928,19 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
}
|
||||
return Event{Invalid, 0, nil}
|
||||
case '7':
|
||||
return Event{Home, 0, nil}
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{Home, 0, nil}
|
||||
}
|
||||
case '8':
|
||||
return Event{End, 0, nil}
|
||||
if rxvtKeyEnd(r.buffer[3]) {
|
||||
return Event{End, 0, nil}
|
||||
}
|
||||
case '1':
|
||||
switch r.buffer[3] {
|
||||
case '~':
|
||||
return Event{Home, 0, nil}
|
||||
case '1', '2', '3', '4', '5', '7', '8', '9':
|
||||
if len(r.buffer) == 5 && r.buffer[4] == '~' {
|
||||
if len(r.buffer) > 4 && r.buffer[4] == '~' {
|
||||
*sz = 5
|
||||
switch r.buffer[3] {
|
||||
case '1':
|
||||
@@ -987,6 +1147,24 @@ func (r *LightRenderer) escSequence(sz *int) Event {
|
||||
} // r.buffer[2]
|
||||
} // r.buffer[2]
|
||||
} // r.buffer[1]
|
||||
// Nothing matched. A framed sequence is dropped whole: reading its
|
||||
// introducer as an ALT-key below would type the rest into the query.
|
||||
// Unterminated ones are left alone, as that is how ALT-[, ALT-O, ALT-],
|
||||
// ALT-P and ALT-_ arrive.
|
||||
if csiIntroducer(r.buffer[1]) {
|
||||
// ALT-[ or ALT-O and one or two typed characters can look like a short
|
||||
// sequence, so ask for at least two bytes before the final byte
|
||||
if end := csiEnd(r.buffer); end > 4 {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
} else if stringIntroducer(r.buffer[1]) {
|
||||
if end := stringEnd(r.buffer); end > 0 && stringReply(r.buffer) {
|
||||
*sz = end
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
}
|
||||
|
||||
rest := bytes.NewBuffer(r.buffer[1:])
|
||||
c, size, err := rest.ReadRune()
|
||||
if err == nil {
|
||||
@@ -1003,11 +1181,13 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
|
||||
rest := r.buffer[*sz:]
|
||||
end := bytes.IndexAny(rest, "mM")
|
||||
if end == -1 {
|
||||
// Not past the frame, where M or m can be a key typed later
|
||||
frame := csiEnd(r.buffer)
|
||||
if frame <= 0 || r.buffer[frame-1] != 'm' && r.buffer[frame-1] != 'M' {
|
||||
return Event{Invalid, 0, nil}
|
||||
}
|
||||
rest := r.buffer[*sz:]
|
||||
end := frame - 1 - *sz
|
||||
|
||||
elems := strings.SplitN(string(rest[:end]), ";", 3)
|
||||
if len(elems) != 3 {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
// An unrecognized escape sequence must be consumed whole, CSI and string
|
||||
// sequences alike. Consuming only part of one leaves the rest to be read as
|
||||
// input and typed into the query.
|
||||
func TestUnknownEscapeSequence(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
sequence string
|
||||
event EventType
|
||||
size int
|
||||
}{
|
||||
// Key encodings fzf does not implement
|
||||
{"\x1b[97;5u", Invalid, 7},
|
||||
{"\x1b[70;5u", Invalid, 7}, // not Home, which its prefix \e[7 matches
|
||||
{"\x1b[42;5u", Invalid, 7}, // nor End
|
||||
{"\x1b[4;5~", Invalid, 6},
|
||||
{"\x1b[127;5u", Invalid, 8},
|
||||
{"\x1b[27;5;127~", Invalid, 11},
|
||||
{"\x1b[57441;1u", Invalid, 10},
|
||||
{"\x1b\x1b[97;5u", Invalid, 7}, // ALT prefixed, the first ESC is dropped
|
||||
|
||||
// Replies to queries fzf did not send, or sent and stopped waiting for
|
||||
{"\x1b[?1;2c", Invalid, 7},
|
||||
{"\x1b[>0;95;0c", Invalid, 10},
|
||||
|
||||
// Mouse report arriving while mouse input is off
|
||||
{"\x1b[<0;1;1M", Invalid, 9},
|
||||
|
||||
// String sequences
|
||||
{"\x1b]11;rgb:4a4a/4a4a/4a4a\x1b\\", Invalid, 25}, // background color reply
|
||||
{"\x1b]0;a title\a", Invalid, 12}, // BEL terminated
|
||||
{"\x1bP>|kitty(0.48.2)\x1b\\", Invalid, 19}, // XTVERSION reply
|
||||
{"\x1b_Gi=1;OK\x1b\\", Invalid, 11}, // Kitty graphics reply
|
||||
{"\x1bP1$r\a\x1b\\", Invalid, 8}, // BEL is payload in a DCS
|
||||
{"\x1b]11;x\x1bX\x1b\\", Invalid, 6}, // ESC ends it, framing "\e]11;x"
|
||||
|
||||
// SS3 is framed like CSI, since the parser routes both the same way
|
||||
{"\x1bO9;5u", Invalid, 6},
|
||||
{"\x1bO1;5X", Invalid, 6}, // unknown final byte
|
||||
{"\x1bO1;5A", CtrlUp, 6}, // recognized, unchanged
|
||||
{"\x1bOP", F1, 3},
|
||||
|
||||
// Left alone: this is how ALT-[, ALT-O, ALT-], ALT-P and ALT-_ arrive
|
||||
{"\x1b[a", Alt, 2},
|
||||
{"\x1b[9A", Alt, 2}, // ALT-[ and two characters can look like a CSI
|
||||
{"\x1b[1m", Alt, 2},
|
||||
{"\x1b[ x", Alt, 2},
|
||||
{"\x1bOx", Alt, 2},
|
||||
{"\x1bO9A", Alt, 2},
|
||||
|
||||
// rxvt keys fzf does not know: dropped, not typed
|
||||
{"\x1b[3^", Invalid, 4}, // CTRL-DELETE
|
||||
{"\x1b[5^", Invalid, 4}, // CTRL-PAGEUP
|
||||
{"\x1b[3@", Invalid, 4}, // CTRL-SHIFT-DELETE
|
||||
{"\x1b[11^", Invalid, 5},
|
||||
|
||||
// rxvt ends keys with $ after digits, so what follows is typed
|
||||
{"\x1b[7$x", Home, 4}, // SHIFT-HOME, then x
|
||||
{"\x1b[7$1x", Home, 4},
|
||||
{"\x1b[3$x", Invalid, 4},
|
||||
{"\x1b[3$1x", Invalid, 4},
|
||||
{"\x1b[23$x", Invalid, 5}, // SHIFT-F11, then x
|
||||
{"\x1b[24$1x", Invalid, 5},
|
||||
|
||||
// A parse that gives up past the frame consumes only the frame, so the
|
||||
// typed ~ after these stays in the buffer
|
||||
{"\x1b[3;1u~", Invalid, 6},
|
||||
{"\x1b[5;1u~", Invalid, 6},
|
||||
{"\x1b[2$~", Invalid, 4}, // rxvt SHIFT-INSERT
|
||||
{"\x1b[2^~", Invalid, 4}, // rxvt CTRL-INSERT
|
||||
{"\x1b[2A~", Invalid, 4},
|
||||
|
||||
// Elsewhere $ and other intermediate bytes do not end a sequence
|
||||
{"\x1b[4;0;10;20;0&w", Invalid, 15}, // not End, which \e[4 matches
|
||||
{"\x1b[4;2$y", Invalid, 7},
|
||||
{"\x1b[2 q", Invalid, 5},
|
||||
{"\x1b]abc", Alt, 2},
|
||||
{"\x1b]11;rgb:", Alt, 2}, // terminator has not arrived
|
||||
{"\x1b]\x1b]", Alt, 2}, // a second sequence must not swallow the ALT key
|
||||
{"\x1b]\x1b[A", Alt, 2},
|
||||
{"\x1bP\x1bP", Alt, 2},
|
||||
{"\x1b_\x1b_", Alt, 2},
|
||||
{"\x1b]\x1b\\", Alt, 2}, // ALT-] then ALT-backslash, not an empty OSC
|
||||
{"\x1b]\a", Alt, 2}, // ALT-] then CTRL-G, which must still abort
|
||||
|
||||
// Typed text after the introducer is not a reply, even when a
|
||||
// terminator follows in the same read
|
||||
{"\x1b]a\a", Alt, 2},
|
||||
{"\x1b]1\a", Alt, 2},
|
||||
{"\x1b]a\x1b[A", Alt, 2},
|
||||
{"\x1bPx\x1b[A", Alt, 2},
|
||||
{"\x1b_ab\x1bb", Alt, 2},
|
||||
|
||||
// SOS and PM are not framed
|
||||
{"\x1bXsos\x1b\\", Alt, 2},
|
||||
{"\x1b^status\x1b\\", Alt, 2},
|
||||
|
||||
// Left alone: no final byte yet, so the sequence may still be arriving
|
||||
{"\x1b[", Invalid, 2},
|
||||
|
||||
// Recognized sequences keep their existing parsing
|
||||
{"\x1b[1;5A", CtrlUp, 6},
|
||||
{"\x1b[3;5~", CtrlDelete, 6},
|
||||
{"\x1b[2~", Insert, 4},
|
||||
{"\x1b[200~", BracketedPasteBegin, 6},
|
||||
{"\x1b[Z", ShiftTab, 3},
|
||||
{"\x1bOA", Up, 3},
|
||||
{"\x1b[12;34R", Invalid, 8},
|
||||
{"\x1b[?2004;2$y", Invalid, 11},
|
||||
} {
|
||||
r := &LightRenderer{buffer: []byte(c.sequence)}
|
||||
sz := 1
|
||||
event := r.escSequence(&sz)
|
||||
if event.Type != c.event {
|
||||
t.Errorf("escSequence(%q) = %s, want %s",
|
||||
c.sequence, event.Type.String(), c.event.String())
|
||||
}
|
||||
if sz != c.size {
|
||||
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringEnd(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want int
|
||||
}{
|
||||
// Terminated
|
||||
{"\x1b]0;t\a", 6},
|
||||
{"\x1b]0;t\x1b\\", 7},
|
||||
{"\x1b_G\x1b\\", 5},
|
||||
{"\x1bP1$r\a\x1b\\", 8}, // BEL is payload in a DCS, ST ends it
|
||||
|
||||
// BEL terminates an OSC only
|
||||
{"\x1bP\a", 0},
|
||||
{"\x1b_Gi=1\a", 0},
|
||||
|
||||
// An ESC ends the string and introduces a sequence of its own
|
||||
{"\x1b]foo\x1bX\x1b\\", 5},
|
||||
{"\x1b]foo\x1bP", 5},
|
||||
{"\x1b]\x1b]", 2},
|
||||
|
||||
// Nothing has ended it yet
|
||||
{"\x1b]0;t", 0},
|
||||
{"\x1b]0;t\x1b", 0}, // ST half arrived
|
||||
{"\x1b]foo\x1b", 0},
|
||||
{"\x1b]", 0},
|
||||
{"\x1b", 0}, // shorter than an introducer
|
||||
|
||||
} {
|
||||
if got := stringEnd([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stringEnd(%q) = %d, want %d", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A dropped sequence followed by ALT-[ is not waiting for anything, so the
|
||||
// bytes in between must not be held back until the next keystroke
|
||||
func TestStillArriving(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1b[97;5u\a\x1b[", false},
|
||||
{"\x1b[97;5u\x1b[", false},
|
||||
{"\x1b[3;\a\x1b[", false}, // the BEL ended it, the trailing \e[ is another one
|
||||
{"\x1b[1;\a", false}, // the BEL has already ended it
|
||||
{"\x1b[1;", true},
|
||||
{"\x1b[", true},
|
||||
{"\x1bO1", true},
|
||||
{"\x1b[<0;1", true},
|
||||
} {
|
||||
if got := stillArriving([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stillArriving(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringReply(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1b]11;rgb:1/2/3\a", true},
|
||||
{"\x1b]0;title\a", true},
|
||||
{"\x1b]52;c;YWJj\x1b\\", true},
|
||||
{"\x1bP>|kitty(0.48.2)\x1b\\", true},
|
||||
{"\x1bP!|0\x1b\\", true},
|
||||
{"\x1bP1$r0m\x1b\\", true},
|
||||
{"\x1bP0+r\x1b\\", true},
|
||||
{"\x1b_Gi=1;OK\x1b\\", true},
|
||||
|
||||
// What typed text after ALT-], ALT-P or ALT-_ looks like
|
||||
{"\x1b]\a", false},
|
||||
{"\x1b]a\a", false},
|
||||
{"\x1b]1\a", false},
|
||||
{"\x1b];\a", false},
|
||||
{"\x1bPx\x1b\\", false},
|
||||
{"\x1bP1\x1b\\", false},
|
||||
{"\x1b_ab\x1b\\", false},
|
||||
{"\x1b_G\x1b\\", false},
|
||||
} {
|
||||
if got := stringReply([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("stringReply(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsiEnd(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want int
|
||||
}{
|
||||
{"\x1b[97;5u", 7},
|
||||
{"\x1b[A", 3},
|
||||
{"\x1b[<0;1;1M", 9},
|
||||
{"\x1b[?2004;2$y", 11},
|
||||
{"\x1b[7$x", 4}, // rxvt ends keys with $ after digits
|
||||
{"\x1b[23$", 5},
|
||||
{"\x1b[4;2$y", 7}, // but $ is intermediate elsewhere
|
||||
{"\x1b[97;5", 0}, // no final byte
|
||||
{"\x1b[", 0}, // no final byte
|
||||
{"\x1b[1\x01A", -1}, // malformed, do not frame it
|
||||
{"\x1b[\a", -1},
|
||||
} {
|
||||
if got := csiEnd([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("csiEnd(%q) = %d, want %d", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key that follows a sequence in the same read must not be consumed with it
|
||||
func TestKeyAfterSequence(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
sequence string
|
||||
mouse bool
|
||||
event EventType
|
||||
size int
|
||||
}{
|
||||
// Recognized when more input follows, as from a paste or tmux send-keys
|
||||
{"\x1b[15~x", false, F5, 5},
|
||||
{"\x1b[3;5~x", false, CtrlDelete, 6},
|
||||
{"\x1b[5;13~x", false, CtrlAltPageUp, 7},
|
||||
{"\x1b[6;2~\x1b[A", false, ShiftPageDown, 6},
|
||||
|
||||
// ALT-[ or ALT-O and a digit, then a key that cannot continue a sequence
|
||||
{"\x1b[2\r", false, Alt, 2},
|
||||
{"\x1b[12\x03", false, Alt, 2},
|
||||
{"\x1bO2\a", false, Alt, 2},
|
||||
{"\x1b[4x", false, Alt, 2}, // not End
|
||||
{"\x1b[7\r", false, Alt, 2}, // not Home
|
||||
{"\x1b[7~x", false, Home, 4},
|
||||
{"\x1b[8^x", false, End, 4}, // rxvt CTRL-END
|
||||
|
||||
// A mouse report ends at its own final byte
|
||||
{"\x1b[<0;5;3Mx", true, Mouse, 9},
|
||||
{"\x1b[<0;1;\x1b[AM", true, Invalid, 3},
|
||||
{"\x1b[<0;1;1xM", true, Invalid, 9},
|
||||
} {
|
||||
r := &LightRenderer{buffer: []byte(c.sequence), mouse: c.mouse}
|
||||
sz := 1
|
||||
event := r.escSequence(&sz)
|
||||
if event.Type != c.event {
|
||||
t.Errorf("escSequence(%q) = %s, want %s",
|
||||
c.sequence, event.Type.String(), c.event.String())
|
||||
}
|
||||
if sz != c.size {
|
||||
t.Errorf("escSequence(%q) consumed %d bytes, want %d", c.sequence, sz, c.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,3 +51,46 @@ func TestIncompleteEscape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A string sequence is not waited for. Its introducer is also ALT-], ALT-P or
|
||||
// ALT-_, and holding the read open on those both delayed the key and let typed
|
||||
// bytes accumulate into something that looked like a sequence.
|
||||
func TestIncompleteStringEscape(t *testing.T) {
|
||||
for _, buffer := range []string{
|
||||
"\x1b]11;rgb:",
|
||||
"\x1bP>|kitty",
|
||||
"\x1b_Gi=1",
|
||||
"\x1b]0;t\x1b",
|
||||
"\x1b]0;title\a",
|
||||
"ab\x1b]11;rgb:",
|
||||
} {
|
||||
if incompleteEscape([]byte(buffer)) {
|
||||
t.Errorf("incompleteEscape(%q) = true, want false", buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SS3 reaches the same parameterized subcases as CSI in the parser, so it has to
|
||||
// be judged the same way here. Declaring it finished after one byte made the
|
||||
// parser give up on a split \eO1;5A and type its tail into the query.
|
||||
func TestIncompleteSS3(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
buffer string
|
||||
want bool
|
||||
}{
|
||||
{"\x1bO", true},
|
||||
{"\x1bO1", true},
|
||||
{"\x1bO1;", true},
|
||||
{"\x1bO1;5", true},
|
||||
{"\x1bOA", false},
|
||||
{"\x1bOP", false},
|
||||
{"\x1bO1;5A", false},
|
||||
{"\x1b[23$", false}, // rxvt SHIFT-F11 is complete
|
||||
{"\x1b[7$", false},
|
||||
{"\x1b[4;2$", true}, // $ after a ; is intermediate
|
||||
} {
|
||||
if got := incompleteEscape([]byte(c.buffer)); got != c.want {
|
||||
t.Errorf("incompleteEscape(%q) = %v, want %v", c.buffer, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Drives queryStartup against a terminal simulated by pipes, with the replies
|
||||
@@ -126,3 +129,54 @@ func TestQueryStartup(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The second-chance read has no time limit, so a key typed long after an
|
||||
// unfinished sequence must not be dropped with it.
|
||||
func TestLateKeyAfterUnfinishedSequence(t *testing.T) {
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inR.Close(); inW.Close() })
|
||||
inW.WriteString("\x1b[12")
|
||||
go func() {
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.WriteString("x")
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.WriteString("y") // ends the wait if x was dropped
|
||||
}()
|
||||
|
||||
r := &LightRenderer{ttyin: inR, escDelay: defaultEscDelay}
|
||||
for range 3 {
|
||||
if ev := r.GetChar(false); ev.Type == Rune {
|
||||
if ev.Char != 'x' {
|
||||
t.Errorf("got %q, want 'x'", ev.Char)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("the late key was dropped")
|
||||
}
|
||||
|
||||
// A read error during the second-chance read must end fzf cleanly, not panic.
|
||||
func TestSecondChanceReadError(t *testing.T) {
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { inR.Close() })
|
||||
inW.WriteString("\x1b[12")
|
||||
go func() {
|
||||
time.Sleep(2 * defaultEscDelay * time.Millisecond)
|
||||
inW.Close()
|
||||
}()
|
||||
|
||||
out, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &LightRenderer{ttyin: inR, ttyout: out, escDelay: defaultEscDelay, origState: &term.State{}}
|
||||
if ev := r.GetChar(false); ev.Type != Fatal {
|
||||
t.Errorf("got %s, want Fatal", ev.Type.String())
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,6 @@ func TestLightRenderer(t *testing.T) {
|
||||
assertEscSequence("\x1b[1;1R", "!Invalid")
|
||||
assertEscSequence("\x1b[", "!Invalid")
|
||||
assertEscSequence("\x1b[1", "!Invalid")
|
||||
assertEscSequence("\x1b[3;3~1", "!Invalid")
|
||||
assertEscSequence("\x1b[13", "!Invalid")
|
||||
assertEscSequence("\x1b[1;3", "!Invalid")
|
||||
assertEscSequence("\x1b[1;10", "!Invalid")
|
||||
@@ -200,6 +199,7 @@ func TestLightRenderer(t *testing.T) {
|
||||
assertEscSequence("\x1b[1;3H", "alt-home")
|
||||
assertEscSequence("\x1b[1;3F", "alt-end")
|
||||
assertEscSequence("\x1b[3;3~", "alt-delete")
|
||||
assertEscSequence("\x1b[3;3~1", "alt-delete") // then 1
|
||||
assertEscSequence("\x1b[5;3~", "alt-page-up")
|
||||
assertEscSequence("\x1b[6;3~", "alt-page-down")
|
||||
|
||||
|
||||
+9
-7
@@ -105,16 +105,18 @@ func RepeatToFill(str string, length int, limit int) string {
|
||||
rest := limit % length
|
||||
output := strings.Repeat(str, times)
|
||||
if rest > 0 {
|
||||
for _, r := range str {
|
||||
rest -= uniseg.StringWidth(string(r))
|
||||
if rest < 0 {
|
||||
break
|
||||
}
|
||||
output += string(r)
|
||||
if rest == 0 {
|
||||
// Iterate over grapheme clusters so that we don't cut a cluster in half
|
||||
end := 0
|
||||
graphemes := uniseg.NewGraphemes(str)
|
||||
for rest > 0 && graphemes.Next() {
|
||||
width := graphemes.Width()
|
||||
if width > rest {
|
||||
break
|
||||
}
|
||||
rest -= width
|
||||
_, end = graphemes.Positions()
|
||||
}
|
||||
output += str[:end]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -109,6 +109,27 @@ func TestRepeatToFill(t *testing.T) {
|
||||
if RepeatToFill("abcde", 10, 42) != strings.Repeat("abcde", 4)+"abcde"[:2] {
|
||||
t.Error("Expected:", strings.Repeat("abcde", 4)+"abcde"[:2])
|
||||
}
|
||||
|
||||
// Should not cut a grapheme cluster in half
|
||||
for _, test := range []struct {
|
||||
str string
|
||||
limit int
|
||||
expected string
|
||||
}{
|
||||
{"a\u0301b", 1, "a\u0301"},
|
||||
{"a\u0301b", 3, "a\u0301ba\u0301"},
|
||||
{"a\u0301b", 4, "a\u0301ba\u0301b"},
|
||||
{"a\u4e00", 2, "a"},
|
||||
{"a\u4e00", 4, "a\u4e00a"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 1, "-"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 2, "-"},
|
||||
{"-\U0001f468\u200d\U0001f469\u200d\U0001f467", 4, "-\U0001f468\u200d\U0001f469\u200d\U0001f467-"},
|
||||
} {
|
||||
actual := RepeatToFill(test.str, StringWidth(test.str), test.limit)
|
||||
if actual != test.expected {
|
||||
t.Errorf("Expected: %q, actual: %q", test.expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringWidth(t *testing.T) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+176
-1
@@ -1532,6 +1532,61 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| refute_includes(lines[-1], '[[1]]') }
|
||||
end
|
||||
|
||||
def assert_focus_log(expected)
|
||||
wait do
|
||||
assert_path_exists tempname
|
||||
assert_equal expected, File.readlines(tempname, chomp: true)
|
||||
end
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload
|
||||
# Without --id-nth, the item at the same index after reload is a new item
|
||||
tmux.send_keys %(printf 'a\\nb\\n' | #{FZF} --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "c\\nd\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> a' }
|
||||
assert_focus_log(%w[a])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> c' }
|
||||
assert_focus_log(%w[a c])
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload_id_nth_same_key
|
||||
# Same --id-nth key after reload: no focus event
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n1 aa\\n2 bb\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> 1 aa' }
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 bb' }
|
||||
assert_focus_log(['1 a', '2 bb'])
|
||||
end
|
||||
|
||||
def test_focus_event_after_reload_id_nth_key_not_found
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "3 c\\n4 d\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines, '> 3 c' }
|
||||
assert_focus_log(['1 a', '3 c'])
|
||||
end
|
||||
|
||||
def test_focus_event_after_async_reload_id_nth
|
||||
# No focus event for the items shown before the tracked item arrives.
|
||||
# 'result' binding makes fzf redraw the list while blocked.
|
||||
tmux.send_keys %(printf '1 a\\n2 b\\n' | #{FZF} --track --id-nth 1 --bind 'result:change-footer(R)' --bind 'focus:execute-silent(echo {} >> #{tempname})' --bind 'ctrl-r:reload(printf "0 x\\n"; sleep 1; printf "1 aa\\n2 bb\\n")'), :Enter
|
||||
tmux.until { |lines| assert_includes lines, '> 1 a' }
|
||||
assert_focus_log(['1 a'])
|
||||
tmux.send_keys 'C-r'
|
||||
tmux.until { |lines| assert_includes lines[-2], '+T*' }
|
||||
tmux.until do |lines|
|
||||
assert_includes lines, '> 1 aa'
|
||||
refute_includes lines[-2], '+T*'
|
||||
end
|
||||
tmux.send_keys :Up
|
||||
tmux.until { |lines| assert_includes lines, '> 2 bb' }
|
||||
assert_focus_log(['1 a', '2 bb'])
|
||||
end
|
||||
|
||||
def test_result_event
|
||||
tmux.send_keys '(echo 0; seq 10) | fzf --bind "result:pos(2)"', :Enter
|
||||
tmux.until { |lines| assert_equal 11, lines.match_count }
|
||||
@@ -2287,6 +2342,94 @@ class TestCore < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_next_value
|
||||
tmux.send_keys %(echo 'a b c d' | #{FZF} --nth 1 --bind 'space:change-nth(1|2|3|4),x:change-nth(3),result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[2]>') }
|
||||
# Changed by another binding. The next value is the one after '3'.
|
||||
tmux.send_keys :x
|
||||
tmux.until { |lines| assert lines.any_include?('[3]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[4]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_all_fields_is_current
|
||||
# '--nth ..' is stored as an empty list, which must match '..'
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth .. --bind 'space:change-nth(..|1|2)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_repeated_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'space:change-nth(1|2|1|3)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
%w[2 1 3 1 2].each do |nth|
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_skips_invalid_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 2 --bind 'space:change-nth(1|x|3)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[2]>') }
|
||||
%w[1 3 1].each do |nth|
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_only_invalid_values_left
|
||||
# 'x' is invalid and '3' is current, so nothing changes
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'a:change-nth(3),space:change-nth(x|3)+transform-prompt:echo "S[$FZF_NTH]> "' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert lines.any_include?('[3]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('S[3]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_invalid_value_ignored
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'a:change-nth(3),space:change-nth(bad)+transform-prompt:echo "S[$FZF_NTH]> "' --bind 'b:transform-nth(echo bad)+transform-prompt:echo "T[$FZF_NTH]> "' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert lines.any_include?('[3]>') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('S[3]>') }
|
||||
tmux.send_keys :b
|
||||
tmux.until { |lines| assert lines.any_include?('T[3]>') }
|
||||
end
|
||||
|
||||
def test_change_nth_from_transform
|
||||
# A new action is created on each run, so the next value is found from the current state
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'space:transform:echo "change-nth(1|2|3)"' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('[1]>') }
|
||||
%w[2 3 1].each do |nth|
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_with_nth_skips_default_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(|2)'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('> a') }
|
||||
# Empty value is the default (field 1), which is current
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('> b') }
|
||||
end
|
||||
|
||||
def test_change_with_nth_skips_invalid_value
|
||||
tmux.send_keys %(echo 'a b c' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(1|foo|2)'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('> a') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('> b') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('> a') }
|
||||
end
|
||||
|
||||
def test_transform_with_nth_search
|
||||
input = [
|
||||
'alpha bravo charlie',
|
||||
@@ -2319,6 +2462,25 @@ class TestCore < TestInteractive
|
||||
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
|
||||
end
|
||||
|
||||
def test_transform_nth_output_does_not_replace_command
|
||||
# '|' in the output must not be treated as a list to cycle through
|
||||
File.write(tempname, '')
|
||||
tmux.send_keys %(echo 'a b' | #{FZF} --with-nth .. --bind "space:transform-nth(echo >> #{tempname}; echo '1|2')+transform-with-nth(echo >> #{tempname}; echo '1|2')"), :Enter
|
||||
tmux.until { |lines| assert_equal 1, lines.item_count }
|
||||
3.times do |i|
|
||||
tmux.send_keys :Space
|
||||
wait { assert_equal 2 * (i + 1), File.readlines(tempname).length }
|
||||
end
|
||||
end
|
||||
|
||||
def test_transform_with_nth_output_with_pipe
|
||||
File.write(tempname, '{2}|{1}')
|
||||
tmux.send_keys %(echo 'a b' | #{FZF} --with-nth .. --bind 'space:transform-with-nth(cat #{tempname})'), :Enter
|
||||
tmux.until { |lines| assert lines.any_include?('a b') }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert lines.any_include?('b|a') }
|
||||
end
|
||||
|
||||
# Regression: actions emitted by bg-transform must affect the iteration that
|
||||
# processes the async result, not the (no-longer-active) iteration that
|
||||
# scheduled the transform. Covers reload (newCommand) and exclude (denylist).
|
||||
@@ -2871,7 +3033,7 @@ class TestCore < TestInteractive
|
||||
end
|
||||
|
||||
def test_change_nth_unset_default
|
||||
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind space:change-nth:), :Enter
|
||||
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind space:change-nth:..), :Enter
|
||||
tmux.until do
|
||||
assert_equal 1, it.item_count
|
||||
assert_equal 0, it.match_count
|
||||
@@ -2885,6 +3047,19 @@ class TestCore < TestInteractive
|
||||
end
|
||||
end
|
||||
|
||||
def test_change_nth_empty_restores_default
|
||||
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind 'a:change-nth(..),space:change-nth(),b:transform-nth(true)'), :Enter
|
||||
tmux.until { |lines| assert_equal 0, lines.match_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :Space
|
||||
tmux.until { |lines| assert_equal 0, lines.match_count }
|
||||
tmux.send_keys :a
|
||||
tmux.until { |lines| assert_equal 1, lines.match_count }
|
||||
tmux.send_keys :b
|
||||
tmux.until { |lines| assert_equal 0, lines.match_count }
|
||||
end
|
||||
|
||||
def test_change_header_lines
|
||||
tmux.send_keys %(seq 10 | #{FZF} --header-lines 3 --bind 'space:change-header-lines(5),enter:transform-header-lines(echo 1)'), :Enter
|
||||
tmux.until do |lines|
|
||||
|
||||
Reference in new issue
Block a user