Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 336cfdef02 Fix nushell contamination in install loop 2026-05-28 00:34:17 +00:00
copilot-swe-agent[bot] eee9672eb0 Initial plan 2026-05-28 00:32:09 +00:00
57 changed files with 280 additions and 4168 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v5
with:
fetch-depth: 0
+1 -1
View File
@@ -9,6 +9,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v7
uses: actions/checkout@v5
- name: 'Dependency Review'
uses: actions/dependency-review-action@v5
+1 -1
View File
@@ -12,6 +12,6 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v7
- uses: actions/labeler@v6
with:
configuration-path: .github/labeler.yml
+3 -3
View File
@@ -18,17 +18,17 @@ jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: "1.23"
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
with:
ruby-version: 3.4.6
+3 -3
View File
@@ -15,17 +15,17 @@ jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: "1.23"
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
with:
ruby-version: 3.0.0
+2 -2
View File
@@ -19,11 +19,11 @@ jobs:
runs-on: macos-latest
environment: release
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v7
- uses: actions/setup-go@v6
with:
go-version: stable
+1 -1
View File
@@ -6,5 +6,5 @@ jobs:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4
-19
View File
@@ -96,25 +96,6 @@ archives:
files:
- non-existent*
nfpms:
- package_name: fzf
file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
vendor: junegunn
homepage: https://github.com/junegunn/fzf
maintainer: Junegunn Choi <junegunn.c@gmail.com>
description: Command-line fuzzy finder
license: MIT
section: utils
formats:
- deb
ids:
- fzf
contents:
- src: man/man1/fzf.1
dst: /usr/share/man/man1/fzf.1
- src: LICENSE
dst: /usr/share/doc/fzf/copyright
release:
github:
owner: junegunn
-107
View File
@@ -1,113 +1,6 @@
CHANGELOG
=========
0.74.3
------
- Performance optimizations for non-ASCII input
- A line holding any non-ASCII character is kept as a rune array, and the prefilter did not run on those lines, so every item went through the full score matrix
- Queries are up to 16x faster, the gain growing with how much of the line is non-ASCII
- Non-ASCII queries are up to 12x faster
- Reading non-ASCII input is up to 37% faster and uses up to 29% less memory, the gain depending on how early the first non-ASCII character appears in the line
- Accented Latin and fullwidth forms get the faster reading but not the faster queries, because those characters can still match their ASCII counterparts
- ASCII input is unaffected
- Fixed an image from a preview command being torn apart when its rows are separated by IND instead of newlines, as `chafa` does under tmux (#4885)
- Fixed `replace-query` corrupting the item text when the query is edited afterwards
- fzf no longer turns bracketed paste mode off on exit when the terminal already had it on, which broke pasting in shells that run fzf from a line editor widget (#4887)
- Fixed startup blocking on terminals that never answer escape sequences, such as FreeBSD virtual terminals. fzf waited for a reply until a key was pressed, then dropped that keystroke (#2860, #976)
0.74.2
------
- Performance optimizations for short queries
- Short queries scan the largest candidate sets, and the first keystroke scans the whole input
- Single-character queries are up to 2.4x faster
- Two-character queries are up to 1.4x faster
- Faster sorting of search results, skipping redundant radix passes
- `change-border-label` and `transform-border-label` now work on the native border of a tmux or Zellij floating pane
- Fixed Kitty graphics sequences from a preview command being taken by tmux as pane title requests
- Fixed an image at the top of the preview being torn by `--preview-window ~N`
- Fixed nondeterministic match highlight positions
- Fixed signal and resize handlers persisting after `Run()` returns when fzf is used as a library
- fzf now detects terminal resize on Windows in `--height` mode (#4790) (@Cyrus580529)
- fish: fixed history command being affected by user initialization scripts, and improved timestamp colors in CTRL-R (#4862) (@bitraid)
- zsh: fixed CTRL-R not propagating the exit status of fzf when perl is available (#4871) (@LangLangBart, @Toliak)
- zsh: fixed `chpwd` hook functions being called twice by ALT-C (#4879) (@LangLangBart, @lucc)
0.74.1
------
- The default separator on the info line is no longer shown when the input section is already visually separated from the list section by a border line
```sh
# No separator shown below the header border
fzf --style full --input-border none --header foo
# Separator shown; no border separates the input section from the list section
fzf --style full --input-border none --header foo --no-header-border
# No separator below the border of the preview window at 'next' position
fzf --preview : --preview-window next
# Conversely, separator is now shown when the input border does not draw
# a line facing the list section
fzf --input-border bottom
```
- Rendering improvements
- Each frame is now wrapped in synchronized update mode (mode 2026) to reduce flickering on supported terminals
- Reduced rendering output by 10-23% by skipping redundant SGR sequences
- Fixed ghost characters and misplaced colors inside Zellij by using CHA instead of CR + CUF for horizontal cursor movement (#4858, zellij-org/zellij#5370)
- Fixed cursor restoration on exit with `--height --no-clear` inside Neovim terminal by using DECSC/DECRC instead of `CSI s`/`CSI u`
- nushell: fixed deprecation error of `str downcase` on nushell 0.114.0 or above (#4857) (@sim590)
- Each release now includes `.deb` packages for easy installation on Debian-based distros (#4859)
0.74.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.74.0/_
- On tmux 3.7 or above, `--popup` starts fzf in a floating pane instead of a popup (#4850)
- Unlike a popup, a floating pane is not modal; you can switch to other panes and windows while fzf is running, move and resize the pane with the mouse, zoom it to fullscreen, and use copy-mode in it
- A floating pane always has a native border, which is what makes the pane movable and resizable, so `border-native` is implied
- A popup is used instead when a border style is explicitly specified with `--border`, so that the fzf-drawn border is the only border shown (`none` and `line` are treated as no border)
```sh
fzf --popup --border
```
- `--border-label` is set as the title of the floating pane, and is displayed on the border if `pane-border-status` is enabled in tmux
```sh
fzf --popup --border-label ' fzf '
```
- On Zellij, `--popup` uses the native border by default, consistent with tmux, so that the pane can be moved and resized with the mouse; fzf draws its own border when a border style is explicitly specified with `--border`
- `--border-label` is set as the name of the pane, displayed on the native border
- Added `result-final` event, a variant of `result` that is not triggered while the input stream is still open (#4835)
- Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading
```sh
# 'result' fires per intermediate snapshot (header keeps updating during load);
# 'result-final' fires once after the stream closes (footer shows the final count)
(seq 100; sleep 1; seq 100) | fzf --query 1 \
--bind 'result:transform-header(echo result: $FZF_MATCH_COUNT),result-final:transform-footer(echo final: $FZF_MATCH_COUNT)'
```
- Added `wait` action to block subsequent actions until search completes (#4825)
- Useful for chaining query-changing actions with motion actions to ensure operations on complete results
```sh
# Wait for search to complete before moving to the best match
fzf --bind 'start:change-query(foo)+wait+best'
```
- The initial loading of the input is also considered a search in progress, so `start:wait` can be used to wait until the input is fully loaded
```sh
# Move to the last item after the input is fully loaded
(seq 1000; sleep 1; seq 1001 2000) | fzf --bind 'start:wait+last'
```
- Bound `alt-left` to `backward-word` and `alt-right` to `forward-word` by default (#4833)
- Bug fixes and improvements
- Skip `$FZF_CURRENT_ITEM` export when the item is larger than 64 KB; a huge item can overflow `ARG_MAX` and break preview and other child commands with `E2BIG` (#4806)
- `transform` and `bg-transform` now allow a bare `put` action in the output to insert the key that triggered the action
```sh
# Insert the typed key ('a') into the query
fzf --bind 'a:transform:echo put'
```
- `ALT-C` in zsh no longer resolves symbolic links when changing the directory, consistent with the `cd` builtin (#4816) (@silverneko)
- Fixed horizontal mouse wheel events being treated as vertical scrolling (#4848) (@jason5122)
- Fixed `bw` theme not inheriting overridden colors
- fish: `CTRL-R` now works when `$fish_color_normal` or `$fish_color_comment` is empty or invalid (#4831) (@bitraid)
- Fixed empty-shell detection in the install script (#4813)
- Fixed the install script writing nushell source lines into the config files of other shells (#4812)
0.73.1
------
- Bug fixes
+1 -11
View File
@@ -1,15 +1,5 @@
FROM rubylang/ruby:3.4.1-noble
RUN apt-get update && apt-get install -y git make golang zsh fish tmux
# https://www.nushell.sh/book/installation.html
RUN <<EOF
set -ex
apt-get install -y wget gnupg
wget -qO- https://apt.fury.io/nushell/gpg.key | gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | tee /etc/apt/sources.list.d/fury-nushell.list
apt-get update
apt-get install -y nushell
EOF
RUN apt-get update -y && apt install -y git make golang zsh fish tmux
RUN gem install --no-document -v 5.22.3 minitest
RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
RUN echo '. ~/.bashrc' >> ~/.bash_profile
+1 -1
View File
@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/
specs:
ast (2.4.2)
json (2.19.9)
json (2.9.1)
language_server-protocol (3.17.0.3)
minitest (5.25.4)
parallel (1.26.3)
-10
View File
@@ -97,16 +97,6 @@ test: $(SOURCES)
itest:
ruby test/runner.rb
# Actively fuzz the matcher fast paths against the general algorithm.
# Go fuzzes one target at a time, so iterate. Override duration with
# FUZZTIME (e.g. make fuzz FUZZTIME=5m).
FUZZTIME ?= 30s
fuzz:
@for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two FuzzRunePrefilter; do \
echo "== $$t =="; \
$(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \
done
bench:
cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem
+1 -1
View File
@@ -18,7 +18,7 @@ triggered by a tag push.
2. Verify file consistency, sign the tag, and push the tag.
```sh
make tag VERSION=0.74.2
make tag VERSION=0.73.1
```
`make tag` runs `prerelease` first (checks that the version
+1 -1
View File
@@ -4,7 +4,7 @@ require (
github.com/charlievieth/fastwalk v1.0.14
github.com/gdamore/tcell/v2 v2.9.0
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
github.com/mattn/go-isatty v0.0.24
github.com/mattn/go-isatty v0.0.22
github.com/rivo/uniseg v0.4.7
golang.org/x/sys v0.35.0
golang.org/x/term v0.34.0
+2 -2
View File
@@ -8,8 +8,8 @@ github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMD
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+4 -4
View File
@@ -2,7 +2,7 @@
set -u
version=0.74.2
version=0.73.1
auto_completion=
key_bindings=
update_config=2
@@ -227,13 +227,13 @@ fi
for s in $shells; do
bin=$s
[[ $s == nushell ]] && bin=nu
[[ "$s" = nushell ]] && bin=nu
if ! command -v "$bin" > /dev/null; then
shells=${shells/$s/}
fi
done
if [[ -z ${shells// /} ]]; then
if [[ ${#shells} -lt 3 ]]; then
echo "No shell configuration to be updated."
exit 0
fi
@@ -442,7 +442,7 @@ if [[ $shells =~ fish ]]; then
fi
fi
if [[ $shells =~ nushell ]]; then
if [[ "$shells" =~ nushell ]]; then
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
echo "Setting up Nushell integration ..."
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.74.2"
$version="0.73.1"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/junegunn/fzf/src/protector"
)
var version = "0.74"
var version = "0.73"
var revision = "devel"
//go:embed shell/key-bindings.bash
+1 -1
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.2" "fzf\-tmux - open fzf in tmux split pane"
.TH fzf\-tmux 1 "May 2026" "fzf 0.73.1" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME
fzf\-tmux - open fzf in tmux split pane
+6 -88
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf 1 "Aug 2026" "fzf 0.74.2" "fzf - a command-line fuzzy finder"
.TH fzf 1 "May 2026" "fzf 0.73.1" "fzf - a command-line fuzzy finder"
.SH NAME
fzf - a command-line fuzzy finder
@@ -226,7 +226,7 @@ Enable processing of ANSI color codes
Synchronous search for multi-staged filtering. If specified, fzf will launch
the finder only after the input stream is complete and the initial filtering
and the associated actions (bound to any of \fBstart\fR, \fBload\fR,
\fBresult\fR, \fBresult\-final\fR, or \fBfocus\fR) are complete.
\fBresult\fR, or \fBfocus\fR) are complete.
.RS
e.g. \fB# Avoid rendering both fzf instances at the same time
@@ -418,29 +418,10 @@ section (default: \fB10+\fR).
Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
.TP
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
Start fzf in a tmux or Zellij floating pane (default \fBcenter,50%\fR).
Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you
Start fzf in a tmux popup or in a Zellij floating pane (default
\fBcenter,50%\fR). Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you
are not running fzf inside tmux or Zellij. \fB\-\-tmux\fR is an alias for this option.
On tmux 3.7 or above and on Zellij, the floating pane is not modal; you can
switch to other panes and windows while fzf is running, and move and resize
the pane with the mouse. The native border of the pane is the handle for
moving and resizing it, so it is used by default and \fBborder\-native\fR is
implied. \fB\-\-border\-label\fR is displayed on the native border, and
\fBchange\-border\-label\fR and \fBtransform\-border\-label\fR update it
(\fB\-\-border\-label\-pos\fR is ignored). On tmux, fzf holds the label in
the \fB@fzf\-border\-label\fR option of the pane and sets its
\fBpane\-border\-format\fR to read it back, so the label is displayed if
\fBpane\-border\-status\fR is enabled in tmux. The title of the pane is left
alone. On Zellij, the label is the name of the pane.
fzf draws its own border instead when a border style is explicitly specified
with \fB\-\-border\fR, so that it is the only border shown. \fBnone\fR and
\fBline\fR are treated as no border. Give \fBborder\-native\fR to keep the
native border nonetheless. On tmux, the fzf\-drawn border is shown in a modal
popup, since the native border of a tmux floating pane cannot be removed;
this is also the case on tmux versions below 3.7.
e.g.
\fB# Popup in the center with 70% width and height
fzf \-\-popup 70%
@@ -827,10 +808,6 @@ A synonym for \fB\-\-info=hidden\fB
The given string will be repeated to form the horizontal separator on the info
line (default: '─' or '\-' depending on \fB\-\-no\-unicode\fR).
Unless explicitly specified, the separator is not displayed if the input
section is already visually separated from the list section by a border line
(e.g. \fB\-\-input\-border\fR or \fB\-\-header\-border\fR).
ANSI color codes are supported.
.TP
@@ -1557,9 +1534,6 @@ fzf exports the following environment variables to its child processes.
.PP
.B FZF_CURRENT_ITEM
is omitted when the item contains a NUL byte, because exec(2) cannot pass it.
It is also omitted when the item is larger than 64 KB, so that a huge item
cannot overflow the environment size limit and break preview and other child
commands.
.SH EXTENDED SEARCH MODE
@@ -1881,20 +1855,6 @@ e.g.
# * Note that you can't use 'change' event in this case because the second position may not be available
fzf \-\-sync \-\-bind 'result:transform:[[ \-z {q} ]] && echo "pos(2)"'\fR
.RE
\fIresult\-final\fR
.RS
Same as \fIresult\fR, but suppressed while the input stream is still open. Use
this when you want a one-shot action per query instead of one per intermediate
snapshot during loading.
e.g.
\fB# 'result' fires per intermediate snapshot (header keeps updating during load);
# 'result-final' fires once after the stream closes (footer shows the final count)
(seq 100; sleep 1; seq 100) | fzf \-\-query 1 \\
\-\-bind 'result:transform\-header(echo result: $FZF_MATCH_COUNT),result\-final:transform\-footer(echo final: $FZF_MATCH_COUNT)'\fR
.RE
\fIchange\fR
.RS
Triggered whenever the query string is changed
@@ -2038,7 +1998,7 @@ A key or an event can be bound to one or more of the following actions.
\fBbackward\-kill\-subword\fR
\fBbackward\-kill\-word\fR \fIalt\-bs\fR
\fBbackward\-subword\fR
\fBbackward\-word\fR \fIalt\-b shift\-left alt\-left\fR
\fBbackward\-word\fR \fIalt\-b shift\-left\fR
\fBbecome(...)\fR (replace fzf process with the specified command; see below for the details)
\fBbeginning\-of\-line\fR \fIctrl\-a home\fR
\fBbell\fR (ring the terminal bell)
@@ -2085,7 +2045,7 @@ A key or an event can be bound to one or more of the following actions.
\fBfirst\fR (move to the first match; same as \fBpos(1)\fR)
\fBforward\-char\fR \fIctrl\-f right\fR
\fBforward\-subword\fR
\fBforward\-word\fR \fIalt\-f shift\-right alt\-right\fR
\fBforward\-word\fR \fIalt\-f shift\-right\fR
\fBignore\fR
\fBjump\fR (EasyMotion-like 2-keystroke movement)
\fBkill\-line\fR
@@ -2172,7 +2132,6 @@ A key or an event can be bound to one or more of the following actions.
\fBunix\-line\-discard\fR \fIctrl\-u\fR
\fBunix\-word\-rubout\fR \fIctrl\-w\fR
\fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled)
\fBwait\fR (block action execution until search completes)
\fBup\fR \fIctrl\-k up\fR
\fBup\-match\fR \fIctrl\-p\fR \fIalt\-up\fR (move to the match above the cursor)
\fBup\-selected\fR (move to the selected item above the cursor)
@@ -2325,47 +2284,6 @@ chain multiple transform actions where later ones depend on earlier results,
prefer using the \fBbg\fR variant. To cancel currently running background
transform processes, use \fBbg\-cancel\fR action.
.SS WAITING FOR SEARCH COMPLETION
The \fBwait\fR action blocks the execution of subsequent actions until the
current search completes. This is useful when chaining query\-changing actions
with motion actions like \fBbest\fR or \fBfirst\fR, ensuring that the motion
action operates on the complete search results rather than stale data.
e.g.
\fBfzf \-\-bind 'start:change\-query(foo)+wait+best'\fR
In this example, \fBchange\-query(foo)\fR starts an asynchronous search for
the new query, \fBwait\fR blocks until the search completes, and \fBbest\fR
then moves the cursor to the best match in the complete result set.
The initial loading of the input is also considered a search in progress, so
\fBstart:wait\fR can be used to block until the input is fully loaded and
searched.
While waiting, user input is ignored, except for keys bound to \fBabort\fR or
\fBcancel\fR (\fIctrl\-c\fR, \fIctrl\-g\fR, \fIctrl\-q\fR, and \fIesc\fR by
default), which cancel the wait and discard the pending actions instead of
performing their usual role. The remaining actions of such a binding still run,
so a binding like \fBesc:cancel+first\fR is possible.
Asynchronous \fBbg\-transform\-*\fR actions are not affected; their results are
applied as soon as they arrive, even while waiting. For the same reason,
\fBwait\fR does not pair with them: in
\fBbg\-transform\-query(...)+wait\fR, the background command completes only
after \fBwait\fR has already been evaluated, so the search its result
eventually triggers is not waited for. Use the synchronous
\fBtransform\-query(...)\fR variant instead when chaining with \fBwait\fR.
If the search takes long enough, fzf indicates that it is waiting by dimming the
input, hiding the cursor, and showing \fB(..)\fR on the info line. This visual
feedback is debounced so that quick searches do not cause flickering.
Note that when searches are triggered in rapid succession (e.g. via
\fB\-\-listen\fR), \fBwait\fR may unblock on the completion of an earlier
search. Also, if the input source never completes, \fBwait\fR will block until
cancelled.
.SS PREVIEW BINDING
With \fBpreview(...)\fR action, you can specify multiple different preview
+1 -2
View File
@@ -116,8 +116,7 @@ def __fzf_list_hosts [] {
(
# Process ssh config files
$ssh_configs | append $ssh_configs_d | append $ssh_config_global
# NOTE: str lowercase is new from Nushell 0.114.0. Please upgrade to Nushell >=0.114.0 if you are seeing `extra positional argument` error regarding `str lowercase` in this block.
| where {|it| ($it | str lowercase | str starts-with 'host') or ($it | str lowercase | str starts-with 'hostname') }
| where {|it| ($it | str downcase | str starts-with 'host') or ($it | str downcase | str starts-with 'hostname') }
| parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword
| default { hosts: null } # Handle lines that don't match regex
| get hosts
+12 -9
View File
@@ -127,21 +127,14 @@ function fzf_key_bindings
set -l -- total_lines (count $command_line)
set -l -- fzf_query (string escape -- $command_line[$current_line])
set -lx -- FZF_DEFAULT_COMMAND "builtin history -z --show-time=(set_color $fish_color_comment 2>/dev/null; or set_color normal)\"%F %a %T%t%s%t\"(set_color normal)"
# Enable syntax highlighting colors on fish v4.3.3 and newer
if string match -qr -- '^\\d\\d+|^4\\.[4-9]|^4\\.3\\.[3-9]' $version
set -a -- FZF_DEFAULT_COMMAND "--color=always"
end
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \
'--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \
'--bind="alt-enter:become(set -g fzf_temp {+sf3..}; string join0 -- (string split0 -- <$fzf_temp | fish_indent -i); unlink $fzf_temp &>/dev/null)"' \
'--bind="alt-t:change-with-nth(1,3..|3..|2..)"' \
"--bind='shift-delete:execute-silent(eval builtin history delete -Ce -- (string escape -n -- (string split0 -- <{+sf3..})))+reload($FZF_DEFAULT_COMMAND)'" \
'--bind="shift-delete:execute-silent(eval builtin history delete -Ce -- (string escape -n -- (string split0 -- <{+sf3..})))+reload(eval $FZF_DEFAULT_COMMAND)"' \
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --ansi --read0 --print0 --with-shell='(status fish-path)\\ -c)
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --read0 --print0 --with-shell='(status fish-path)\\ -c)
# Add dynamic preview options if preview command isn't already set by user
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
@@ -154,6 +147,16 @@ function fzf_key_bindings
set -lx FZF_DEFAULT_OPTS_FILE
set -lx -- FZF_DEFAULT_COMMAND 'builtin history -z'
# Enable syntax highlighting colors on fish v4.3.3 and newer
if string match -qr -- '^\\d\\d+|^4\\.[4-9]|^4\\.3\\.[3-9]' $version
set -a -- FZF_DEFAULT_OPTS '--ansi'
set -a -- FZF_DEFAULT_COMMAND '--color=always --show-time=(set_color $fish_color_comment)"%F %a %T%t%s%t"(set_color $fish_color_normal)'
else
set -a -- FZF_DEFAULT_COMMAND '--show-time="%F %a %T%t%s%t"'
end
# Merge history from other sessions before searching
test -z "$fish_private_mode"; and builtin history merge
+2 -8
View File
@@ -110,14 +110,8 @@ fzf-cd-widget() {
zle redisplay
return 0
fi
# Use subshell expansion to get the absolute PWD of the target dir.
# This allows the recorded shell history to be reused even from a different
# working directory.
# If failed, fallback to the unexpanded path to surface the error to the user.
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
dir=$(builtin cd -q >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
zle push-line # Clear buffer. Auto-restored on next prompt.
BUFFER="builtin cd -- ${(q)dir}"
BUFFER="builtin cd -- ${(q)dir:a}"
zle accept-line
local ret=$?
unset dir # ensure this doesn't end up appearing in prompt expansion
@@ -139,11 +133,11 @@ fzf-history-widget() {
# as the associative 'history' array, which maps event numbers to full history
# lines, are set. Also, make sure Perl is installed for multi-line output.
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
extracted_with_perl=1
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
extracted_with_perl=1
else
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \
+2 -3
View File
@@ -186,12 +186,11 @@ func _() {
_ = x[actExclude-175]
_ = x[actExcludeMulti-176]
_ = x[actAsync-177]
_ = x[actWait-178]
}
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsyncactWait"
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync"
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 336, 351, 371, 391, 410, 428, 442, 454, 470, 486, 502, 523, 545, 560, 574, 588, 601, 618, 626, 639, 655, 667, 675, 689, 703, 720, 731, 742, 756, 774, 791, 798, 817, 839, 851, 865, 874, 889, 901, 914, 925, 936, 948, 962, 983, 998, 1011, 1028, 1046, 1062, 1074, 1086, 1099, 1114, 1128, 1140, 1152, 1169, 1176, 1188, 1193, 1203, 1212, 1223, 1234, 1247, 1262, 1273, 1286, 1301, 1308, 1321, 1334, 1351, 1366, 1379, 1393, 1407, 1423, 1443, 1467, 1479, 1502, 1519, 1537, 1560, 1578, 1601, 1624, 1646, 1667, 1682, 1701, 1720, 1744, 1762, 1779, 1797, 1807, 1821, 1846, 1865, 1885, 1910, 1930, 1955, 1980, 2004, 2027, 2044, 2065, 2086, 2112, 2132, 2151, 2171, 2182, 2191, 2201, 2214, 2230, 2242, 2256, 2272, 2290, 2310, 2332, 2346, 2361, 2369, 2375, 2389, 2404, 2414, 2430, 2445, 2455, 2462, 2470, 2477, 2486, 2499, 2515, 2530, 2539, 2550, 2559, 2568, 2581, 2590, 2603, 2616, 2623, 2633, 2648, 2656, 2663}
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 336, 351, 371, 391, 410, 428, 442, 454, 470, 486, 502, 523, 545, 560, 574, 588, 601, 618, 626, 639, 655, 667, 675, 689, 703, 720, 731, 742, 756, 774, 791, 798, 817, 839, 851, 865, 874, 889, 901, 914, 925, 936, 948, 962, 983, 998, 1011, 1028, 1046, 1062, 1074, 1086, 1099, 1114, 1128, 1140, 1152, 1169, 1176, 1188, 1193, 1203, 1212, 1223, 1234, 1247, 1262, 1273, 1286, 1301, 1308, 1321, 1334, 1351, 1366, 1379, 1393, 1407, 1423, 1443, 1467, 1479, 1502, 1519, 1537, 1560, 1578, 1601, 1624, 1646, 1667, 1682, 1701, 1720, 1744, 1762, 1779, 1797, 1807, 1821, 1846, 1865, 1885, 1910, 1930, 1955, 1980, 2004, 2027, 2044, 2065, 2086, 2112, 2132, 2151, 2171, 2182, 2191, 2201, 2214, 2230, 2242, 2256, 2272, 2290, 2310, 2332, 2346, 2361, 2369, 2375, 2389, 2404, 2414, 2430, 2445, 2455, 2462, 2470, 2477, 2486, 2499, 2515, 2530, 2539, 2550, 2559, 2568, 2581, 2590, 2603, 2616, 2623, 2633, 2648, 2656}
func (i actionType) String() string {
if i < 0 || i >= actionType(len(_actionType_index)-1) {
+3 -314
View File
@@ -303,9 +303,7 @@ func bonusAt(input *util.Chars, idx int) int16 {
}
func normalizeRune(r rune) rune {
// Every key of the map folds to ASCII, so a rune the bitmap rejects cannot
// be in it. TestNormalizedKeysAreFlagged verifies that.
if !util.MayFoldToAscii(r) {
if r < 0x00C0 || r > 0xFF61 {
return r
}
@@ -347,91 +345,11 @@ func isAscii(runes []rune) bool {
return true
}
// runePrefilterable reports whether scanning the rune array can decide this
// pattern against this item without missing a match. Phase 2 lowercases an
// uppercase text rune and then normalizes it, and the scan sees neither
// transform, so every pattern rune must be unreachable by them.
func runePrefilterable(input *util.Chars, pattern []rune, caseSensitive bool) bool {
if input.MayFoldToAscii() {
// A non-ASCII rune of this item could fold onto an ASCII pattern char
for _, r := range pattern {
if r < utf8.RuneSelf {
return false
}
}
}
if caseSensitive {
// No case transform is applied, and normalization only ever produces
// ASCII, so nothing can reach a non-ASCII pattern rune
return true
}
for _, r := range pattern {
// Another rune must not lowercase onto this one. Being uncased is not
// enough by itself: U+00DF has no simple uppercase yet U+1E9E
// lowercases to it. Excluding the foldable set covers that.
if r >= utf8.RuneSelf &&
(unicode.ToUpper(r) != r || unicode.ToLower(r) != r || util.MayFoldToAscii(r)) {
return false
}
}
return true
}
// runeFuzzyIndex is asciiFuzzyIndex for rune-mode input. Only valid when
// runePrefilterable says so.
func runeFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
runes := input.Runes()
firstIdx, idx, lastIdx := 0, 0, 0
var last rune
for pidx := range pattern {
last = pattern[pidx]
if last < utf8.RuneSelf {
idx = indexAsciiRune(runes, caseSensitive, byte(last), idx)
} else {
idx = indexRune(runes, last, idx)
}
if idx < 0 {
return -1, -1
}
if pidx == 0 && idx > 0 {
// Step back to find the right bonus point
firstIdx = idx - 1
}
lastIdx = idx
idx++
}
// Find the last appearance of the last character of the pattern to limit
// the search scope
if lastIdx+1 < len(runes) {
var end int
if last < utf8.RuneSelf {
end = lastIndexAsciiRune(runes, caseSensitive, byte(last), lastIdx+1)
} else {
end = lastIndexRune(runes, last, lastIdx+1)
}
if end >= 0 {
return firstIdx, end + 1
}
}
return firstIdx, lastIdx + 1
}
func asciiFuzzyIndex(input *util.Chars, pattern []rune, caseSensitive bool) (int, int) {
// Can't determine
if !input.IsBytes() {
if disableRunePrefilter {
return 0, input.Length()
}
// runePrefilterable does not inline, so keep the common case out of
// it: an ASCII pattern against an item that cannot fold to ASCII is
// always scannable, and both of these checks do inline.
if input.MayFoldToAscii() || !isAscii(pattern) {
if !runePrefilterable(input, pattern, caseSensitive) {
return 0, input.Length()
}
}
return runeFuzzyIndex(input, pattern, caseSensitive)
}
// Not possible
if !isAscii(pattern) {
@@ -507,218 +425,6 @@ func debugV2(T []rune, pattern []rune, F []int32, lastIdx int, H []int16, C []in
}
}
// fuzzyMatchV2Single is a fast path for a single-character ASCII pattern on
// ASCII input. Same scoring and tiebreaks as Phase 2 of FuzzyMatchV2, but
// jumps between occurrences instead of scanning every character, and
// allocates no arrays.
func fuzzyMatchV2Single(caseSensitive bool, forward bool, input *util.Chars, b byte, withPos bool) (Result, *[]int) {
byteArray := input.Bytes()
maxScore, maxScorePos := int16(0), -1
for idx := 0; idx < len(byteArray); {
idx = trySkip(input, caseSensitive, b, idx)
if idx < 0 {
break
}
class := asciiCharClasses[byteArray[idx]]
prevClass := initialCharClass
if idx > 0 {
prevClass = asciiCharClasses[byteArray[idx-1]]
}
bonus := bonusMatrix[prevClass][class]
score := scoreMatch + bonus*bonusFirstCharMultiplier
if forward && score > maxScore || !forward && score >= maxScore {
maxScore, maxScorePos = score, idx
if forward && bonus >= bonusBoundary {
break
}
}
idx++
}
if maxScorePos < 0 {
return Result{-1, -1, 0}, nil
}
result := Result{maxScorePos, maxScorePos + 1, int(maxScore)}
if !withPos {
return result, nil
}
pos := []int{maxScorePos}
return result, &pos
}
// Test hooks: force the general path instead of a fast path, so the two can
// be compared for equivalence.
var (
disableSingle bool
disableTwo bool
disableRunePrefilter bool
)
// fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on
// ASCII input. It replicates Phase 2 (row 0) and Phase 3 (row 1) of
// FuzzyMatchV2 in a single pass, carrying the row-0 diagonal/left values and
// the row-1 left value as scalars instead of materializing score arrays.
// When withPos is set, the two DP rows are stored so the backtrace can
// recover the matched character positions, exactly as the general Phase 4.
func fuzzyMatchV2Two(caseSensitive bool, forward bool, input *util.Chars, pchar0 byte, pchar1 byte, minIdx int, maxIdx int, withPos bool, slab *util.Slab) (Result, *[]int) {
sl := input.Bytes()
N := maxIdx - minIdx
// Row storage, only needed for the backtrace
var H0, C0, H1, C1 []int16
if withPos {
o := 0
o, H0 = alloc16(o, slab, N)
o, C0 = alloc16(o, slab, N)
o, H1 = alloc16(o, slab, N)
_, C1 = alloc16(o, slab, N)
}
maxScore, maxScorePos := int16(0), 0
prevClass := initialCharClass
// Subsequence tracking (equivalent to F[0], F[1] in Phase 2). The scope
// from asciiFuzzyIndex ends exactly at the last pchar1, so row 1's upper
// bound (Phase 3 lastIdx) is the final loop position; no separate var.
f0, f1 := -1, -1
// Row 0 running state at the previous position
var h0Prev, c0Prev, bPrev int16
inGap0 := false
// Row 1 running state
var h1Prev int16
inGap1 := false
for off := range N {
pos := minIdx + off
b := sl[pos]
class := asciiCharClasses[b]
lb := b
if !caseSensitive && b >= 'A' && b <= 'Z' {
lb = b + 32
}
bonus := bonusMatrix[prevClass][class]
prevClass = class
// Subsequence advance: pchar0 then pchar1
if f0 < 0 {
if lb == pchar0 {
f0 = off
}
} else if lb == pchar1 && f1 < 0 {
f1 = off
}
// Row 0 (pchar0)
var h0Cur, c0Cur int16
if lb == pchar0 {
h0Cur = scoreMatch + bonus*bonusFirstCharMultiplier
c0Cur = 1
inGap0 = false
} else {
if inGap0 {
h0Cur = max(h0Prev+scoreGapExtension, 0)
} else {
h0Cur = max(h0Prev+scoreGapStart, 0)
}
c0Cur = 0
inGap0 = true
}
if withPos {
H0[off], C0[off] = h0Cur, c0Cur
}
// Row 1 (pchar1), only within [f1, lastIdx]
if f1 >= 0 && off >= f1 {
var s1, s2, consecutive int16
hleft := h1Prev
if off == f1 {
hleft = 0
}
if inGap1 {
s2 = hleft + scoreGapExtension
} else {
s2 = hleft + scoreGapStart
}
if lb == pchar1 {
s1 = h0Prev + scoreMatch
bb := bonus
consecutive = c0Prev + 1
if consecutive > 1 {
fb := bPrev
if bb >= bonusBoundary && bb > fb {
consecutive = 1
} else {
bb = max(bb, bonusConsecutive, fb)
}
}
if s1+bb < s2 {
s1 += bonus
consecutive = 0
} else {
s1 += bb
}
}
inGap1 = s1 < s2
score := max(s1, s2, 0)
if forward && score > maxScore || !forward && score >= maxScore {
maxScore, maxScorePos = score, off
}
h1Prev = score
if withPos {
H1[off], C1[off] = score, consecutive
}
}
h0Prev, c0Prev, bPrev = h0Cur, c0Cur, bonus
}
if f1 < 0 {
return Result{-1, -1, 0}, nil
}
if !withPos {
return Result{minIdx + f0, minIdx + maxScorePos + 1, int(maxScore)}, nil
}
// Phase 4 backtrace, specialized to two rows. Mirrors the general loop:
// record a cell when it dominates its diagonal and left neighbors, then
// step up a row; otherwise step left. preferMatch breaks score ties and
// must not read row 1 left of f1 (unwritten, possibly stale slab data).
pos := posArray(true, 2)
i := 1
j := maxScorePos
preferMatch := true
for {
var s, s1, s2, cCur int16
if i == 1 {
s, cCur = H1[j], C1[j]
if j >= f1 {
s1 = H0[j-1]
}
if j > f1 {
s2 = H1[j-1]
}
} else {
s, cCur = H0[j], C0[j]
if j > f0 {
s2 = H0[j-1]
}
}
row := i
if s > s1 && (s > s2 || s == s2 && preferMatch) {
*pos = append(*pos, j+minIdx)
if i == 0 {
break
}
i--
}
preferMatch = cCur > 1 ||
row == 0 && j < N-1 && j+1 >= f1 && C1[j+1] > 0
j--
}
return Result{minIdx + j, minIdx + maxScorePos + 1, int(maxScore)}, pos
}
func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) {
// Assume that pattern is given in lowercase if case-insensitive.
// First check if there's a match and calculate bonus for each position.
@@ -741,12 +447,6 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
}
// Single-character ASCII pattern needs neither the prefilter nor the
// score matrix
if !disableSingle && M == 1 && input.IsBytes() && pattern[0] < utf8.RuneSelf {
return fuzzyMatchV2Single(caseSensitive, forward, input, byte(pattern[0]), withPos)
}
// Phase 1. Optimized search for ASCII string
minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive)
if minIdx < 0 {
@@ -755,13 +455,6 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
// fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString())
N = maxIdx - minIdx
// Two-character ASCII pattern: rows 0 and 1 collapse to scalar running
// state, so the general score arrays are unnecessary
if !disableTwo && M == 2 && input.IsBytes() &&
pattern[0] < utf8.RuneSelf && pattern[1] < utf8.RuneSelf {
return fuzzyMatchV2Two(caseSensitive, forward, input, byte(pattern[0]), byte(pattern[1]), minIdx, maxIdx, withPos, slab)
}
// Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages
offset16 := 0
offset32 := 0
@@ -937,7 +630,6 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
s2 = H[I+j0-1]
}
row := i
if s > s1 && (s > s2 || s == s2 && preferMatch) {
*pos = append(*pos, j+minIdx)
if i == 0 {
@@ -945,10 +637,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
}
i--
}
// Row below is only written from column F[row+1]; don't read
// stale slab data left of it
preferMatch = C[I+j0] > 1 ||
row+1 < M && j < lastIdx && int32(j+1) >= F[row+1] && C[I+width+j0+1] > 0
preferMatch = C[I+j0] > 1 || I+width+j0+1 < len(C) && C[I+width+j0+1] > 0
j--
}
}
-95
View File
@@ -218,98 +218,3 @@ func TestLongStringWithNormalize(t *testing.T) {
unicodeString := string(bytes) + " Minímal example"
assertMatch2(t, FuzzyMatchV1, false, true, false, unicodeString, "minim", 30001, 30006, 140)
}
func TestResultPositionsWithReusedSlab(t *testing.T) {
// Backtrace positions in equal-score ties must not depend on data
// a previous match left in the slab
pattern := []rune("co/")
target := util.ToChars([]byte("core_color/view/server.txt"))
_, freshPos := FuzzyMatchV2(false, false, true, &target, pattern, true, util.MakeSlab(100*1024, 2048))
slab := util.MakeSlab(100*1024, 2048)
dirty := util.ToChars([]byte("completion/keybinding/client/handler/writer_index.txt"))
FuzzyMatchV2(false, false, true, &dirty, pattern, true, slab)
_, reusedPos := FuzzyMatchV2(false, false, true, &target, pattern, true, slab)
if len(*freshPos) != len(*reusedPos) {
t.Fatalf("position count mismatch: %v vs %v", *freshPos, *reusedPos)
}
for i := range *freshPos {
if (*freshPos)[i] != (*reusedPos)[i] {
t.Errorf("positions differ with reused slab: %v vs %v", *freshPos, *reusedPos)
break
}
}
}
// TestFuzzyMatchV2TwoEquivalence verifies that the two-character fast path
// produces the same Result and positions as the general algorithm across
// case sensitivity, direction, and withPos, using a reused slab to surface
// any stale-data reads in the backtrace.
func TestFuzzyMatchV2TwoEquivalence(t *testing.T) {
words := []string{"src", "main", "core", "config", "parser", "render", "server",
"client", "index", "handler", "util", "list", "cache", "reader"}
exts := []string{".go", ".rb", ".py", ".md", ".c", ".txt"}
// Deterministic corpus (LCG), plus adversarial short/repeated items
corpus := []util.Chars{}
seed := uint32(12345)
next := func(n int) int { seed = seed*1664525 + 1013904223; return int(seed>>8) % n }
for i := 0; i < 4000; i++ {
depth := 2 + next(4)
s := ""
for d := 0; d < depth; d++ {
if d > 0 {
s += "/"
}
s += words[next(len(words))]
if next(5) == 0 {
s += "_" + words[next(len(words))]
}
}
s += exts[next(len(exts))]
corpus = append(corpus, util.ToChars([]byte(s)))
}
for _, s := range []string{"", "a", "ab", "aa", "aXb", "a/b", "//", "..", "abcabc",
"AaBb", "x.y.z", "a_b_c", "CoreCore", " co"} {
corpus = append(corpus, util.ToChars([]byte(s)))
}
pats := []string{"co", "ab", "aa", "//", "..", "sr", "a/", "_c", "oo", "Ab",
"z.", "b.", "1a", "ll", "re", "er", "Co"}
slab := util.MakeSlab(100*1024, 2048)
for _, cs := range []bool{false, true} {
for _, fwd := range []bool{true, false} {
for _, wp := range []bool{false, true} {
for _, p := range pats {
pattern := []rune(p)
for j := range corpus {
disableTwo = true
rg, pg := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
disableTwo = false
rt, pt := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
if rg != rt {
t.Fatalf("Result cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
cs, fwd, wp, p, corpus[j].ToString(), rg, rt)
}
if (pg == nil) != (pt == nil) || (pg != nil && !equalInts(*pg, *pt)) {
t.Fatalf("Pos cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
cs, fwd, wp, p, corpus[j].ToString(), pg, pt)
}
}
}
}
}
}
}
func equalInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
-157
View File
@@ -1,157 +0,0 @@
package algo
// Equivalence tests for the single- and two-character fast paths against the
// general FuzzyMatchV2 algorithm, which serves as the reference.
//
// Two complementary strategies:
// - Exhaustive: every string up to a fixed length over an alphabet that
// covers all ASCII character classes, so every local scoring branch is
// exercised (not merely sampled).
// - Fuzz: coverage-guided, arbitrary length, to reach cases the bounded
// exhaustive sweep cannot (e.g. long gaps where a high score decays).
import (
"testing"
"github.com/junegunn/fzf/src/util"
)
// One char of each ASCII class that affects scoring: lower, upper, delimiter,
// number, non-word, whitespace.
var equivAlphabet = []byte{'a', 'B', '/', '1', '_', ' '}
func samePos(a, b *[]int) bool {
if (a == nil) != (b == nil) {
return false
}
if a == nil {
return true
}
return equalInts(*a, *b)
}
// compareFastPath runs a single input/pattern/params pair through both the
// fast path and the general algorithm and fails on any difference.
// It toggles the package-global disable* hook, so tests that call it must
// not run in parallel.
func compareFastPath(t *testing.T, chars *util.Chars, pattern []rune, cs, norm, fwd, wp bool, disable *bool, slab *util.Slab) {
t.Helper()
*disable = true
rg, pg := FuzzyMatchV2(cs, norm, fwd, chars, pattern, wp, slab)
*disable = false
rt, pt := FuzzyMatchV2(cs, norm, fwd, chars, pattern, wp, slab)
if rg != rt || !samePos(pg, pt) {
t.Fatalf("mismatch item=%q pat=%q cs=%v norm=%v fwd=%v wp=%v: general %v %v vs fast %v %v",
chars.ToString(), string(pattern), cs, norm, fwd, wp, rg, pg, rt, pt)
}
}
// lowerPattern lowercases the pattern for case-insensitive search, matching
// the Algo contract (the pattern is pre-lowercased by BuildPattern).
func lowerPattern(p []rune, cs bool) []rune {
if cs {
return p
}
out := make([]rune, len(p))
for i, c := range p {
if c >= 'A' && c <= 'Z' {
c += 32
}
out[i] = c
}
return out
}
func runExhaustive(t *testing.T, patLen, maxLen int, disable *bool) {
// All patterns of length patLen over the alphabet
var pats [][]rune
var genPat func(cur []rune)
genPat = func(cur []rune) {
if len(cur) == patLen {
pats = append(pats, append([]rune(nil), cur...))
return
}
for _, c := range equivAlphabet {
genPat(append(cur, rune(c)))
}
}
genPat(nil)
slab := util.MakeSlab(100*1024, 2048)
buf := make([]byte, 0, maxLen)
var rec func(depth int)
rec = func(depth int) {
chars := util.ToChars(append([]byte(nil), buf...))
// normalize is not varied: normalizeRune is the identity below 0xC0,
// and the general algorithm skips normalization on its ASCII branch,
// so it cannot change the result for the ASCII-only fast path. The
// fuzz test exercises normalize=true as a guard against that changing.
for _, cs := range []bool{false, true} {
for _, fwd := range []bool{true, false} {
for _, wp := range []bool{false, true} {
for _, p := range pats {
compareFastPath(t, &chars, lowerPattern(p, cs), cs, false, fwd, wp, disable, slab)
}
}
}
}
if depth == maxLen {
return
}
for _, c := range equivAlphabet {
buf = append(buf, c)
rec(depth + 1)
buf = buf[:len(buf)-1]
}
}
rec(0)
}
func TestFuzzyMatchV2SingleExhaustive(t *testing.T) {
runExhaustive(t, 1, 6, &disableSingle)
}
func TestFuzzyMatchV2TwoExhaustive(t *testing.T) {
runExhaustive(t, 2, 6, &disableTwo)
}
func fuzzFastPath(f *testing.F, patLen int, disable *bool) {
inputs := []string{"core_color/view/server.txt", "a b",
"XyZ/123_abc.def", "aaaaaaaaaaaaaaaaaaaaaaaa"}
// Seed patterns of the right length; wrong-length seeds are rejected below
// and would waste the corpus.
seedPats := map[int][]string{1: {"c", "a", "/"}, 2: {"co", "ab", "z1", "aa"}}[patLen]
for _, in := range inputs {
for _, p := range seedPats {
f.Add(in, p)
}
}
slab := util.MakeSlab(200*1024, 4096)
f.Fuzz(func(t *testing.T, input, pat string) {
r := []rune(pat)
if len(r) != patLen {
return
}
for _, c := range r {
if c >= 128 {
return
}
}
chars := util.ToChars([]byte(input))
if !chars.IsBytes() {
return
}
for _, cs := range []bool{false, true} {
for _, norm := range []bool{false, true} {
for _, fwd := range []bool{true, false} {
for _, wp := range []bool{false, true} {
compareFastPath(t, &chars, lowerPattern(r, cs), cs, norm, fwd, wp, disable, slab)
}
}
}
}
})
}
func FuzzFuzzyMatchV2Single(f *testing.F) { fuzzFastPath(f, 1, &disableSingle) }
func FuzzFuzzyMatchV2Two(f *testing.F) { fuzzFastPath(f, 2, &disableTwo) }
-23
View File
@@ -1,23 +0,0 @@
//go:build !386 && !amd64 && !arm64
package algo
// The byte-view scanners in runeindex_x86.go reinterpret a []rune as
// little-endian 4-byte lanes, which is not valid everywhere. Elsewhere the
// reference scanners are the implementation.
func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
return indexAsciiRuneRef(runes, caseSensitive, b, from)
}
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
return lastIndexAsciiRuneRef(runes, caseSensitive, b, from)
}
func indexRune(runes []rune, r rune, from int) int {
return indexRuneRef(runes, r, from)
}
func lastIndexRune(runes []rune, r rune, from int) int {
return lastIndexRuneRef(runes, r, from)
}
-55
View File
@@ -1,55 +0,0 @@
package algo
// Reference scanners over a []rune, with no representation tricks.
//
// They have two roles. Where reinterpreting a []rune as little-endian bytes is
// not valid, they are the shipped implementation, via runeindex_others.go.
// Everywhere else, the tests feed the same inputs to these and to the byte-view
// scanners in runeindex_x86.go and require identical answers.
//
// They carry no build tag so that both roles hold on every platform. Otherwise
// the portable build would be code that nothing here ever runs.
func indexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int {
lower, upper := rune(b), rune(-1)
if !caseSensitive && b >= 'a' && b <= 'z' {
upper = rune(b - 32)
}
for i := from; i < len(runes); i++ {
if runes[i] == lower || runes[i] == upper {
return i
}
}
return -1
}
func lastIndexAsciiRuneRef(runes []rune, caseSensitive bool, b byte, from int) int {
lower, upper := rune(b), rune(-1)
if !caseSensitive && b >= 'a' && b <= 'z' {
upper = rune(b - 32)
}
for i := len(runes) - 1; i >= from; i-- {
if runes[i] == lower || runes[i] == upper {
return i
}
}
return -1
}
func indexRuneRef(runes []rune, r rune, from int) int {
for i := from; i < len(runes); i++ {
if runes[i] == r {
return i
}
}
return -1
}
func lastIndexRuneRef(runes []rune, r rune, from int) int {
for i := len(runes) - 1; i >= from; i-- {
if runes[i] == r {
return i
}
}
return -1
}
-128
View File
@@ -1,128 +0,0 @@
//go:build 386 || amd64 || arm64
package algo
import (
"bytes"
"unsafe"
)
// On these architectures a []rune is a little-endian array of 4-byte lanes, so
// an ASCII rune is the byte itself followed by three zero bytes at a 4-byte
// aligned offset. That lets the SIMD byte scanners run over the rune array
// directly: find the low byte, then confirm alignment and the three zeroes.
// A byte equal to the needle can also appear as the low byte of a multi-byte
// rune (0x0165 has low byte 'e'), which those two checks reject.
func runeBytes(runes []rune) []byte {
return unsafe.Slice((*byte)(unsafe.Pointer(unsafe.SliceData(runes))), len(runes)*4)
}
// indexAsciiRune returns the index of the first rune equal to b, or to its
// uppercase form when ignoring case, at or after rune index from.
func indexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
view := runeBytes(runes)
both := !caseSensitive && b >= 'a' && b <= 'z'
for off := from * 4; off < len(view); {
var idx int
if both {
idx = IndexByteTwo(view[off:], b, b-32)
} else {
idx = bytes.IndexByte(view[off:], b)
}
if idx < 0 {
return -1
}
pos := off + idx
if pos&3 == 0 && view[pos+1]|view[pos+2]|view[pos+3] == 0 {
return pos >> 2
}
off = pos + 1
}
return -1
}
// runeNeedle picks which of the rune's four bytes to scan for, and returns its
// lane index and value. A zero byte is a useless needle because every ASCII
// rune contributes three of them, so U+AE00 scanned by its low byte would hit
// on almost every character of an ASCII-heavy line. Prefer a byte that cannot
// occur in an ASCII rune at all, then any non-zero byte.
func runeNeedle(r rune) (int, byte) {
var b [4]byte
b[0], b[1], b[2], b[3] = byte(r), byte(r>>8), byte(r>>16), byte(r>>24)
for i, v := range b {
if v >= 0x80 {
return i, v
}
}
for i, v := range b {
if v != 0 {
return i, v
}
}
return 0, 0
}
func runeAt(view []byte, start int) rune {
return rune(view[start]) | rune(view[start+1])<<8 | rune(view[start+2])<<16 | rune(view[start+3])<<24
}
// indexRune returns the index of the first rune equal to r at or after rune
// index from. Case is not folded, so the caller must have established that no
// other rune can transform into r.
func indexRune(runes []rune, r rune, from int) int {
view := runeBytes(runes)
lane, needle := runeNeedle(r)
for off := from*4 + lane; off < len(view); {
idx := bytes.IndexByte(view[off:], needle)
if idx < 0 {
return -1
}
pos := off + idx
if start := pos - lane; start&3 == 0 && runeAt(view, start) == r {
return start >> 2
}
off = pos + 1
}
return -1
}
// lastIndexRune is indexRune scanning backwards from the end.
func lastIndexRune(runes []rune, r rune, from int) int {
view := runeBytes(runes)
lane, needle := runeNeedle(r)
for end := len(view); end > from*4+lane; {
idx := bytes.LastIndexByte(view[from*4+lane:end], needle)
if idx < 0 {
return -1
}
pos := from*4 + lane + idx
if start := pos - lane; start&3 == 0 && runeAt(view, start) == r {
return start >> 2
}
end = pos
}
return -1
}
// lastIndexAsciiRune is indexAsciiRune scanning backwards from the end.
func lastIndexAsciiRune(runes []rune, caseSensitive bool, b byte, from int) int {
view := runeBytes(runes)[from*4:]
both := !caseSensitive && b >= 'a' && b <= 'z'
for end := len(view); end > 0; {
var idx int
if both {
idx = lastIndexByteTwo(view[:end], b, b-32)
} else {
idx = bytes.LastIndexByte(view[:end], b)
}
if idx < 0 {
return -1
}
if idx&3 == 0 && view[idx+1]|view[idx+2]|view[idx+3] == 0 {
return from + idx>>2
}
end = idx
}
return -1
}
-479
View File
@@ -1,479 +0,0 @@
package algo
// Correctness tests for the rune-array prefilter (Step C).
//
// The prefilter may narrow the search scope but must never change a Result or
// its positions, and must never reject an item the general path would match.
// Each result is compared against the same code with the prefilter disabled.
import (
"math/rand"
"strings"
"testing"
"unicode"
"unicode/utf8"
"github.com/junegunn/fzf/src/util"
)
// foldForTest mirrors what Phase 2 does to a non-ASCII text rune: lowercase if
// uppercase, then normalize.
func foldForTest(r rune, normalize bool) rune {
if charClassOfNonAscii(r) == charUpper {
r = unicode.To(unicode.LowerCase, r)
}
if normalize {
r = normalizeRune(r)
}
return r
}
// The prefilter is only safe on items whose runes cannot become ASCII. This
// verifies util.MayFoldToAscii as a superset of the runes that actually can,
// over the whole Unicode range and both normalization modes. If normalize.go
// or the Go unicode tables change, this fails.
func TestMayFoldToAsciiIsSuperset(t *testing.T) {
missed := 0
for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ {
if r >= 0xD800 && r <= 0xDFFF {
continue
}
for _, normalize := range []bool{true, false} {
if foldForTest(r, normalize) < utf8.RuneSelf && !util.MayFoldToAscii(r) {
if missed++; missed < 10 {
t.Errorf("U+%04X folds to ASCII (normalize=%v) but MayFoldToAscii is false", r, normalize)
}
}
}
}
if missed > 0 {
t.Fatalf("%d runes fold to ASCII without being flagged", missed)
}
}
// Scripts that must stay unflagged, otherwise the prefilter never runs for
// them and Step C has no effect.
func TestMayFoldToAsciiExcludesMajorScripts(t *testing.T) {
for _, s := range []struct {
name string
lo, hi rune
}{
{"Cyrillic", 0x0400, 0x04FF}, {"Greek", 0x0370, 0x03FF}, {"Hebrew", 0x0590, 0x05FF},
{"Arabic", 0x0600, 0x06FF}, {"Thai", 0x0E00, 0x0E7F}, {"Devanagari", 0x0900, 0x097F},
{"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF},
{"box drawing", 0x2500, 0x257F}, {"emoji", 0x1F300, 0x1FAFF},
// These sit between the Latin blocks and were included in an earlier,
// wider grouping of foldableRanges. General Punctuation matters most:
// curly quotes, en and em dashes and the ellipsis are in it.
{"Greek Extended", 0x1F00, 0x1FFF}, {"General Punctuation", 0x2000, 0x206F},
{"Currency Symbols", 0x20A0, 0x20CF}, {"CJK Symbols", 0x3000, 0x303F},
} {
for r := s.lo; r <= s.hi; r++ {
if util.MayFoldToAscii(r) {
t.Errorf("%s U+%04X should not be flagged foldable", s.name, r)
break
}
}
}
}
// The byte-view scan must agree with the shipped reference scanners. The interesting inputs
// are runes whose low byte collides with the needle (U+0165 has low byte 'e')
// and runes sharing a lane offset, which the alignment and zero checks reject.
func TestIndexAsciiRuneMatchesReference(t *testing.T) {
rng := rand.New(rand.NewSource(3))
alphabet := []rune{'a', 'A', 'e', 'E', '/', '1', 0x0165, 0x00E9, 0x4E00, 0xD55C,
0x1F389, 0x0065 + 0x100, 0x0041 + 0x100, 0x2F65}
for trial := range 20000 {
n := rng.Intn(24)
runes := make([]rune, n)
for i := range runes {
runes[i] = alphabet[rng.Intn(len(alphabet))]
}
b := []byte{'a', 'e', 'A', 'E', '/', '1'}[rng.Intn(6)]
cs := rng.Intn(2) == 0
from := 0
if n > 0 {
from = rng.Intn(n)
}
if got, exp := indexAsciiRune(runes, cs, b, from), indexAsciiRuneRef(runes, cs, b, from); got != exp {
t.Fatalf("trial %d: indexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp)
}
if got, exp := lastIndexAsciiRune(runes, cs, b, from), lastIndexAsciiRuneRef(runes, cs, b, from); got != exp {
t.Fatalf("trial %d: lastIndexAsciiRune(%U, cs=%v, %q, %d) = %d, expected %d", trial, runes, cs, b, from, got, exp)
}
}
}
// Differential test: the prefilter must not change any Result or position.
// Corpora deliberately mix scripts that clear the foldable bit (CJK, Hangul,
// Cyrillic, emoji) with scripts that set it (accented Latin, fullwidth), so
// both the engaged and the bypassed path are exercised.
func TestRunePrefilterEquivalence(t *testing.T) {
t.Cleanup(func() { disableRunePrefilter = false })
rng := rand.New(rand.NewSource(4))
parts := []string{
"src", "util", "conf", "a", "e", "E", "A", "/", "_", "1", " ",
"漢字", "한글", "мир", "ελλ", "🎉", "café", "Müller", "naïve", "full",
"Å", "İ", "K", "ǰ", "ff",
}
patterns := []string{"a", "e", "conf", "src/util", "ae", "A", "E", "K", "k", "i", "//", "zz", "s l",
// non-ASCII patterns, the Step G path
"漢", "漢字", "한", "한글", "мир", "м", "ελλ", "🎉", "é", "ß", "Å", "İ", "",
"漢a", "a漢", "한글/src", "🎉e"}
slab := util.MakeSlab(100*1024, 2048)
engaged, bypassed := 0, 0
for trial := range 30000 {
var sb strings.Builder
for range 1 + rng.Intn(8) {
sb.WriteString(parts[rng.Intn(len(parts))])
}
chars := util.ToChars([]byte(sb.String()))
if chars.IsBytes() {
continue
}
if chars.MayFoldToAscii() {
bypassed++
} else {
engaged++
}
pat := patterns[rng.Intn(len(patterns))]
cs := rng.Intn(2) == 0
if !cs {
pat = strings.ToLower(pat)
}
pattern := []rune(pat)
norm := rng.Intn(2) == 0
fwd := rng.Intn(2) == 0
wp := rng.Intn(2) == 0
disableRunePrefilter = true
expR, expP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
disableRunePrefilter = false
gotR, gotP := FuzzyMatchV2(cs, norm, fwd, &chars, pattern, wp, slab)
if gotR != expR || !samePos(gotP, expP) {
t.Fatalf("trial %d: %q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v",
trial, sb.String(), pat, cs, norm, fwd, wp, gotR, gotP, expR, expP)
}
}
disableRunePrefilter = false
t.Logf("prefilter engaged on %d items, bypassed on %d", engaged, bypassed)
if engaged == 0 || bypassed == 0 {
t.Fatalf("corpus did not exercise both paths (engaged=%d bypassed=%d)", engaged, bypassed)
}
// Equivalence alone would still hold if the prefilter never filtered
// anything, so confirm it both rejects and narrows.
rejected, narrowed := 0, 0
for range 5000 {
var sb strings.Builder
for range 1 + rng.Intn(8) {
sb.WriteString(parts[rng.Intn(len(parts))])
}
chars := util.ToChars([]byte(sb.String()))
if chars.IsBytes() || chars.MayFoldToAscii() {
continue
}
pattern := []rune(patterns[rng.Intn(len(patterns))])
lo, hi := asciiFuzzyIndex(&chars, pattern, false)
switch {
case lo < 0:
rejected++
case hi-lo < chars.Length():
narrowed++
}
}
t.Logf("prefilter rejected %d items, narrowed scope on %d", rejected, narrowed)
if rejected == 0 {
t.Fatal("prefilter never rejected an item, so equivalence proves nothing")
}
if narrowed == 0 {
t.Fatal("prefilter never narrowed the scope")
}
}
// FuzzyMatchV1 shares asciiFuzzyIndex, so it needs the same guarantee.
func TestRunePrefilterEquivalenceV1(t *testing.T) {
t.Cleanup(func() { disableRunePrefilter = false })
rng := rand.New(rand.NewSource(5))
parts := []string{"src", "conf", "a", "e", "/", "漢字", "한글", "мир", "café", "Å", "🎉"}
slab := util.MakeSlab(100*1024, 2048)
for trial := range 20000 {
var sb strings.Builder
for range 1 + rng.Intn(6) {
sb.WriteString(parts[rng.Intn(len(parts))])
}
chars := util.ToChars([]byte(sb.String()))
if chars.IsBytes() {
continue
}
pattern := []rune([]string{"a", "e", "conf", "src", "ae", "zz"}[rng.Intn(6)])
cs, norm, fwd, wp := rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0, rng.Intn(2) == 0
disableRunePrefilter = true
expR, expP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
disableRunePrefilter = false
gotR, gotP := FuzzyMatchV1(cs, norm, fwd, &chars, pattern, wp, slab)
if gotR != expR || !samePos(gotP, expP) {
t.Fatalf("trial %d: %q pattern=%q\n prefilter on: %v %v\n prefilter off: %v %v",
trial, sb.String(), string(pattern), gotR, gotP, expR, expP)
}
}
disableRunePrefilter = false
}
// normalizeRune skips the map when util.MayFoldToAscii rejects the rune. That
// is only sound if every key of the map is flagged, since a flagged-false rune
// is returned unchanged.
func TestNormalizedKeysAreFlagged(t *testing.T) {
for k := range normalized {
if !util.MayFoldToAscii(k) {
t.Errorf("normalized key U+%04X (%c) is not flagged by MayFoldToAscii", k, k)
}
}
}
// Guarding normalizeRune must not change what it returns, for any rune.
func TestNormalizeRuneUnchangedByGuard(t *testing.T) {
for r := rune(0); r <= unicode.MaxRune; r++ {
if r >= 0xD800 && r <= 0xDFFF {
continue
}
exp := r
if r >= 0x00C0 && r <= 0xFF61 {
if n := normalized[r]; n > 0 {
exp = n
}
}
if got := normalizeRune(r); got != exp {
t.Fatalf("normalizeRune(U+%04X) = U+%04X, expected U+%04X", r, got, exp)
}
}
}
// Step G lets non-ASCII pattern runes use the scan, but only when no other
// rune can transform into them. Being uncased is not sufficient: U+00DF has no
// simple uppercase yet U+1E9E lowercases onto it. This checks the guard against
// the full preimage relation over all of Unicode.
func TestRunePrefilterableGuardIsSound(t *testing.T) {
preimage := map[rune][]rune{}
for r := rune(0); r <= unicode.MaxRune; r++ {
if r >= 0xD800 && r <= 0xDFFF {
continue
}
if charClassOfNonAscii(r) == charUpper {
if l := unicode.To(unicode.LowerCase, r); l != r {
preimage[l] = append(preimage[l], r)
}
}
}
clean := util.ToChars([]byte("漢字")) // rune mode, fold bit clear
admitted, violations := 0, 0
for r := rune(utf8.RuneSelf); r <= unicode.MaxRune; r++ {
if r >= 0xD800 && r <= 0xDFFF {
continue
}
if !runePrefilterable(&clean, []rune{r}, false) {
continue
}
admitted++
if extra := preimage[r]; len(extra) > 0 {
violations++
if violations <= 5 {
t.Errorf("guard admits U+%04X but %U lowercases onto it", r, extra)
}
}
}
t.Logf("guard admits %d non-ASCII pattern runes, unsound for %d", admitted, violations)
// The scripts this step exists for must be fully admitted.
for _, s := range []struct {
name string
lo, hi rune
}{{"CJK", 0x4E00, 0x9FFF}, {"Hangul", 0xAC00, 0xD7A3}, {"kana", 0x3040, 0x30FF},
{"Thai", 0x0E00, 0x0E7F}, {"emoji", 0x1F300, 0x1FAFF}} {
for r := s.lo; r <= s.hi; r++ {
if !runePrefilterable(&clean, []rune{r}, false) {
t.Errorf("%s U+%04X should be admitted", s.name, r)
break
}
}
}
}
// The Step G path must actually run and reject, otherwise the equivalence
// test above proves nothing about non-ASCII patterns.
func TestNonAsciiPatternPrefilterEngages(t *testing.T) {
rng := rand.New(rand.NewSource(6))
parts := []string{"漢字", "한글", "src", "conf", "/", "мир", "🎉"}
engaged, rejected, narrowed, bypassed := 0, 0, 0, 0
for range 5000 {
var sb strings.Builder
for range 1 + rng.Intn(6) {
sb.WriteString(parts[rng.Intn(len(parts))])
}
chars := util.ToChars([]byte(sb.String()))
if chars.IsBytes() {
continue
}
pattern := []rune([]string{"漢", "漢字", "한글", "мир", "🎉", "é", "ß"}[rng.Intn(7)])
if !runePrefilterable(&chars, pattern, false) {
bypassed++
continue
}
engaged++
lo, hi := asciiFuzzyIndex(&chars, pattern, false)
switch {
case lo < 0:
rejected++
case hi-lo < chars.Length():
narrowed++
}
}
t.Logf("non-ASCII patterns: engaged %d, bypassed %d, rejected %d, narrowed %d",
engaged, bypassed, rejected, narrowed)
if engaged == 0 || rejected == 0 {
t.Fatalf("non-ASCII pattern path not exercised (engaged=%d rejected=%d)", engaged, rejected)
}
if bypassed == 0 {
t.Fatal("cased and foldable patterns should still bypass")
}
}
// indexRune picks which byte lane to scan, and is checked against the shipped
// reference scanners rather than a copy of them. Zero-low-byte runes (U+AE00) and
// runes whose lanes collide with common ASCII bytes are the cases that break a
// naive low-byte scan, so the alphabet includes both.
func TestIndexRuneMatchesReference(t *testing.T) {
alphabet := []rune{
'a', 'e', 'N', '/', 0x00,
0xAE00, 0xAC00, 0xD55C, // Hangul, low byte zero for U+AE00
0x4E00, 0x6587, 0x9FFF, // CJK, U+4E00 has zero low byte
0x3040, 0x0E00, // kana, Thai with zero low byte
0x1F389, 0x1F300, // emoji, 3 significant bytes
0x0100, 0x0165, 0x00E9, // low byte zero / ASCII-colliding lanes
}
rng := rand.New(rand.NewSource(7))
for trial := range 30000 {
n := rng.Intn(20)
runes := make([]rune, n)
for i := range runes {
runes[i] = alphabet[rng.Intn(len(alphabet))]
}
r := alphabet[rng.Intn(len(alphabet))]
from := 0
if n > 0 {
from = rng.Intn(n)
}
if got, exp := indexRune(runes, r, from), indexRuneRef(runes, r, from); got != exp {
t.Fatalf("trial %d: indexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp)
}
if got, exp := lastIndexRune(runes, r, from), lastIndexRuneRef(runes, r, from); got != exp {
t.Fatalf("trial %d: lastIndexRune(%U, U+%04X, %d) = %d, expected %d", trial, runes, r, from, got, exp)
}
}
}
// preparePattern mirrors what pattern.go guarantees the algo functions:
// lowercased when case-insensitive, normalized when normalize is on.
func preparePattern(pat string, caseSensitive, normalize bool) []rune {
if !caseSensitive {
pat = strings.ToLower(pat)
}
r := []rune(pat)
if normalize {
r = NormalizeRunes(r)
}
return r
}
// FuzzRunePrefilter drives arbitrary rune-mode input and arbitrary patterns
// through the prefilter and through the same code with it disabled, and
// requires identical Results and positions. The existing fast-path fuzzers
// only generate byte-mode input, so they never reach this path.
func FuzzRunePrefilter(f *testing.F) {
for _, in := range []string{
"한글/src/util.go", "漢字/conf", "café/binutils", "мир/test", "🎉/a",
"ABC.txt", "Ångström", "ǰ/ß/İ", "a漢b한c", "Āā",
} {
for _, p := range []string{"a", "conf", "漢", "한글", "мир", "ß", "É", "a漢"} {
f.Add(in, p)
}
}
slab := util.MakeSlab(200*1024, 4096)
f.Fuzz(func(t *testing.T, input, pat string) {
if len(input) > 512 || len(pat) == 0 || len(pat) > 32 {
return
}
chars := util.ToChars([]byte(input))
if chars.IsBytes() {
return // byte mode is covered by the existing fuzzers
}
for _, cs := range []bool{false, true} {
for _, norm := range []bool{false, true} {
p := preparePattern(pat, cs, norm)
if len(p) == 0 {
continue
}
for _, fwd := range []bool{true, false} {
for _, wp := range []bool{false, true} {
for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive} {
disableRunePrefilter = true
expR, expP := fn(cs, norm, fwd, &chars, p, wp, slab)
disableRunePrefilter = false
gotR, gotP := fn(cs, norm, fwd, &chars, p, wp, slab)
if gotR != expR || !samePos(gotP, expP) {
t.Fatalf("input=%q pattern=%q cs=%v norm=%v fwd=%v wp=%v\n prefilter on: %v %v\n prefilter off: %v %v",
input, pat, cs, norm, fwd, wp, gotR, gotP, expR, expP)
}
}
}
}
}
}
})
}
// RunesToChars can produce rune-mode Chars holding zero runes, which sends a
// nil pointer through unsafe.SliceData in runeBytes. ToChars cannot produce
// this (an empty input is byte mode), so it needs its own test.
func TestEmptyRuneModeChars(t *testing.T) {
t.Cleanup(func() { disableRunePrefilter = false })
slab := util.MakeSlab(100*1024, 2048)
for _, runes := range [][]rune{{}, nil, {'a'}, {0x4E00}} {
chars := util.RunesToChars(runes)
if chars.IsBytes() {
continue
}
for _, pat := range []string{"a", "漢", "ab"} {
p := []rune(pat)
for _, fn := range []Algo{FuzzyMatchV2, FuzzyMatchV1, ExactMatchNaive,
PrefixMatch, SuffixMatch, EqualMatch} {
disableRunePrefilter = true
expR, expP := fn(false, true, true, &chars, p, true, slab)
disableRunePrefilter = false
gotR, gotP := fn(false, true, true, &chars, p, true, slab)
if gotR != expR || !samePos(gotP, expP) {
t.Errorf("runes=%U pat=%q: prefilter on %v %v, off %v %v", runes, pat, gotR, gotP, expR, expP)
}
}
}
}
}
// MayFoldToAscii subtracts before bounds-checking, so a rune below the range
// (including a negative one, which utf8 decoding never produces but callers
// could construct) must not wrap into a false positive.
func TestMayFoldToAsciiOutOfRange(t *testing.T) {
for _, r := range []rune{-1, -0x10000, 0, 'a', 0x7F, 0xBF, 0xFF62, unicode.MaxRune, unicode.MaxRune + 1} {
if util.MayFoldToAscii(r) {
t.Errorf("MayFoldToAscii(%d) = true, expected false", r)
}
}
}
+8 -13
View File
@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui"
@@ -75,7 +76,7 @@ Usage: fzf [options]
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
Add '+' to automatically increase the value
according to the other layout options (default: 10+).
--popup[=OPTS] Start fzf in a floating pane (requires tmux 3.3+ or Zellij 0.44+)
--popup[=OPTS] Start fzf in a popup window (requires tmux 3.3+ or Zellij 0.44+)
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
[,border-native] (default: center,50%)
--tmux[=OPTS] Alias for --popup
@@ -427,7 +428,7 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
var err error
opts := defaultTmuxOptions(index)
tokens := splitRegexp.Split(arg, -1)
errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native])")
errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%][,border-native]])")
if len(tokens) == 0 || len(tokens) > 4 {
return nil, errorToReturn
}
@@ -1063,8 +1064,6 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
add(tui.Focus)
case "result":
add(tui.Result)
case "result-final":
add(tui.ResultFinal)
case "resize":
add(tui.Resize)
case "one":
@@ -1735,10 +1734,10 @@ Loop:
return masked
}
func parseSingleActionList(str string, putAllowed bool) ([]*action, error) {
func parseSingleActionList(str string) ([]*action, error) {
// We prepend a colon to satisfy argActionRegexp and remove it later
masked := maskActionContents(":" + str)[1:]
return parseActionList(masked, str, []*action{}, putAllowed)
return parseActionList(masked, str, []*action{}, false)
}
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) {
@@ -1958,8 +1957,6 @@ func parseActionList(masked string, original string, prevActions []*action, putA
} else {
return nil, errors.New("unable to put non-printable character")
}
case "wait":
appendAction(actWait)
case "bell":
appendAction(actBell)
case "exclude":
@@ -2046,7 +2043,8 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) error {
}
key = firstKey(keys)
}
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], key.Printable())
putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char)
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed)
if err != nil {
return err
}
@@ -3696,10 +3694,7 @@ func (opts *Options) noSeparatorLine() bool {
if opts.Inputless {
return true
}
// NOTE: This does not know that the default separator can be suppressed at
// runtime (see Terminal.separatorLength), so the minimum heights derived
// from it can be one line taller than strictly necessary.
sep := opts.Separator == nil && !inputBorderFacesList(opts.Layout, opts.InputBorderShape) || opts.Separator != nil && len(*opts.Separator) > 0
sep := opts.Separator == nil && !opts.InputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0
return noSeparatorLine(opts.InfoStyle, sep)
}
+2 -2
View File
@@ -572,7 +572,7 @@ func TestValidateSign(t *testing.T) {
}
func TestParseSingleActionList(t *testing.T) {
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", false)
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down")
if len(actions) != 4 {
t.Errorf("Invalid number of actions parsed:%d", len(actions))
}
@@ -588,7 +588,7 @@ func TestParseSingleActionList(t *testing.T) {
}
func TestParseSingleActionListError(t *testing.T) {
_, err := parseSingleActionList("change-query(foobar)baz", false)
_, err := parseSingleActionList("change-query(foobar)baz")
if err == nil {
t.Errorf("Failed to detect error")
}
+3 -82
View File
@@ -11,7 +11,6 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/junegunn/fzf/src/tui"
@@ -20,75 +19,10 @@ import (
const becomeSuffix = ".become"
// Withheld from the environment replay below, so an outer value cannot
// override what the floating pane set
const internalEnvPrefix = "__FZF_INTERNAL_"
func escapeSingleQuote(str string) string {
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
}
// Read and remove, so preview and execute commands do not inherit it
func takeEnv(name string) string {
value := os.Getenv(name)
os.Unsetenv(name)
return value
}
// Applies label updates off the event loop, which would block on the fork
type labelUpdater struct {
set func(string)
mu sync.Mutex
pending *string
running bool
}
// A queue of one: a burst collapses to the newest label. A goroutine per
// update could apply them out of order
func (u *labelUpdater) update(label string) {
u.mu.Lock()
defer u.mu.Unlock()
u.pending = &label
if u.running {
return
}
u.running = true
go u.run()
}
// Ends once caught up, so nothing needs cancelling when Run returns
func (u *labelUpdater) run() {
for {
u.mu.Lock()
label := u.pending
if label == nil {
u.running = false
u.mu.Unlock()
return
}
u.pending = nil
u.mu.Unlock()
u.set(*label)
}
}
// Updates the label on the native border, or nil when it is not fzf's to update
func nativeLabelSetter() func(string) {
// Read both, so neither is left behind
tmuxPane, zellijPane := takeEnv(tmuxBorderLabelEnv), takeEnv(zellijBorderLabelEnv)
var set func(string)
switch {
case tmuxPane != "":
set = func(label string) { setTmuxBorderLabel(tmuxPane, label) }
case zellijPane != "":
set = func(label string) { setZellijBorderLabel(zellijPane, label) }
default:
return nil
}
updater := &labelUpdater{set: set}
return updater.update
}
func popupArgStr(args []string, opts *Options) (string, string) {
fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"}
@@ -191,9 +125,6 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
for _, pairStr := range os.Environ() {
pair := strings.SplitN(pairStr, "=", 2)
if strings.HasPrefix(pair[0], internalEnvPrefix) {
continue
}
if validIdentifier.MatchString(pair[0]) {
exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1])))
} else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") {
@@ -207,17 +138,6 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
temp := WriteTemporaryFile(append(exports, command), "\n")
defer os.Remove(temp)
// Pre-create the become file so that another user on a shared TMPDIR
// cannot plant a file or a symbolic link at the predictable path while
// fzf is running
becomeFile := temp + becomeSuffix
becomeF, err := os.OpenFile(becomeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return ExitError, err
}
becomeF.Close()
defer os.Remove(becomeFile)
cmd, err := cmdBuilder(temp, needBash)
if err != nil {
return ExitError, err
@@ -235,15 +155,16 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
if exitError, ok := err.(*exec.ExitError); ok {
code := exitError.ExitCode()
if code == ExitBecome {
becomeFile := temp + becomeSuffix
data, err := os.ReadFile(becomeFile)
os.Remove(becomeFile)
if err != nil {
return ExitError, err
}
if len(data) == 0 {
elems := strings.Split(string(data), "\x00")
if len(elems) < 1 {
return ExitError, errors.New("invalid become command")
}
elems := strings.Split(string(data), "\x00")
command := elems[0]
env := []string{}
if len(elems) > 1 {
-65
View File
@@ -1,65 +0,0 @@
package fzf
import (
"strconv"
"sync"
"testing"
"time"
)
func TestLabelUpdater(t *testing.T) {
var mu sync.Mutex
applied := []int{}
// The goroutine can still be running after the test function returns, so
// it records the label instead of reporting it, and -1 stands in for one
// that could not be parsed
updater := &labelUpdater{set: func(label string) {
n, err := strconv.Atoi(label)
if err != nil {
n = -1
}
mu.Lock()
applied = append(applied, n)
mu.Unlock()
// Long enough for the burst to pile up behind the first update
time.Sleep(time.Millisecond)
}}
const last = 99
for i := 0; i <= last; i++ {
updater.update(strconv.Itoa(i))
}
// However many updates are dropped, the final one is always applied
deadline := time.Now().Add(5 * time.Second)
for {
mu.Lock()
done := len(applied) > 0 && applied[len(applied)-1] == last
mu.Unlock()
if done {
break
}
if time.Now().After(deadline) {
t.Fatalf("last update was not applied: %v", applied)
}
time.Sleep(time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
// Updates are never applied out of order, so the border cannot be left
// showing a label older than the last one requested
for i, n := range applied {
if i > 0 && n <= applied[i-1] {
t.Errorf("applied out of order: %v", applied)
break
}
}
// A burst collapses instead of running one command per update. The
// updates are queued in a tight loop while each one takes a millisecond
// to apply, so all of them landing would mean no coalescing at all
if len(applied) >= last+1 {
t.Errorf("expected updates to be coalesced, applied all %d", len(applied))
}
t.Logf("applied %d of %d updates", len(applied), last+1)
}
+1 -16
View File
@@ -198,10 +198,7 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
start := 0
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
if !theme.Colored {
// Ignore ANSI colors but keep the attributes. Retain the base
// colors (e.g. an overridden input-bg or list-bg) instead of
// resetting to the terminal default.
return tui.NewColorPair(base.Fg(), base.Bg(), ansi.color.attr).MergeAttr(base)
return tui.NewColorPair(-1, -1, ansi.color.attr).MergeAttr(base)
}
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
if base.ShouldStripColors() {
@@ -369,21 +366,9 @@ func radixSortResults(a []Result, tac bool, scratch []Result) []Result {
src, dst := a, buf
scattered := 0
// OR of all keys: a byte position that is zero here is zero in every key,
// so its pass is a no-op and can be skipped without a histogram scan.
// With the common two-criteria setup the low 32 bits are always zero.
var keyOR uint64
for i := range src {
keyOR |= sortKey(&src[i])
}
for pass := range 8 {
shift := uint(pass) * 8
if byte(keyOR>>shift) == 0 {
continue
}
var count [256]int
for i := range src {
count[byte(sortKey(&src[i])>>shift)]++
+1 -1
View File
@@ -240,7 +240,7 @@ Loop:
}
body = body[:contentLength]
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"), false)
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"))
if err != nil {
return bad(err.Error())
}
+40 -514
View File
@@ -57,12 +57,8 @@ var offsetComponentRegex *regexp.Regexp
var offsetTrimCharsRegex *regexp.Regexp
var passThroughBeginRegex *regexp.Regexp
var passThroughEndTmuxRegex *regexp.Regexp
var sixelBeginRegex *regexp.Regexp
var cursorBackRegex *regexp.Regexp
var ttyin *os.File
var inTmux = len(os.Getenv("TMUX")) > 0
const clearCode string = "\x1b[2J"
// Number of maximum focus events to process synchronously
@@ -72,10 +68,6 @@ const maxFocusEvents = 10000
// After this duration, users can press CTRL-C to terminate the command.
const blockDuration = 1 * time.Second
// Skip exporting FZF_CURRENT_ITEM when the item is larger than this, so a huge
// item cannot overflow ARG_MAX and break exec for preview and other commands.
const maxCurrentItemEnvSize = 64 * 1024
func init() {
placeholder = regexp.MustCompile(`\\?(?:{[+*sfr]*[0-9,-.]*}|{q(?::s?[0-9,-.]+)?}|{fzf:(?:query|action|prompt)}|{[+*]?f?nf?})`)
whiteSuffix = regexp.MustCompile(`\s*$`)
@@ -96,10 +88,6 @@ func init() {
*/
passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`)
passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`)
sixelBeginRegex = regexp.MustCompile(`^\x1bP[0-9;]*q`)
// CUB right before an IND, used to return to the column a row started on
cursorBackRegex = regexp.MustCompile(`\x1b\[([0-9]*)D$`)
}
type jumpMode int
@@ -152,13 +140,6 @@ type quitSignal struct {
err error
}
type waitState struct {
blocked bool
blockedAt time.Time
pending []*action
searching bool // a search is in progress or the input is still loading
}
type previewer struct {
version int64
lines []string
@@ -279,7 +260,6 @@ type Terminal struct {
ghost string
separator labelPrinter
separatorLen int
separatorAuto bool
spinner []string
promptString string
prompt func()
@@ -341,7 +321,6 @@ type Terminal struct {
trackBlocked bool
trackSync bool
trackKeyCache map[int32]bool
wait waitState
pendingSelections map[string]selectedItem
targetIndex int32
delimiter Delimiter
@@ -476,7 +455,6 @@ type Terminal struct {
clickFooterLine int
clickFooterColumn int
proxyScript string
setNativeLabel func(string)
numLinesCache map[int32]numLinesCacheValue
raw bool
lastActivity time.Time
@@ -742,7 +720,6 @@ const (
actExclude
actExcludeMulti
actAsync
actWait
)
func (a actionType) Name() string {
@@ -894,10 +871,8 @@ func defaultKeymap() map[tui.Event][]*action {
addEvent(tui.AltKey('b'), actBackwardWord)
add(tui.ShiftLeft, actBackwardWord)
add(tui.AltLeft, actBackwardWord)
addEvent(tui.AltKey('f'), actForwardWord)
add(tui.ShiftRight, actForwardWord)
add(tui.AltRight, actForwardWord)
addEvent(tui.AltKey('d'), actKillWord)
add(tui.AltBackspace, actBackwardKillWord)
@@ -1149,7 +1124,6 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
printer: opts.Printer,
printsep: opts.PrintSep,
proxyScript: opts.ProxyScript,
setNativeLabel: nativeLabelSetter(),
merger: em,
passMerger: em,
resultMerger: em,
@@ -1178,7 +1152,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
bgSemaphore: make(chan struct{}, maxBgProcesses),
bgSemaphores: make(map[action]chan struct{}),
keyChan: make(chan tui.Event),
eventChan: make(chan tui.Event, 7), // start | (load + result + result-final + zero|one) | (focus) | (resize)
eventChan: make(chan tui.Event, 6), // start | (load + result + zero|one) | (focus) | (resize)
timerChan: make(chan tui.Event), // unbuffered: every() ticks coalesce when main loop is busy
tui: renderer,
ttyDefault: opts.TtyDefault,
@@ -1188,10 +1162,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
lastAction: actStart,
lastFocus: minItem.Index(),
lastActivity: time.Now(),
numLinesCache: make(map[int32]numLinesCacheValue),
// The initial load counts as a search in progress ('start:wait').
// Set before the reader starts so the first final result clears it.
wait: waitState{searching: true}}
numLinesCache: make(map[int32]numLinesCacheValue)}
if opts.AcceptNth != nil {
t.acceptNth = opts.AcceptNth(t.delimiter)
}
@@ -1259,7 +1230,13 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
}
// Determine input border shape
t.inputBorderShape = resolveInputBorderShape(t.layout, t.inputBorderShape)
if t.inputBorderShape == tui.BorderLine {
if t.layout == layoutReverse {
t.inputBorderShape = tui.BorderBottom
} else {
t.inputBorderShape = tui.BorderTop
}
}
// Inline borders are embedded between the list's top and bottom horizontals.
// Shapes missing either one (none/phantom/line/single-sided) fall back to a plain
@@ -1304,16 +1281,14 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
}
}
// The default separator can be suppressed at runtime depending on the
// window layout (see separatorLength)
t.separatorAuto = opts.Separator == nil
// Disable separator by default if input border is set
if opts.Separator == nil && !t.inputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0 {
bar := "─"
if opts.Separator != nil {
bar = *opts.Separator
} else if !t.unicode {
bar = "-"
}
if len(bar) > 0 {
t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true)
}
@@ -1370,9 +1345,6 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
}
_, t.hasStartActions = t.keymap[tui.Start.AsEvent()]
_, t.hasResultActions = t.keymap[tui.Result.AsEvent()]
if _, prs := t.keymap[tui.ResultFinal.AsEvent()]; prs {
t.hasResultActions = true
}
_, t.hasFocusActions = t.keymap[tui.Focus.AsEvent()]
_, t.hasLoadActions = t.keymap[tui.Load.AsEvent()]
@@ -1467,10 +1439,8 @@ func (t *Terminal) environImpl(forPreview bool) []string {
env = append(env, fmt.Sprintf("FZF_COLUMNS=%d", t.areaColumns))
env = append(env, fmt.Sprintf("FZF_POS=%d", min(t.merger.Length(), t.cy+1)))
if item := t.currentItem(); item != nil {
// Skip if the value contains a NUL byte (exec(2) would reject the env)
// or is too large (a huge item can overflow ARG_MAX and break exec
// entirely for preview and other child commands).
if s := item.AsString(t.ansi); !strings.ContainsRune(s, 0) && len(s) <= maxCurrentItemEnvSize {
// Skip if the value contains a NUL byte; exec(2) would reject the env.
if s := item.AsString(t.ansi); !strings.ContainsRune(s, 0) {
env = append(env, "FZF_CURRENT_ITEM="+s)
}
}
@@ -1702,129 +1672,7 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) {
}
func (t *Terminal) noSeparatorLine() bool {
return t.inputless || noSeparatorLine(t.infoStyle, t.separatorLength() > 0)
}
// Effective length of the horizontal separator. The default separator is
// suppressed when the input section is already visually separated from the
// list section by a border line.
func (t *Terminal) separatorLength() int {
if t.separatorAuto && t.separatedByBorder() {
return 0
}
return t.separatorLen
}
// BorderLine for the input border resolves to a single line on the side
// facing the list section
func resolveInputBorderShape(layout layoutType, shape tui.BorderShape) tui.BorderShape {
if shape != tui.BorderLine {
return shape
}
if layout == layoutReverse {
return tui.BorderBottom
}
return tui.BorderTop
}
// Whether the input border draws a line on the side facing the list section
func inputBorderFacesList(layout layoutType, shape tui.BorderShape) bool {
shape = resolveInputBorderShape(layout, shape)
if layout == layoutReverse {
return shape.HasBottom()
}
return shape.HasTop()
}
// Whether a border line is already drawn between the info line and the
// section next to it on the list side, making the default horizontal
// separator redundant
func (t *Terminal) separatedByBorder() bool {
// The input border itself draws a line on the side facing the list section
if inputBorderFacesList(t.layout, t.inputBorderShape) {
return true
}
// A preview window at 'next' position sits right next to the input
// section, in front of the sections handled below
anyNext := false
for po := &t.previewOpts; po != nil; po = po.alternative {
if po.position == posNext {
anyNext = true
break
}
}
if anyNext && t.hasPreviewer() {
// The separator is hidden only when every spec in the threshold chain
// displays a preview window at 'next' position with a border line
// facing the input section. We cannot single out the active spec;
// which one is active depends on the terminal size, and the
// resolution (computePreviewSize in resizeWindows) itself consults
// noSeparatorLine. When not every spec qualifies, err on the side of
// showing the separator.
if len(t.previewOpts.command) == 0 {
// No preview command, so no preview window is normally shown, but
// the preview(...) action can still display one at any time
return false
}
// NOTE: resizeWindows can clear 'hidden' mid-layout when the
// preview(...) action forces the window, so the frame being laid out
// can briefly disagree with this decision until the next relayout
for po := &t.previewOpts; po != nil; po = po.alternative {
if po.position != posNext || po.hidden || po.size.size == 0 || !t.borderFacingInput(po.Border(t.layout)) {
return false
}
}
return true
}
hasHeaderLinesWindow, headerLinesShape := t.determineHeaderLinesShape()
hasHeaderWindow := t.hasHeaderWindow()
// Mirror of hasInputWindow in resizeWindows; a 'next' preview position
// gives the input section its own window even when no preview window is
// shown. A 'next' spec here implies there is no previewer (the block
// above returns otherwise), and without a previewer the position is
// resolved from the first spec alone, so a 'next' in a threshold
// alternative does not count.
hasInputWindow := t.previewOpts.position == posNext || t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow
if !hasInputWindow {
// The input section is embedded in the list window and no border
// separates the two
return false
}
// The input section has its own window. Determine which section it is
// facing, and check if the border of that section draws a line toward it.
// NOTE: hasHeaderWindow can be true with no header content when the input
// border is visible (see Terminal.hasHeaderWindow). An empty header window
// takes no space, so it cannot be the facing section.
hasHeaderSection := hasHeaderWindow && t.visibleHeaderLines() > 0
if hasHeaderSection && !t.headerFirst && t.headerBorderShape != tui.BorderInline {
// Header section is facing the input section. An inline header is
// excluded; it is drawn inside the list border and is covered by the
// branches below.
return t.borderFacingInput(t.headerBorderShape)
}
// Header lines section is facing the input section, except in
// reverse-list layout where it is on the other side of the list section,
// or with --header-first and no header section where it is moved past
// the input section
if hasHeaderLinesWindow && t.layout != layoutReverseList && (hasHeaderWindow || !t.headerFirst) {
return t.borderFacingInput(headerLinesShape)
}
// The input section sits right next to the list section
return t.borderFacingInput(t.listBorderShape)
}
// Whether the border of the section next to the input section on the list
// side draws a horizontal line facing the info line
func (t *Terminal) borderFacingInput(shape tui.BorderShape) bool {
if shape == tui.BorderInline {
// Embedded between the horizontal lines of the list border
return true
}
if t.layout == layoutReverse {
return shape.HasTop()
}
return shape.HasBottom()
return t.inputless || noSeparatorLine(t.infoStyle, t.separatorLen > 0)
}
func getScrollbar(perLine int, total int, height int, offset int) (int, int) {
@@ -2023,21 +1871,6 @@ func (t *Terminal) UpdateProgress(progress float32) {
func (t *Terminal) UpdateList(result MatchResult) {
merger := result.merger
t.mutex.Lock()
waitWasBlocked := t.wait.blocked
wakeUp := false
if result.final() {
t.wait.searching = false
// If waiting, unblock so main loop can execute pending actions.
// Note: any final result unblocks the wait, not just the one for the
// search that armed it. Back-to-back searches (--listen, bg-transform
// callbacks, reload+wait) can therefore unblock early and run the
// pending actions on the previous result set. Accepted; a per-search
// generation token through the matcher isn't worth the complexity.
if t.wait.blocked {
t.unblockWait()
wakeUp = len(t.wait.pending) > 0
}
}
prevIndex := minItem.Index()
newRevision := merger.Revision()
if t.revision.compatible(newRevision) && t.track != trackDisabled {
@@ -2189,33 +2022,13 @@ func (t *Terminal) UpdateList(result MatchResult) {
}
}
if t.hasResultActions {
result := tui.Result.AsEvent()
if _, prs := t.keymap[result]; prs {
t.pendingReqList = true
t.eventChan <- result
}
if !t.reading {
resultFinal := tui.ResultFinal.AsEvent()
if _, prs := t.keymap[resultFinal]; prs {
t.pendingReqList = true
t.eventChan <- resultFinal
}
}
t.eventChan <- tui.Result.AsEvent()
}
updateList := !t.trackBlocked && !t.pendingReqList
updatePrompt := (trackWasBlocked && !t.trackBlocked) || (waitWasBlocked && !t.wait.blocked)
updatePrompt := trackWasBlocked && !t.trackBlocked
t.mutex.Unlock()
// Wake up the main loop to execute pending actions after wait unblocks.
// Send from a goroutine; UpdateList runs inside the event box callback,
// and an inline send on a full channel would deadlock with the main loop
// blocking on eventBox.Set while trying to drain the channel.
if wakeUp {
go func() {
t.serverInputChan <- []*action{{t: actIgnore}}
}()
}
t.reqBox.Set(reqInfo, nil)
if updateList {
t.reqBox.Set(reqList, nil)
@@ -2280,14 +2093,6 @@ func (t *Terminal) displayWidthWithPrefix(str string, prefixWidth int) int {
return width
}
// displayWidthWithoutEscapes is displayWidthWithPrefix for a string that may
// still carry pass-throughs and ANSI codes, neither of which take any column.
func (t *Terminal) displayWidthWithoutEscapes(str string, prefixWidth int) int {
_, text := extractPassThroughs(str)
stripped, _, _ := extractColor(text, nil, nil)
return t.displayWidthWithPrefix(stripped, prefixWidth)
}
const (
minWidth = 4
minHeight = 3
@@ -3439,7 +3244,7 @@ func (t *Terminal) printPrompt() {
color := tui.ColInput
if t.paused {
color = tui.ColDisabled
} else if t.trackBlocked || t.waitFeedback() {
} else if t.trackBlocked {
color = color.WithAttr(tui.Dim)
}
w.CPrint(color, string(before))
@@ -3467,7 +3272,6 @@ func (t *Terminal) printInfoImpl() {
}
pos := 0
line := 0
separatorLen := t.separatorLength()
maxHeight := t.window.Height()
move := func(y int, x int, clear bool) bool {
if y < 0 || y >= maxHeight {
@@ -3495,7 +3299,7 @@ func (t *Terminal) printInfoImpl() {
str = string(trimmed)
width = maxWidth
}
move(line, pos, separatorLen == 0)
move(line, pos, t.separatorLen == 0)
if t.reading {
t.window.CPrint(tui.ColSpinner, str)
} else {
@@ -3504,7 +3308,7 @@ func (t *Terminal) printInfoImpl() {
pos += width
}
printSeparator := func(fillLength int, pad bool) {
if separatorLen > 0 {
if t.separatorLen > 0 {
t.separator(t.window, fillLength)
t.window.Print(" ")
} else if pad {
@@ -3513,7 +3317,7 @@ func (t *Terminal) printInfoImpl() {
}
if t.infoStyle == infoHidden {
if separatorLen > 0 {
if t.separatorLen > 0 {
if !move(line+1, 0, false) {
return
}
@@ -3532,6 +3336,9 @@ func (t *Terminal) printInfoImpl() {
output += fmt.Sprintf(" (%d/%d)", len(t.selected), t.multi)
}
}
if t.progress > 0 && t.progress < 100 {
output += fmt.Sprintf(" (%d%%)", t.progress)
}
if t.toggleSort {
if t.sort {
output += " +S"
@@ -3552,14 +3359,6 @@ func (t *Terminal) printInfoImpl() {
output += " +t"
}
}
if t.waitFeedback() {
output += " (..)"
}
// Keep the search progress at the end so the other indicators don't shift
// as it appears and disappears.
if t.progress > 0 && t.progress < 100 {
output += fmt.Sprintf(" (%d%%)", t.progress)
}
if t.failed != nil && t.count == 0 {
output = fmt.Sprintf("[Command failed: %s]", *t.failed)
}
@@ -3576,7 +3375,7 @@ func (t *Terminal) printInfoImpl() {
}
switch t.infoStyle {
case infoDefault:
if !move(line+1, 0, separatorLen == 0) {
if !move(line+1, 0, t.separatorLen == 0) {
return
}
printSpinner()
@@ -3660,7 +3459,7 @@ func (t *Terminal) printInfoImpl() {
}
if t.infoStyle == infoInlineRight {
if separatorLen > 0 {
if t.separatorLen > 0 {
if !move(line+1, 0, false) {
return
}
@@ -4691,23 +4490,15 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
height := t.pwindow.Height()
body := t.previewer.lines
headerLines := t.activePreviewOpts.headerLines
lineNo := -t.previewer.offset + headerLines
// Scrollbar is sized from the body alone, split off or not
scrollLines := len(body)
// Do not enable preview header lines if it's value is too large
if headerLines > 0 && headerLines < min(len(body), height) {
scrollLines -= headerLines
header := t.previewer.lines[0:headerLines]
// A separate header pass would resume the body inside an image, which
// takes up more rows than the line it arrives on
if !containsImage(header) {
body = t.previewer.lines[headerLines:]
// Always redraw header
t.renderPreviewText(height, header, 0, false)
t.pwindow.MoveAndClear(t.pwindow.Y(), 0)
body = t.previewer.lines[headerLines:]
}
}
t.renderPreviewText(height, body, lineNo, unchanged)
t.renderPreviewText(height, body, -t.previewer.offset+headerLines, unchanged)
if !unchanged {
t.pwindow.FinishFill()
@@ -4718,7 +4509,7 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
}
effectiveHeight := height - headerLines
barLength, barStart := getScrollbar(1, scrollLines, effectiveHeight, min(scrollLines-effectiveHeight, t.previewer.offset-headerLines))
barLength, barStart := getScrollbar(1, len(body), effectiveHeight, min(len(body)-effectiveHeight, t.previewer.offset-headerLines))
t.renderPreviewScrollbar(headerLines, barLength, barStart)
}
@@ -4782,66 +4573,6 @@ func findPassThrough(line string) []int {
return []int{loc[0], loc[1] + pos + 2}
}
// tmux takes a bare APC as a request to set the pane title, so a Kitty
// graphics command never reaches the terminal and clobbers the title on the
// way. 'kitten icat --clear' emits one unwrapped. Sixel is left alone.
// https://github.com/junegunn/fzf/issues/4870
func wrapPassThrough(passThrough string, tmux bool) string {
if !tmux || !strings.HasPrefix(passThrough, "\x1b_G") {
return passThrough
}
// Only the sequence is passed through, not the trailing CR
suffix := ""
if strings.HasSuffix(passThrough, "\r") {
passThrough, suffix = passThrough[:len(passThrough)-1], "\r"
}
return "\x1bPtmux;" + strings.ReplaceAll(passThrough, "\x1b", "\x1b\x1b") + "\x1b\\" + suffix
}
// Whether the sequence draws an image. Kitty commands that only transmit or
// delete do not
func isImagePassThrough(passThrough string) bool {
// Unwrap the tmux passthrough sequence, in which every ESC is doubled
if after, ok := strings.CutPrefix(passThrough, "\x1bPtmux;"); ok {
passThrough = strings.ReplaceAll(after, "\x1b\x1b", "\x1b")
}
if after, ok := strings.CutPrefix(passThrough, "\x1b_G"); ok {
// Control data ends at the payload delimiter or at the terminator
keys := after
if index := strings.IndexAny(keys, ";\x1b"); index >= 0 {
keys = keys[:index]
}
for _, key := range strings.Split(keys, ",") {
// Transmit and display, or put an image already transmitted
if key == "a=T" || key == "a=p" {
return true
}
}
return false
}
if after, ok := strings.CutPrefix(passThrough, "\x1b]1337;"); ok {
return strings.HasPrefix(after, "File=") || strings.HasPrefix(after, "MultipartFile=")
}
return sixelBeginRegex.MatchString(passThrough)
}
// Whether any line carries an image
func containsImage(lines []string) bool {
for _, line := range lines {
for {
loc := findPassThrough(line)
if loc == nil {
break
}
if isImagePassThrough(line[loc[0]:loc[1]]) {
return true
}
line = line[loc[1]:]
}
}
return false
}
func extractPassThroughs(line string) ([]string, string) {
passThroughs := []string{}
transformed := ""
@@ -4861,38 +4592,6 @@ func extractPassThroughs(line string) ([]string, string) {
return passThroughs, transformed
}
// splitOnIND breaks a preview line on IND (ESC D), which moves the cursor
// down one line, keeping the column. A program drawing at a column offset ends
// its rows with IND instead of a newline, because ONLCR would rewrite a newline
// as CR NL and snap the cursor to column 0. chafa does this for Kitty Unicode
// placeholders, and without the break the whole image collapses into a single
// line.
//
// The column is tracked and re-created with padding so that an indented image
// keeps its indent, and a CUB right before the IND is subtracted, which is how
// chafa returns to the column its rows start on.
func (t *Terminal) splitOnIND(line string) []string {
chunks := strings.Split(line, "\x1bD")
if len(chunks) == 1 {
return nil
}
lines := make([]string, 0, len(chunks))
col := 0
for _, chunk := range chunks[:len(chunks)-1] {
lines = append(lines, strings.Repeat(" ", col)+chunk+"\n")
col += t.displayWidthWithoutEscapes(chunk, col)
if match := cursorBackRegex.FindStringSubmatch(chunk); match != nil {
back := 1
if len(match[1]) > 0 {
back, _ = strconv.Atoi(match[1])
}
col = max(0, col-back)
}
}
return append(lines, strings.Repeat(" ", col)+chunks[len(chunks)-1])
}
// followOffset computes the correct content-line offset for follow mode,
// accounting for line wrapping in the preview window.
func (t *Terminal) followOffset() int {
@@ -5114,7 +4813,7 @@ Loop:
} else {
t.pwindow.Move(y, x)
}
t.tui.PassThrough(wrapPassThrough(passThrough, inTmux))
t.tui.PassThrough(passThrough)
if requiredLines > 0 {
if y+requiredLines == height {
@@ -6066,63 +5765,12 @@ func (t *Terminal) unblockTrack() {
t.trackBlocked = false
t.trackKey = ""
t.trackKeyCache = nil
// Keep the cursor hidden if the wait feedback is still showing it
if !t.inputless && !t.waitFeedback() {
if !t.inputless {
t.tui.ShowCursor()
}
}
}
// The wait state machine. Invariant: arming captures every action after
// 'wait' at any nesting level into wait.pending; while blocked, actions are
// dropped unless they are results of work started before the block
// (bg-transform callbacks, bracketed paste bookkeeping); abort/cancel
// discards everything.
// blockWait blocks action execution and defers the given actions until the
// current search completes (see UpdateList)
func (t *Terminal) blockWait(pending []*action) {
t.wait.blocked = true
t.wait.blockedAt = time.Now()
// Clone so that later joins don't append into the backing array of the
// bound action list
t.wait.pending = slices.Clone(pending)
// Show the waiting feedback only if the search takes long enough,
// so that quick searches don't cause flickering
go func() {
timer := time.NewTimer(progressMinDuration)
<-timer.C
t.mutex.Lock()
blocked := t.wait.blocked
t.mutex.Unlock()
if blocked {
t.reqBox.Set(reqPrompt, nil)
t.reqBox.Set(reqInfo, nil)
}
}()
}
// unblockWait lifts the block, leaving the pending actions to the caller:
// UpdateList keeps them for the main loop to replay, cancelWait discards them
func (t *Terminal) unblockWait() {
t.wait.blocked = false
// Restore the cursor unless it's still hidden for another reason
if !t.inputless && !t.trackBlocked {
t.tui.ShowCursor()
}
}
// cancelWait unblocks and discards the pending actions (user abort)
func (t *Terminal) cancelWait() {
t.unblockWait()
t.wait.pending = nil
}
// Debounce visual feedback so quick searches don't cause flashing
func (t *Terminal) waitFeedback() bool {
return t.wait.blocked && time.Since(t.wait.blockedAt) > progressMinDuration
}
func (t *Terminal) addClickHeaderWord(env []string) []string {
/*
* echo $'HL1\nHL2' | fzf --header-lines 3 --header $'H1\nH2' --header-lines-border --bind 'click-header:preview:env | grep FZF_CLICK'
@@ -6305,7 +5953,6 @@ func (t *Terminal) Loop() error {
for {
select {
case <-ctx.Done():
signal.Stop(intChan)
return
case s := <-intChan:
// Don't quit by SIGINT while executing because it should be for the executing command and not for fzf itself
@@ -6318,7 +5965,7 @@ func (t *Terminal) Loop() error {
if !t.tui.ShouldEmitResizeEvent() {
resizeChan := make(chan os.Signal, 1)
notifyOnResize(ctx, resizeChan) // Non-portable
notifyOnResize(resizeChan) // Non-portable
go func() {
for {
select {
@@ -6465,12 +6112,8 @@ func (t *Terminal) Loop() error {
version--
offset = 0
}
if split := t.splitOnIND(line); split != nil {
lines = append(lines, split...)
} else {
lines = append(lines, line)
}
}
if err != nil {
t.reqBox.Set(reqPreviewDisplay, previewResult{version, lines, offset, ""})
rendered.Set(true)
@@ -6753,10 +6396,6 @@ func (t *Terminal) Loop() error {
t.printFooter()
}
}
// Hide the cursor while the debounced waiting feedback is shown
if !t.inputless && !t.trackBlocked && t.waitFeedback() {
t.tui.HideCursor()
}
t.flush()
t.mutex.Unlock()
t.uiMutex.Unlock()
@@ -6813,10 +6452,6 @@ func (t *Terminal) Loop() error {
var newCommand *commandSpec
var reloadSync bool
var denylist []int32
// True while running bg-transform callbacks. Declared outside the loop
// because callbacks invoke the doActions closure of the iteration that
// scheduled them, not the one executing actAsync.
inBgCallback := false
req := func(evts ...util.EventType) {
for _, event := range evts {
events = append(events, event)
@@ -6908,8 +6543,6 @@ func (t *Terminal) Loop() error {
}
t.mutex.Lock()
// Ignore --expect keys while wait-blocked like the rest of the input
if !t.wait.blocked {
for key, ret := range t.expect {
if keyMatch(key, event) {
t.pressed = ret
@@ -6918,7 +6551,6 @@ func (t *Terminal) Loop() error {
return nil
}
}
}
triggering := map[tui.Event]struct{}{}
previousInput := t.input
previousCx := t.cx
@@ -6966,41 +6598,12 @@ func (t *Terminal) Loop() error {
var doAction func(*action) bool
doActions := func(actions []*action) bool {
// Snapshot to detect query edits in this batch that trigger a
// search at loop end without setting 'changed'. String copy:
// edits mutate t.input in place.
queryBefore := string(t.input)
for iter := 0; iter <= maxFocusEvents; iter++ {
currentIndex := t.currentIndex()
for i, action := range actions {
if action.t == actWait {
// Already waiting. Actions parsed from a bg-transform
// result join the current wait; user input can't reset
// the blocked state
if t.wait.blocked {
if inBgCallback {
t.wait.pending = append(t.wait.pending, actions[i+1:]...)
}
return true
}
// Block if search is in progress or will be triggered
if changed || newCommand != nil || t.wait.searching || queryBefore != string(t.input) {
t.blockWait(actions[i+1:])
return true
}
// No search, wait is a no-op; continue to next action
continue
}
blockedBefore := t.wait.blocked
for _, action := range actions {
if !doAction(action) {
return false
}
// If this action armed the wait through a nested list
// (e.g. via trigger), defer the rest of this list too
if !blockedBefore && t.wait.blocked {
t.wait.pending = append(t.wait.pending, actions[i+1:]...)
return true
}
// A terminal action performed. We should stop processing more.
if !looping {
break
@@ -7046,22 +6649,8 @@ func (t *Terminal) Loop() error {
callback(a.a)
}
}
// Actions that run even while wait/track-blocked: bg-transform
// callbacks and their parsed actions (results of processes
// started before the block), and bracketed paste bookkeeping (a
// swallowed paste-end would leave t.pasting set forever).
passthrough := inBgCallback || a.t == actAsync ||
a.t == actBracketedPasteBegin || a.t == actBracketedPasteEnd
// When wait-blocked, only allow abort/cancel
if t.wait.blocked && !passthrough {
if a.t == actAbort || a.t == actCancel {
t.cancelWait()
req(reqPrompt, reqInfo)
}
return true
}
// When track-blocked, only allow abort/cancel and track-disabling actions
if t.trackBlocked && !passthrough && a.t != actToggleTrack && a.t != actToggleTrackCurrent && a.t != actUntrackCurrent {
if t.trackBlocked && a.t != actToggleTrack && a.t != actToggleTrackCurrent && a.t != actUntrackCurrent {
if a.t == actAbort || a.t == actCancel {
t.unblockTrack()
req(reqPrompt, reqInfo)
@@ -7072,13 +6661,11 @@ func (t *Terminal) Loop() error {
switch a.t {
case actIgnore, actStart, actClick:
case actAsync:
inBgCallback = true
for _, callback := range callbacks {
if t.bgVersion == callback.version {
callback.callback()
}
}
inBgCallback = false
case actBecome:
valid, list := t.buildPlusList(a.a, false)
if valid {
@@ -7128,9 +6715,7 @@ func (t *Terminal) Loop() error {
t.mutex.Unlock()
return false
case actBracketedPasteBegin:
// Clone: []rune(t.input) would alias t.input, and in-place
// query edits during the paste would corrupt the snapshot
current := slices.Clone(t.input)
current := []rune(t.input)
t.pasting = &current
case actBracketedPasteEnd:
if t.pasting != nil {
@@ -7376,10 +6961,6 @@ func (t *Terminal) Loop() error {
if t.border != nil {
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false)
req(reqRedrawBorderLabel)
} else if t.setNativeLabel != nil {
// fzf draws no border of its own; the label is on the
// native border of the floating pane
t.setNativeLabel(label)
}
})
case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel:
@@ -7392,8 +6973,7 @@ func (t *Terminal) Loop() error {
})
case actTransform, actBgTransform:
capture(false, func(body string) {
// Allow 'put' if the triggering key is a printable character
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n"), event.Printable()); err == nil {
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n")); err == nil {
// NOTE: We're not properly passing the return value here
doActions(actions)
}
@@ -7417,9 +6997,7 @@ func (t *Terminal) Loop() error {
case actReplaceQuery:
current := t.currentItem()
if current != nil {
// ToRunes aliases the item text in rune mode, and the
// editing actions below append into t.input in place
t.input = append([]rune{}, current.text.ToRunes()...)
t.input = current.text.ToRunes()
t.cx = len(t.input)
}
case actFatal:
@@ -7954,8 +7532,7 @@ func (t *Terminal) Loop() error {
req(reqPrompt)
case actTrigger:
if _, chords, err := parseKeyChords(a.a, ""); err == nil {
blockedBefore := t.wait.blocked
for ci, chord := range chords {
for _, chord := range chords {
if _, prs := triggering[chord]; prs {
// Avoid recursive triggering
continue
@@ -7965,19 +7542,6 @@ func (t *Terminal) Loop() error {
doActions(acts)
delete(triggering, chord)
}
// If this chord armed the wait, defer the remaining chords
if !blockedBefore && t.wait.blocked {
for _, rest := range chords[ci+1:] {
if _, prs := triggering[rest]; prs {
// Avoid recursive triggering
continue
}
if acts, prs := t.keymap[rest]; prs {
t.wait.pending = append(t.wait.pending, acts...)
}
}
break
}
}
}
case actSigStop:
@@ -8406,8 +7970,6 @@ func (t *Terminal) Loop() error {
case actChangePreviewWindow:
// NOTE: We intentionally use "previewOpts" instead of "activePreviewOpts" here
currentPreviewOpts := t.previewOpts
wasNoSeparatorLine := t.noSeparatorLine()
wasSeparatorLength := t.separatorLength()
// Reset preview options and apply the additional options
t.previewOpts = t.initialPreviewOpts
@@ -8428,28 +7990,8 @@ func (t *Terminal) Loop() error {
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
}
// Do not drop a preview window forced by the preview(...) action
keepForcedPreview := t.hasPreviewWindow() && !t.activePreviewOpts.hidden
// The default separator is decided from the whole threshold
// chain (separatedByBorder), which compare does not fully
// inspect, so it can change even when compare finds no
// layout difference
checkSeparator := func() {
if t.noSeparatorLine() != wasNoSeparatorLine {
// Number of lines changed; relayout
updatePreviewWindow(keepForcedPreview)
req(reqPreviewRefresh)
} else if t.separatorLength() != wasSeparatorLength {
// Only the content of the info line changed
req(reqInfo)
}
}
// Full redraw
switch currentPreviewOpts.compare(t.activePreviewOpts, &t.previewOpts) {
case previewOptsSame:
checkSeparator()
case previewOptsDifferentLayout:
// Preview command can be running in the background if the size of
// the preview window is 0 but not 'hidden'
@@ -8457,7 +7999,7 @@ func (t *Terminal) Loop() error {
// FIXME: One-time preview window can't reappear once hidden
// fzf --bind space:preview:ls --bind 'enter:change-preview-window:down|left|up|hidden|'
updatePreviewWindow(keepForcedPreview)
updatePreviewWindow(t.hasPreviewWindow() && !t.activePreviewOpts.hidden)
if wasHidden && t.hasPreviewWindow() {
// Restart
refreshPreview(t.previewOpts.command)
@@ -8471,7 +8013,6 @@ func (t *Terminal) Loop() error {
case previewOptsDifferentContentLayout:
t.previewed.version = 0
req(reqPreviewRefresh)
checkSeparator()
}
// Adjust scroll offset
@@ -8514,21 +8055,9 @@ func (t *Terminal) Loop() error {
return true
}
// Execute pending actions if wait just unblocked. Capture the jump
// state first so that a pending 'jump' action isn't cancelled right
// away by the wake-up event below.
jumpingBefore := t.jumping
if len(t.wait.pending) > 0 && !t.wait.blocked {
pending := t.wait.pending
t.wait.pending = nil
if !doActions(pending) {
continue
}
}
if jumpingBefore == jumpDisabled || len(actions) > 0 {
if t.jumping == jumpDisabled || len(actions) > 0 {
// Break out of jump mode if any action is submitted to the server
if jumpingBefore != jumpDisabled {
if t.jumping != jumpDisabled {
t.jumping = jumpDisabled
if acts, prs := t.keymap[tui.JumpCancel.AsEvent()]; prs && !doActions(acts) {
continue
@@ -8587,9 +8116,6 @@ func (t *Terminal) Loop() error {
}
reload := changed || newCommand != nil
if reload {
t.wait.searching = true
}
var reloadRequest *searchRequest
if reload {
reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, withNth: newWithNth, headerLines: newHeaderLines, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.resultMerger.Revision()}
-153
View File
@@ -549,89 +549,6 @@ func TestExtractPassthroughs(t *testing.T) {
}
}
func TestWrapPassThrough(t *testing.T) {
for _, passThrough := range []string{
"\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", // Already wrapped
"\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", // Sixel
"\x1b]1337;File=inline=1:AAAA\a", // iTerm2
} {
if got := wrapPassThrough(passThrough, true); got != passThrough {
t.Errorf("should have been left alone: %q -> %q", passThrough, got)
}
}
kitty := "\x1b_Ga=d,d=A\x1b\\"
if got := wrapPassThrough(kitty, false); got != kitty {
t.Errorf("should have been left alone outside of tmux: %q", got)
}
// ESC characters are doubled, and the trailing carriage return is kept
// outside of the wrapper
for _, test := range []struct{ input, want string }{
{kitty, "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\"},
{kitty + "\r", "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\\r"},
{"\x1b_Gm=1;\x1bAAA=\x1b\\", "\x1bPtmux;\x1b\x1b_Gm=1;\x1b\x1bAAA=\x1b\x1b\\\x1b\\"},
} {
if got := wrapPassThrough(test.input, true); got != test.want {
t.Errorf("expected %q, got %q", test.want, got)
}
}
}
func TestIsImagePassThrough(t *testing.T) {
for _, tc := range []struct {
given string
image bool
}{
// Kitty
{"\x1b_Ga=T,f=32,s=1258,v=1295,c=74,r=35,m=1\x1b\\", true},
{"\x1b_Ga=p,i=1\x1b\\", true},
// The action key is not always first, and a put carries no payload
{"\x1b_Gi=1,a=p\x1b\\", true},
{"\x1b_Ga=p\x1b\\", true},
{"\x1b_Ga=T,f=100\x1b\\\r", true},
{"\x1b_Gi=1,a=d\x1b\\", false},
{"\x1b_Ga=d,d=A\x1b\\", false}, // 'kitten icat --clear'
{"\x1b_Ga=q,i=1\x1b\\", false}, // query
{"\x1b_Gi=1,f=100\x1b\\", false}, // transmit only, the default action
{"\x1b_Gm=1;AAAA\x1b\\", false}, // continuation chunk
{"\x1b_Ga=f,i=1;AAAA\x1b\\", false}, // animation frame
{"\x1b_Ga=T,U=1,f=32;AAAA\x1b\\", true}, // unicode placeholder
// Wrapped in the tmux passthrough sequence
{"\x1bPtmux;\x1b\x1b_Ga=T,f=100\x1b\x1b\\\x1b\\", true},
{"\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", false},
{"\x1bPtmux;\x1b\x1b_Gi=1,a=p\x1b\x1b\\\x1b\\", true},
// iTerm2
{"\x1b]1337;File=inline=1:AAAA\a", true},
{"\x1b]1337;MultipartFile=inline=1\a", true},
{"\x1b]1337;SetUserVar=foo=YmFy\a", false},
{"\x1b]1337;CurrentDir=/tmp\a", false},
// Sixel
{"\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", true},
{"\x1bPq#0~~\x1b\\", true},
{"\x1bPtmux;\x1b\x1bP0;1;0q#0~~\x1b\x1b\\\x1b\\", true},
{"", false},
} {
if actual := isImagePassThrough(tc.given); actual != tc.image {
t.Errorf("expected %v for %q, got %v", tc.image, tc.given, actual)
}
}
if containsImage([]string{"foo", "bar"}) {
t.Error("plain text carries no image")
}
if !containsImage([]string{"foo", "bar\x1b_Ga=T,f=100\x1b\\baz"}) {
t.Error("failed to find an image in a line")
}
if containsImage([]string{"foo\x1b_Ga=d,d=A\x1b\\"}) {
t.Error("a delete command is not an image")
}
// The image is not the first passthrough on the line
if !containsImage([]string{"\x1b_Ga=d,d=A\x1b\\text\x1b_Ga=T,f=100;AAAA\x1b\\"}) {
t.Error("failed to look past an earlier passthrough")
}
}
/* utilities section */
// Item represents one line in fzf UI. Usually it is relative path to files and folders.
@@ -851,73 +768,3 @@ func TestWordWrapAnsiLine(t *testing.T) {
t.Errorf("Tab wrap: %q", result)
}
}
func TestSplitOnIND(t *testing.T) {
term := &Terminal{tabstop: 8}
for _, tc := range []struct {
name string
line string
want []string
}{
{
// Nothing to do, so the caller keeps the line as it read it
name: "no IND",
line: "foo\n",
want: nil,
},
{
// chafa: CUB returns to the column the row started on
name: "rows at column 0",
line: "AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
want: []string{"AAA\x1b[3D\n", "BBB\x1b[3D\n", "CCC\n"},
},
{
// 'printf " "; chafa ...'
name: "indented rows",
line: " AAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
want: []string{" AAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
},
{
name: "tab indent expands to the tab stop",
line: "\tAAA\x1b[3D\x1bDBBB\x1b[3D\x1bDCCC\n",
want: []string{"\tAAA\x1b[3D\n", " BBB\x1b[3D\n", " CCC\n"},
},
{
// IND on its own keeps the column
name: "bare IND",
line: "AB\x1bDCD\n",
want: []string{"AB\n", " CD\n"},
},
{
name: "CUB without a parameter moves back one",
line: "AB\x1b[D\x1bDCD\n",
want: []string{"AB\x1b[D\n", " CD\n"},
},
{
name: "CUB past the left edge is clamped",
line: "AB\x1b[9D\x1bDCD\n",
want: []string{"AB\x1b[9D\n", "CD\n"},
},
{
name: "SGR codes take no column",
line: "\x1b[31mAB\x1b[m\x1b[2D\x1bDCD\n",
want: []string{"\x1b[31mAB\x1b[m\x1b[2D\n", "CD\n"},
},
{
name: "pass-throughs take no column",
line: "\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\x1bDCD\n",
want: []string{"\x1b_Ga=T,c=2,r=1\x1b\\AB\x1b[2D\n", "CD\n"},
},
} {
got := term.splitOnIND(tc.line)
if len(got) != len(tc.want) {
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
continue
}
for idx, line := range got {
if line != tc.want[idx] {
t.Errorf("%s: line %d: got %q, want %q", tc.name, idx, line, tc.want[idx])
}
}
}
}
+1 -6
View File
@@ -3,7 +3,6 @@
package fzf
import (
"context"
"os"
"os/signal"
"syscall"
@@ -11,12 +10,8 @@ import (
"golang.org/x/sys/unix"
)
func notifyOnResize(ctx context.Context, resizeChan chan<- os.Signal) {
func notifyOnResize(resizeChan chan<- os.Signal) {
signal.Notify(resizeChan, syscall.SIGWINCH)
go func() {
<-ctx.Done()
signal.Stop(resizeChan)
}()
}
func notifyStop(p *os.Process) {
+2 -49
View File
@@ -3,58 +3,11 @@
package fzf
import (
"context"
"os"
"syscall"
"time"
"golang.org/x/sys/windows"
)
const resizePollInterval = 100 * time.Millisecond
type resizeSignal struct{}
func (resizeSignal) String() string { return "resize" }
func (resizeSignal) Signal() {}
// Windows has no SIGWINCH, so poll the console screen buffer for window
// size changes instead.
func notifyOnResize(ctx context.Context, resizeChan chan<- os.Signal) {
consoleOut, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0)
if err != nil {
return
}
var info windows.ConsoleScreenBufferInfo
if windows.GetConsoleScreenBufferInfo(windows.Handle(consoleOut), &info) != nil {
syscall.Close(consoleOut)
return
}
last := info.Window
go func() {
defer syscall.Close(consoleOut)
ticker := time.NewTicker(resizePollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
if windows.GetConsoleScreenBufferInfo(windows.Handle(consoleOut), &info) != nil {
continue
}
current := info.Window
if current.Right-current.Left != last.Right-last.Left ||
current.Bottom-current.Top != last.Bottom-last.Top {
last = current
select {
case resizeChan <- resizeSignal{}:
default:
}
}
}
}()
func notifyOnResize(resizeChan chan<- os.Signal) {
// TODO
}
func notifyStop(p *os.Process) {
-224
View File
@@ -1,234 +1,10 @@
package fzf
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/junegunn/fzf/src/tui"
)
// Returns the size of the current window if the tmux server supports
// floating panes (tmux 3.7 or above)
func tmuxFloatingPaneInfo() (int, int, bool) {
// TMUX_PANE is not set when fzf is not started from within a pane
// (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating
// pane there; a blocking run-shell suspends key processing for the
// client until the command exits, so fzf in a floating pane would
// never receive a key and the client would appear frozen until the
// process is killed externally. A popup is unaffected; its input is
// handled at the client overlay level, bypassing the suspended path.
// The right approach for such bindings is 'run-shell -b', which does
// not suspend key processing, as discussed in:
// https://github.com/tmux/tmux/issues/5384
target := os.Getenv("TMUX_PANE")
if target == "" {
return 0, 0, false
}
// A single invocation for both checks. Cannot rely on the exit status;
// tmux versions before 3.7 exit normally with empty output for an
// unknown command name, so check the output instead.
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
}
var width, height int
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
return 0, 0, false
}
// The window is too small to fit a floating pane of the minimum size
if width < 3 || height < 3 {
return 0, 0, false
}
return width, height, true
}
// tmux ends a command at an argument ending in ';', so a trailing one is
// escaped. Elsewhere it is not special
func escapeTmuxSeparator(str string) string {
if strings.HasSuffix(str, ";") {
return str[:len(str)-1] + `\;`
}
return str
}
// '#[...]' is still processed when a substituted value is drawn, so '#' is
// doubled. '#{...}' is already literal
func escapeTmuxFormat(str string) string {
return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##"))
}
// The option pane-border-format reads back, and the pane it is set on
const (
tmuxBorderLabelOption = "@fzf-border-label"
tmuxBorderLabelEnv = internalEnvPrefix + "TMUX_LABEL_PANE"
)
// Errors are ignored. The pane may be gone
func setTmuxBorderLabel(pane string, label string) {
// Strip ANSI sequences. tmux would draw the escape's remainder as text
label, _, _ = extractColor(label, nil, nil)
exec.Command("tmux", "set-option", "-p", "-t", pane,
tmuxBorderLabelOption, escapeTmuxFormat(label)).Run()
}
// Convert sizeSpec to the number of cells, clamped between the minimum
// footprint of 3, including the border, and the window size
func tmuxDim(spec sizeSpec, window int) int {
dim := int(spec.size)
if spec.percent {
dim = window * dim / 100
}
return max(3, min(dim, window))
}
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
// Unlike display-popup, the size of a floating pane does not account for
// the border around it, and the position is that of the content area. To
// stay consistent with popups, treat the requested size as the total
// footprint including the border.
width := tmuxDim(opts.Tmux.width, windowWidth)
height := tmuxDim(opts.Tmux.height, windowHeight)
x := (windowWidth-width)/2 + 1
y := (windowHeight-height)/2 + 1
switch opts.Tmux.position {
case posUp:
y = 1
case posDown:
y = windowHeight - height + 1
case posLeft:
x = 1
case posRight:
x = windowWidth - width + 1
}
return runProxy(argStr, func(temp string, needBash bool) (*exec.Cmd, error) {
sh, err := sh(needBash)
if err != nil {
return nil, err
}
// Unlike display-popup, new-pane does not block until the command
// finishes, and it does not propagate the exit status. So we block on
// a wait-for channel that the pane signals on completion, and pass
// the exit status through a temporary file. A watchdog process
// signals the same channel if the pane is closed abnormally
// (e.g. kill-pane), in which case the file is not written.
//
// has-session is the liveness check because it fails when the target
// pane is gone, while display-message succeeds even for a dead pane.
signal := escapeSingleQuote("fzf-" + filepath.Base(temp))
// Pre-create the exit status file so that another user on a shared
// TMPDIR cannot plant a file or a symbolic link at the predictable
// path while the pane is running
codeFile := temp + ".code"
f, err := os.OpenFile(codeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
f.Close()
code := escapeSingleQuote(codeFile)
// Pane options are set by the pane itself before running fzf, so
// that they are in place no matter how quickly the command exits.
// The target must be explicit; without it, the commands would
// resolve to the active pane of the session's current window,
// which is not necessarily the pane running them.
//
// The pane should always close on exit like a popup, even when
// remain-on-exit is on.
setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; `
// --border-label lives in a pane-scoped user option that
// pane-border-format reads back, so a program in the pane cannot
// replace it by setting the title. Set even when empty, so
// change-border-label can fill it in and the default pane status is
// not shown. pane-border-status is a window option until the next
// tmux release, so it is left alone.
// https://github.com/tmux/tmux/commit/7a18fa281db3
// --border-label-pos is ignored. With an explicit --border fzf draws
// the label itself, but the native border cannot be removed.
ownsLabel := noBorderSpecified(opts)
label := ""
if ownsLabel {
// Strip ANSI sequences fzf would otherwise render itself
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
}
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" %s %s 2> /dev/null; `,
tmuxBorderLabelOption, escapeSingleQuote(escapeTmuxFormat(label)))
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" pane-border-format %s 2> /dev/null; `,
escapeSingleQuote("#{"+tmuxBorderLabelOption+"}"))
if ownsLabel {
setup += fmt.Sprintf(`export %s="$TMUX_PANE"; `, tmuxBorderLabelEnv)
}
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),
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
// The pane is killed when the proxy process is interrupted or hung up,
// like a popup dying with its client. wait-for runs in the background
// and is awaited with the interruptible wait builtin so that the trap
// can fire while blocked. The trap is installed before creating the
// pane; a signal received during creation is deferred until the
// command substitution completes, and the pane is killed right after.
// An interrupted wait does not reap the waiter, so it is killed
// along with the watchdog.
script := fmt.Sprintf(`trap '[ -n "$id" ] && tmux kill-pane -t "$id" 2> /dev/null' INT TERM HUP
id=$(%s) || { status=$?; rm -f %s; exit "$status"; }
{ while tmux has-session -t "$id" 2> /dev/null; do sleep 1; done; tmux wait-for -S %s; } &
watchdog=$!
tmux wait-for %s &
waiter=$!
wait "$waiter"
kill "$watchdog" "$waiter" 2> /dev/null
wait 2> /dev/null
if [ -s %s ]; then code=$(cat %s); else code=130; fi
rm -f %s
exit "$code"`, newPane, code, signal, signal, code, code, code)
return exec.Command(sh, "-c", script), nil
}, opts, true)
}
// Whether no box border was asked for. 'none' and 'line' count as none.
// fzf draws no box for either
func noBorderSpecified(opts *Options) bool {
return opts.BorderShape == tui.BorderUndefined ||
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone
}
// Whether to use the multiplexer's native border for the floating pane. Its
// native border is the handle that makes the pane movable and resizable with
// the mouse, so it is the default; 'border-native' forces it. It is not used
// when a border style is explicitly specified with --border, so that the
// fzf-drawn border is the only one shown.
func nativeBorder(opts *Options) bool {
return opts.Tmux.border || noBorderSpecified(opts)
}
func runTmux(args []string, opts *Options) (int, error) {
// On tmux 3.7 or above, fzf runs in a floating pane instead of a popup.
// When the native border is not used (an explicit --border style), a
// 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 {
opts.Tmux.border = true
argStr, dir := popupArgStr(args, opts)
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
}
}
argStr, dir := popupArgStr(args, opts)
// Set tmux options for popup placement
-53
View File
@@ -1,53 +0,0 @@
package fzf
import "testing"
func TestEscapeTmuxFormat(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"", ""},
{" fzf ", " fzf "},
{"100%", "100%"},
// '#[' would be taken as a style directive when the label is drawn
{"#[fg=red]x", "##[fg=red]x"},
// '#{' is already literal in a substituted value, but doubling it
// keeps a label that fzf renders itself looking the same
{"#{pane_id}", "##{pane_id}"},
{" C# notes #S ", " C## notes ##S "},
{";", `\;`},
{"; rm", "; rm"},
{"C#;", `C##\;`},
} {
if actual := escapeTmuxFormat(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
}
func TestEscapeTmuxSeparator(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"", ""},
{" fzf ", " fzf "},
{"#", "#"},
{"#{pane_id}", "#{pane_id}"},
{"100%", "100%"},
// tmux drops a trailing ';' and everything after it, so only that
// one is escaped
{";", `\;`},
{"abc;", `abc\;`},
{";;", `;\;`},
{`foo\;`, `foo\\;`},
{"; rm", "; rm"},
{" ; ", " ; "},
{"a;b", "a;b"},
} {
if actual := escapeTmuxSeparator(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
}
+2 -3
View File
@@ -164,12 +164,11 @@ func _() {
_ = x[ClickFooter-153]
_ = x[Multi-154]
_ = x[Every-155]
_ = x[ResultFinal-156]
}
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEveryResultFinal"
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEvery"
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1065, 1076, 1085, 1095, 1105, 1116, 1124, 1134, 1143, 1154, 1169, 1186, 1193, 1198, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330, 1341}
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1065, 1076, 1085, 1095, 1105, 1116, 1124, 1134, 1143, 1154, 1169, 1186, 1193, 1198, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330}
func (i EventType) String() string {
if i < 0 || i >= EventType(len(_EventType_index)-1) {
+39 -117
View File
@@ -24,45 +24,21 @@ const (
defaultEscDelay = 100
escPollInterval = 5
offsetPollTries = 10
queryTimeout = 500 * time.Millisecond
maxInputBuffer = 1024 * 1024
maxSelectTries = 100
)
const DefaultTtyDevice string = "/dev/tty"
var offsetRegexp = regexp.MustCompile("\x00?\x1b\\[([0-9]+);([0-9]+)R")
var offsetRegexp = regexp.MustCompile("(.*?)\x00?\x1b\\[([0-9]+);([0-9]+)R")
var offsetRegexpBegin = regexp.MustCompile("^\x1b\\[[0-9]+;[0-9]+R")
// DECRPM reply to the DECRQM query for bracketed paste mode. Ps is 1 or 3 when
// the mode was already set, 2 or 4 when reset, 0 when the terminal does not
// recognize the mode.
var pasteModeRegexp = regexp.MustCompile("\x00?\x1b\\[\\?2004;([0-4])\\$y")
var pasteModeRegexpBegin = regexp.MustCompile("^\x1b\\[\\?2004;[0-4]\\$y")
// A report to ask the terminal for, and the reply to recognize it by.
type termQuery struct {
seq string
reply *regexp.Regexp
}
// What we ask the terminal at startup, in the order the queries go out. A
// terminal answers them in that order, so the cursor position query is last and
// also ends the wait: every terminal fzf supports answers it, so once its reply
// arrives, a query still unanswered is one the terminal does not know rather
// than one we stopped waiting for too early.
var startupQueries = []termQuery{
{"?2004$p", pasteModeRegexp},
{"6n", offsetRegexp},
}
func (r *LightRenderer) Bell() {
r.flushRaw("\a")
}
func (r *LightRenderer) PassThrough(str string) {
r.queued.WriteString("\x1b7" + str + "\x1b8")
r.invalidateSGR()
}
func (r *LightRenderer) stderr(str string) {
@@ -114,36 +90,14 @@ func (r *LightRenderer) csi(code string) string {
return fullcode
}
// setSGR emits the given SGR sequence only when the terminal is not already
// in that state. Sequences built by csiColor start with a reset parameter,
// so each of them fully determines the state on its own. The zero value of
// r.sgr never matches a sequence, so the first update after Init, Resume, or
// invalidateSGR is always emitted.
func (r *LightRenderer) setSGR(code string) {
if code != r.sgr {
r.stderr(code)
r.sgr = code
}
}
// invalidateSGR marks the terminal SGR state as unknown, e.g. after raw
// output that may have changed it behind the renderer's back.
func (r *LightRenderer) invalidateSGR() {
r.sgr = ""
}
func (r *LightRenderer) flush() {
if r.queued.Len() > 0 {
// Leave the terminal in the default SGR state between frames
r.setSGR("\x1b[0m")
// Wrap the frame in synchronized update mode (2026) so that the
// terminal applies it atomically. Terminals without support ignore
// the unknown private mode and behave as before.
raw := "\x1b[?2026h\x1b[?7l\x1b[?25l" + r.queued.String()
raw := "\x1b[?7l\x1b[?25l" + r.queued.String()
if r.showCursor {
raw += "\x1b[?25h"
raw += "\x1b[?25h\x1b[?7h"
} else {
raw += "\x1b[?7h"
}
raw += "\x1b[?7h\x1b[?2026l"
r.flushRaw(raw)
r.queued.Reset()
}
@@ -174,17 +128,12 @@ type LightRenderer struct {
fullscreen bool
upOneLine bool
queued strings.Builder
sgr string
y int
x int
maxHeightFunc func(int) int
showCursor bool
mutex sync.Mutex
// Whether bracketed paste was already on before we enabled it. Nil when
// the terminal did not answer the query.
pasteWasSet *bool
// Windows only
ttyinChannel chan byte
inHandle uintptr
@@ -257,13 +206,8 @@ func (r *LightRenderer) Init() error {
if r.fullscreen {
r.smcup()
}
// Ask everything in one round trip, before the offset is needed.
y, x, pasteWasSet := r.queryStartup()
r.pasteWasSet = pasteWasSet
if !r.fullscreen {
} else {
y, x := r.findOffset()
r.mouse = r.mouse && y >= 0
// When --no-clear is used for repetitive relaunching, there is a small
// time frame between fzf processes where the user keystrokes are not
@@ -288,7 +232,7 @@ func (r *LightRenderer) Init() error {
r.csi("G")
r.csi("K")
if !r.clearOnExit && !r.fullscreen {
r.stderr("\x1b7") // DECSC: save cursor position
r.csi("s")
}
if !r.fullscreen && r.mouse {
r.yoffset, _ = r.findOffset()
@@ -306,15 +250,15 @@ func (r *LightRenderer) makeSpace() {
}
func (r *LightRenderer) move(y int, x int) {
// w.csi("u")
if r.y < y {
r.csi(fmt.Sprintf("%dB", y-r.y))
} else if r.y > y {
r.csi(fmt.Sprintf("%dA", r.y-y))
}
if x == 0 {
r.stderr("\r")
} else {
r.csi(fmt.Sprintf("%dG", x+1))
if x > 0 {
r.csi(fmt.Sprintf("%dC", x))
}
r.y = y
r.x = x
@@ -350,11 +294,7 @@ func (r *LightRenderer) getBytesInternal(cancellable bool, buffer []byte, nonblo
if c == Esc.Int() || nonblock {
retries = r.escDelay / escPollInterval
}
// A non-blocking read that found nothing has no byte to record. Recording
// one would put a NUL in the middle of a reply still being assembled.
if result.ok() {
buffer = append(buffer, byte(c))
}
pc := c
for {
@@ -482,12 +422,6 @@ func (r *LightRenderer) escSequence(sz *int) Event {
return Event{Invalid, 0, nil}
}
loc = pasteModeRegexpBegin.FindIndex(r.buffer)
if loc != nil && loc[0] == 0 {
*sz = loc[1]
return Event{Invalid, 0, nil}
}
*sz = 2
if r.buffer[1] == 8 {
return Event{CtrlAltBackspace, 0, nil}
@@ -971,15 +905,12 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
down := rest[end] == 'M'
scroll := 0
wheel := t >= 64
if wheel {
if t >= 64 {
t -= 64
// SGR wheel button codes: 64=up, 65=down, 66=left, 67=right
switch t & 0b11 {
case 0:
scroll = 1
case 1:
if t&0b1 == 1 {
scroll = -1
} else {
scroll = 1
}
}
@@ -990,7 +921,7 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
shift := t&0b00100 > 0
drag := t&0b100000 > 0 // 32
if wheel {
if scroll != 0 {
return Event{Mouse, 0, &MouseEvent{y, x, scroll, false, false, false, ctrl, alt, shift}}
}
@@ -1061,23 +992,11 @@ func (r *LightRenderer) disableMouse() {
func (r *LightRenderer) disableModes() {
r.disableMouse()
// Put bracketed paste back the way we found it. A shell that runs fzf from
// a line editor widget re-enables the mode only when the editor starts, so
// forcing it off here would leave it off for the rest of the session.
// Terminals that did not answer the query fall back to disabling, which is
// what fzf has always done.
if r.pasteWasSet != nil && *r.pasteWasSet {
r.csi("?2004h")
} else {
r.csi("?2004l")
}
}
func (r *LightRenderer) Resume(clear bool, sigcont bool) {
r.setupTerminal()
// The programs that ran in the meantime may have left the terminal in an
// arbitrary SGR state
r.invalidateSGR()
if clear {
if r.fullscreen {
r.smcup()
@@ -1099,6 +1018,7 @@ func (r *LightRenderer) Clear() {
if r.fullscreen {
r.csi("H")
}
// r.csi("u")
r.origin()
r.csi("J")
r.flush()
@@ -1121,6 +1041,7 @@ func (r *LightRenderer) Refresh() {
}
func (r *LightRenderer) Close() {
// r.csi("u")
if r.clearOnExit {
if r.fullscreen {
r.rmcup()
@@ -1132,7 +1053,7 @@ func (r *LightRenderer) Close() {
r.csi("J")
}
} else if !r.fullscreen {
r.stderr("\x1b8") // DECRC: restore cursor position
r.csi("u")
}
if !r.showCursor {
r.csi("?25h")
@@ -1349,6 +1270,10 @@ func (w *LightWindow) drawBorder(onlyHorizontal bool) {
}
}
func (w *LightWindow) csi(code string) string {
return w.renderer.csi(code)
}
func (w *LightWindow) stderrInternal(str string, allowNLCR bool, resetCode string) {
w.renderer.stderrInternal(str, allowNLCR, resetCode)
}
@@ -1487,18 +1412,13 @@ func ulColorCode(c Color) string {
return ""
}
// csiColor builds the SGR sequence for the given colors and attributes
// without emitting it. The sequence starts with a reset parameter, so it
// fully determines the SGR state on its own.
func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) {
codes := append(attrCodes(attr), colorCodes(fg, bg)...)
if ulCode := ulColorCode(ul); ulCode != "" {
codes = append(codes, ulCode)
}
if len(codes) == 0 {
return false, "\x1b[0m"
}
return true, "\x1b[;" + strings.Join(codes, ";") + "m"
code := w.csi(";" + strings.Join(codes, ";") + "m")
return len(codes) > 0, code
}
func (w *LightWindow) Print(text string) {
@@ -1511,13 +1431,15 @@ func cleanse(str string) string {
func (w *LightWindow) CPrint(pair ColorPair, text string) {
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr())
w.renderer.setSGR(code)
w.stderrInternal(cleanse(text), false, code)
w.csi("0m")
}
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
_, code := w.csiColor(fg, bg, colDefault, attr)
w.renderer.setSGR(code)
hasColors, code := w.csiColor(fg, bg, colDefault, attr)
if hasColors {
defer w.csi("0m")
}
w.stderrInternal(cleanse(text), false, code)
}
@@ -1538,7 +1460,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
}
w.MoveAndClear(w.posy, w.posx)
w.Move(w.posy+1, 0)
w.renderer.setSGR(resetCode)
w.renderer.stderr(resetCode)
if len(lines) > 1 {
sign := w.wrapSign
width := w.wrapSignWidth
@@ -1548,8 +1470,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
width = truncatedWidth
}
w.stderrInternal(DIM+sign, false, resetCode)
w.renderer.invalidateSGR()
w.renderer.setSGR(resetCode)
w.renderer.stderr(resetCode)
w.Move(w.posy, width)
}
}
@@ -1560,20 +1481,21 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
return FillSuspend
}
w.Move(w.posy+1, 0)
w.renderer.setSGR(resetCode)
w.renderer.stderr(resetCode)
return FillNextLine
}
return FillContinue
}
func (w *LightWindow) setBg() string {
// The plain reset code for the default background is still required to
// clear the dim attribute after ␍ in the preview window
// e.g. printf "foo\rbar" | fzf --ansi --preview 'printf "foo\rbar"'
if w.bg != colDefault {
_, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
w.renderer.setSGR(code)
return code
}
// Should clear dim attribute after ␍ in the preview window
// e.g. printf "foo\rbar" | fzf --ansi --preview 'printf "foo\rbar"'
return "\x1b[m"
}
func (w *LightWindow) LinkBegin(uri string, params string) {
w.renderer.queued.WriteString("\x1b]8;" + params + ";" + uri + "\x1b\\")
@@ -1598,7 +1520,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string
bg = w.bg
}
if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors {
w.renderer.setSGR(resetCode)
defer w.csi("0m")
return w.fill(text, resetCode)
}
return w.fill(text, w.setBg())
-127
View File
@@ -3,7 +3,6 @@ package tui
import (
"fmt"
"os"
"strings"
"testing"
"unicode"
)
@@ -351,129 +350,3 @@ func TestLightRenderer(t *testing.T) {
assertEscSequence("\x1b[14~", "f4")
}
func TestLightRendererScrollWheel(t *testing.T) {
tty_file, _ := os.Open("")
renderer, _ := NewLightRenderer(
"", tty_file, &ColorTheme{}, true, true, 0, false, true,
func(h int) int { return h })
light_renderer := renderer.(*LightRenderer)
assertScroll := func(sequence string, scroll int, mods string) {
bytes := []byte(sequence)
light_renderer.buffer = bytes
sz := 1
event := light_renderer.escSequence(&sz)
me := event.MouseEvent
if event.Type != Mouse || me == nil {
t.Errorf("sequence: %q | got %s, want a Mouse event", sequence, event.Type.String())
return
}
got := ""
if me.Ctrl {
got += "ctrl-"
}
if me.Alt {
got += "alt-"
}
if me.Shift {
got += "shift-"
}
got = strings.TrimSuffix(got, "-")
if me.S != scroll || got != mods || me.Down {
t.Errorf(
"sequence: %q | scroll=%d mods=%q down=%v != scroll=%d mods=%q down=false",
sequence, me.S, got, me.Down, scroll, mods)
}
}
assertScroll("\x1b[<64;1;1M", 1, "") // up
assertScroll("\x1b[<65;1;1M", -1, "") // down
assertScroll("\x1b[<66;1;1M", 0, "") // left (ignored)
assertScroll("\x1b[<67;1;1M", 0, "") // right (ignored)
assertScroll("\x1b[<68;1;1M", 1, "shift") // shift + up
assertScroll("\x1b[<69;1;1M", -1, "shift") // shift + down
assertScroll("\x1b[<70;1;1M", 0, "shift") // shift + left (ignored)
assertScroll("\x1b[<71;1;1M", 0, "shift") // shift + right (ignored)
assertScroll("\x1b[<72;1;1M", 1, "alt") // alt + up
assertScroll("\x1b[<73;1;1M", -1, "alt") // alt + down
assertScroll("\x1b[<74;1;1M", 0, "alt") // alt + left (ignored)
assertScroll("\x1b[<75;1;1M", 0, "alt") // alt + right (ignored)
assertScroll("\x1b[<80;1;1M", 1, "ctrl") // ctrl + up
assertScroll("\x1b[<81;1;1M", -1, "ctrl") // ctrl + down
assertScroll("\x1b[<82;1;1M", 0, "ctrl") // ctrl + left (ignored)
assertScroll("\x1b[<83;1;1M", 0, "ctrl") // ctrl + right (ignored)
assertScroll("\x1b[<84;1;1M", 1, "ctrl-shift") // ctrl+shift + up
assertScroll("\x1b[<85;1;1M", -1, "ctrl-shift") // ctrl+shift + down
assertScroll("\x1b[<86;1;1M", 0, "ctrl-shift") // ctrl+shift + left (ignored)
assertScroll("\x1b[<87;1;1M", 0, "ctrl-shift") // ctrl+shift + right (ignored)
assertScroll("\x1b[<92;1;1M", 1, "ctrl-alt-shift") // ctrl+alt+shift + up
assertScroll("\x1b[<93;1;1M", -1, "ctrl-alt-shift") // ctrl+alt+shift + down
assertScroll("\x1b[<94;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + left (ignored)
assertScroll("\x1b[<95;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + right (ignored)
}
// Assert the exact byte stream emitted for a scripted rendering sequence,
// locking down the SGR deduplication behavior of the light renderer.
func TestLightRendererSGRDeduplication(t *testing.T) {
renderer, _ := NewLightRenderer(
"", nil, &ColorTheme{}, true, false, 8, false, true,
func(h int) int { return h })
r := renderer.(*LightRenderer)
r.width = 80
r.height = 24
out, err := os.CreateTemp(t.TempDir(), "fzf-ttyout")
if err != nil {
t.Fatal(err)
}
r.ttyout = out
w := r.NewWindow(0, 0, 40, 4, WindowList, MakeBorderStyle(BorderNone, true), false).(*LightWindow)
w.fg = colDefault
w.bg = colDefault
r.queued.Reset()
red := NewColorPair(196, colDefault, AttrRegular)
blue := NewColorPair(21, colDefault, AttrRegular)
w.Move(0, 0)
w.CPrint(red, "ab")
w.CPrint(red, "cd") // same pair; no SGR expected
w.CPrint(blue, "ef")
w.Print("gh") // default colors; one reset expected
w.Print("ij") // still default; no SGR expected
w.Move(1, 2)
w.CPrint(red, "kl")
r.invalidateSGR()
w.CPrint(red, "mn") // re-emitted after invalidation
r.flush() // adds a trailing reset to leave the default state
expected := "\x1b[?2026h\x1b[?7l\x1b[?25l" +
"\r" +
"\x1b[;38;5;196mab" +
"cd" +
"\x1b[;38;5;21mef" +
"\x1b[0mgh" +
"ij" +
"\x1b[1B\x1b[3G" +
"\x1b[;38;5;196mkl" +
"\x1b[;38;5;196mmn" +
"\x1b[0m" +
"\x1b[?25h\x1b[?7h\x1b[?2026l"
bytes, err := os.ReadFile(out.Name())
if err != nil {
t.Fatal(err)
}
if string(bytes) != expected {
t.Errorf("expected: %q\n got: %q", expected, string(bytes))
}
}
+13 -102
View File
@@ -8,7 +8,6 @@ import (
"os/exec"
"strings"
"syscall"
"time"
"github.com/junegunn/fzf/src/util"
"golang.org/x/sys/unix"
@@ -94,113 +93,25 @@ func (r *LightRenderer) updateTerminalSize() {
}
}
// waitReadable reports whether the tty has something to read before the
// deadline. A terminal that does not recognize a query answers nothing at all,
// so the read that follows must be able to stop waiting, or fzf would wait for
// the user to press a key instead of drawing itself. The timeout is generous
// because a terminal that does answer exceeds it only when the link is slow
// enough to be unusable anyway.
func (r *LightRenderer) waitReadable(timeout time.Duration) bool {
fd := r.fd()
deadline := time.Now().Add(timeout)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false
}
var rfds unix.FdSet
if fd >= len(rfds.Bits)*unix.NFDBITS {
return false
}
rfds.Set(fd)
// Recomputed each time round: Linux select rewrites the timeout with
// the time left, other systems leave it alone
tv := unix.NsecToTimeval(int64(remaining))
n, err := unix.Select(fd+1, &rfds, nil, nil, &tv)
if err == syscall.EINTR {
continue
}
if err != nil {
// Nothing was confirmed readable, and the read that follows
// reports the failure if the fd is really broken
return false
}
return n > 0
}
}
// queryTerminal sends every query in a single write and reads until the last
// one is answered. Returns the submatches of each reply, nil for a query the
// terminal ignored. Replies are cut out of what we read as they are recognized,
// so whatever is left over is input the user typed during the round trip.
func (r *LightRenderer) queryTerminal(queries []termQuery) [][][]byte {
for _, query := range queries {
r.csi(query.seq)
}
func (r *LightRenderer) findOffset() (row int, col int) {
r.csi("6n")
r.flush()
replies := make([][][]byte, len(queries))
buffer := []byte{}
for tries := range offsetPollTries {
// Only the first read blocks, so that is the one to put a bound on
if tries == 0 && !r.waitReadable(queryTimeout) {
return replies
}
var err error
buffer, _, err = r.getBytesInternal(false, buffer, tries > 0)
bytes := []byte{}
for tries := range offsetPollTries {
bytes, _, err = r.getBytesInternal(false, bytes, tries > 0)
if err != nil {
return replies
}
for idx, query := range queries {
if replies[idx] != nil {
continue
}
loc := query.reply.FindSubmatchIndex(buffer)
if loc == nil {
continue
}
groups := make([][]byte, len(loc)/2)
for group := range groups {
if loc[group*2] >= 0 {
groups[group] = buffer[loc[group*2]:loc[group*2+1]]
}
}
replies[idx] = groups
// Capping the prefix makes append copy, leaving groups valid
buffer = append(buffer[:loc[0]:loc[0]], buffer[loc[1]:]...)
}
if replies[len(queries)-1] != nil {
break
}
}
r.buffer = append(r.buffer, buffer...)
return replies
}
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
replies := r.queryTerminal(startupQueries)
if paste := replies[0]; paste != nil && paste[1][0] != '0' {
// 1 = set, 3 = permanently set
set := paste[1][0] == '1' || paste[1][0] == '3'
pasteWasSet = &set
}
row, col = parseOffset(replies[1])
return
}
func parseOffset(reply [][]byte) (row int, col int) {
if reply == nil {
return -1, -1
}
return atoi(string(reply[1]), 0) - 1, atoi(string(reply[2]), 0) - 1
}
func (r *LightRenderer) findOffset() (row int, col int) {
return parseOffset(r.queryTerminal(startupQueries[1:])[0])
offsets := offsetRegexp.FindSubmatch(bytes)
if len(offsets) > 3 {
// Add anything we skipped over to the input buffer
r.buffer = append(r.buffer, offsets[1]...)
return atoi(string(offsets[2]), 0) - 1, atoi(string(offsets[3]), 0) - 1
}
}
return -1, -1
}
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
-7
View File
@@ -151,13 +151,6 @@ func (r *LightRenderer) findOffset() (row int, col int) {
return int(bufferInfo.CursorPosition.Y), int(bufferInfo.CursorPosition.X)
}
// The console API answers for the cursor, and there is no reply to parse for
// bracketed paste, so fzf keeps disabling the mode on exit here.
func (r *LightRenderer) queryStartup() (row int, col int, pasteWasSet *bool) {
row, col = r.findOffset()
return
}
func (r *LightRenderer) getch(cancellable bool, nonblock bool) (int, getCharResult) {
if !nonblock && !cancellable {
bc := <-r.ttyinChannel
+29 -42
View File
@@ -4,7 +4,6 @@ import (
"strconv"
"strings"
"time"
"unicode"
"github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg"
@@ -234,7 +233,6 @@ const (
ClickFooter
Multi
Every
ResultFinal
)
func (t EventType) AsEvent() Event {
@@ -254,12 +252,6 @@ func (e Event) Comparable() Event {
return Event{e.Type, e.Char, nil}
}
// Printable returns true if the event is a printable character that can be
// inserted into the query (e.g. via the 'put' action).
func (e Event) Printable() bool {
return e.Type == Rune && unicode.IsGraphic(e.Char)
}
func (e Event) KeyName() string {
if me := e.MouseEvent; me != nil {
return me.Name()
@@ -1004,55 +996,50 @@ func init() {
NoColorTheme = &ColorTheme{
Colored: false,
// Root colors. Everything else is left undefined so that overriding a
// root (e.g. --color bw,bg:blue) propagates to the derived colors,
// just like in the colored base themes.
Input: defaultColor,
Fg: defaultColor,
Bg: defaultColor,
ListFg: defaultColor,
ListBg: defaultColor,
AltBg: undefined,
SelectedFg: defaultColor,
SelectedBg: defaultColor,
SelectedMatch: defaultColor,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
Current: undefined,
CurrentMatch: undefined,
Spinner: defaultColor,
Info: defaultColor,
Pointer: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Footer: defaultColor,
BorderLabel: defaultColor,
// Derived colors. Left undefined so they inherit from a root.
ListFg: undefined,
ListBg: undefined,
AltBg: undefined,
SelectedFg: undefined,
SelectedBg: undefined,
SelectedMatch: undefined,
Current: undefined,
CurrentMatch: undefined,
Border: undefined,
BorderLabel: defaultColor,
Ghost: undefined,
Disabled: undefined,
PreviewFg: undefined,
PreviewBg: undefined,
Disabled: defaultColor,
PreviewFg: defaultColor,
PreviewBg: defaultColor,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
ListLabel: undefined,
ListBorder: undefined,
Separator: undefined,
Scrollbar: undefined,
InputBg: undefined,
InputBorder: undefined,
InputLabel: undefined,
HeaderBg: undefined,
HeaderBorder: undefined,
HeaderLabel: undefined,
FooterBg: undefined,
FooterBorder: undefined,
FooterLabel: undefined,
GapLine: undefined,
PreviewBorder: defaultColor,
PreviewScrollbar: defaultColor,
PreviewLabel: defaultColor,
ListLabel: defaultColor,
ListBorder: defaultColor,
Separator: defaultColor,
Scrollbar: defaultColor,
InputBg: defaultColor,
InputBorder: defaultColor,
InputLabel: defaultColor,
HeaderBg: defaultColor,
HeaderBorder: defaultColor,
HeaderLabel: defaultColor,
FooterBg: defaultColor,
FooterBorder: defaultColor,
FooterLabel: defaultColor,
GapLine: defaultColor,
Nth: undefined,
Nomatch: undefined,
}
+8 -141
View File
@@ -3,7 +3,6 @@ package util
import (
"bytes"
"fmt"
"math/bits"
"unicode"
"unicode/utf8"
"unsafe"
@@ -14,17 +13,9 @@ const (
overflow32 uint32 = 0x80808080
)
const (
flagInBytes uint8 = 1 << iota
flagMayFold
)
type Chars struct {
slice []byte // or []rune
// Only ever set, never cleared, so a reader racing a Prepend sees either
// the old or the new value and both are safe. trimLength* is kept out
// because TrimLength rewrites it.
flags uint8
inBytes bool
trimLengthKnown bool
trimLength uint16
@@ -33,59 +24,6 @@ type Chars struct {
Index int32
}
// Rune ranges that case folding or normalization can turn into ASCII, derived
// from algo's normalization table and unicode.ToLower, then merged. They are a
// superset of the exact set, which TestMayFoldToAsciiIsSuperset in the algo
// package verifies. Grouped tightly on purpose: a wider merge would include
// Greek Extended, General Punctuation and the currency and letterlike blocks,
// and every line holding a curly quote or an em dash would then lose the
// prefilter. Cyrillic, Greek, Hebrew, Arabic, Thai, Devanagari, CJK, Hangul,
// kana, emoji, punctuation and box drawing are all outside.
const (
foldLo = 0x00C0
foldHi = 0xFF61
)
var foldableRanges = [...][2]rune{
{0x00C0, 0x01B6}, // Latin-1 Supplement, Latin Extended-A and -B
{0x01CD, 0x02AE}, // rest of Latin Extended-B and IPA Extensions
{0x0363, 0x036F}, // combining Latin small letters
{0x1D00, 0x1D22}, // Phonetic Extensions, small capitals
{0x1D62, 0x1D65}, // subscript letters
{0x1E00, 0x1EF9}, // Latin Extended Additional
{0x2071, 0x2071}, // superscript i
{0x2095, 0x209C}, // subscript letters
{0x212A, 0x212B}, // KELVIN SIGN and ANGSTROM SIGN, which fold by case
{0x2183, 0x2184}, // reversed roman numeral one hundred
{0x2C62, 0x2C7F}, // Latin Extended-C
{0xA78D, 0xA78D}, // Latin Extended-D
{0xA7AA, 0xA7B2}, // more Latin Extended-D
{0xA7C5, 0xA7C5},
{0xFF01, 0xFF61}, // fullwidth ASCII forms, and halfwidth ideographic full stop
}
// Walking the ranges costs a serial chain of comparisons per rune, which is
// measurable at ingestion, so precompute a bitmap instead.
var foldableBits = func() (bits [(foldHi-foldLo)/8 + 1]byte) {
for _, r := range foldableRanges {
for c := r[0]; c <= r[1]; c++ {
i := c - foldLo
bits[i>>3] |= 1 << (i & 7)
}
}
return
}()
// MayFoldToAscii reports whether case folding or normalization could turn r
// into an ASCII character.
func MayFoldToAscii(r rune) bool {
i := uint32(r - foldLo)
if i > foldHi-foldLo {
return false
}
return foldableBits[i>>3]&(1<<(i&7)) != 0
}
func checkAscii(bytes []byte) (bool, int) {
i := 0
for ; i <= len(bytes)-8; i += 8 {
@@ -106,94 +44,31 @@ func checkAscii(bytes []byte) (bool, int) {
return true, 0
}
// countRunes counts the bytes that are not UTF-8 continuation bytes, which is
// the rune count of valid UTF-8. Each invalid byte decodes to its own
// RuneError, so the result can undercount but never overcount, making it safe
// as a capacity hint.
func countRunes(bytes []byte) int {
n, i := 0, 0
for ; i <= len(bytes)-8; i += 8 {
v := *(*uint64)(unsafe.Pointer(&bytes[i]))
// Continuation byte: bit 7 set, bit 6 clear. In `v << 1` bit 7 of each
// lane holds bit 6 of that same lane.
n += 8 - bits.OnesCount64(v&^(v<<1)&overflow64)
}
for ; i < len(bytes); i++ {
if bytes[i]&0xC0 != 0x80 {
n++
}
}
return n
}
// ToChars converts byte array into rune array
func ToChars(bytes []byte) Chars {
inBytes, bytesUntil := checkAscii(bytes)
if inBytes {
return Chars{slice: bytes, flags: flagInBytes}
return Chars{slice: bytes, inBytes: inBytes}
}
runes := make([]rune, bytesUntil, bytesUntil+countRunes(bytes[bytesUntil:]))
runes := make([]rune, bytesUntil, len(bytes))
for i := range bytesUntil {
runes[i] = rune(bytes[i])
}
mayFold := false
for i := bytesUntil; i < len(bytes); {
// utf8.DecodeRune has an ASCII path of its own, but it is too complex
// to inline, so a mostly-ASCII line pays one call per byte for it.
// An ASCII rune never sets the fold bit either, so skip both calls.
if b := bytes[i]; b < utf8.RuneSelf {
runes = append(runes, rune(b))
i++
continue
}
r, sz := utf8.DecodeRune(bytes[i:])
i += sz
mayFold = mayFold || MayFoldToAscii(r)
runes = append(runes, r)
}
return runesToChars(runes, mayFold)
return RunesToChars(runes)
}
// RunesToChars adopts the caller's slice rather than copying it, so the caller
// must not keep mutating it. See Runes for why.
func RunesToChars(runes []rune) Chars {
mayFold := false
for _, r := range runes {
if MayFoldToAscii(r) {
mayFold = true
break
}
}
return runesToChars(runes, mayFold)
}
func runesToChars(runes []rune, mayFold bool) Chars {
var flags uint8
if mayFold {
flags = flagMayFold
}
return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), flags: flags}
return Chars{slice: *(*[]byte)(unsafe.Pointer(&runes)), inBytes: false}
}
func (chars *Chars) IsBytes() bool {
return chars.flags&flagInBytes != 0
}
// MayFoldToAscii reports whether the text holds a rune that case folding or
// normalization could turn into an ASCII character. When false, an ASCII
// pattern character can only match the identical ASCII rune, which is what
// lets the prefilter scan the rune array directly.
func (chars *Chars) MayFoldToAscii() bool {
return chars.flags&flagMayFold != 0
}
// Runes returns the underlying rune slice, or nil if the text is kept as
// bytes. Read only. The result aliases the text, so writing to it would change
// the text without updating the cached fold bit, and the prefilter would then
// reject items it should match. Copy before mutating.
func (chars *Chars) Runes() []rune {
return chars.optionalRunes()
return chars.inBytes
}
func (chars *Chars) Bytes() []byte {
@@ -230,7 +105,7 @@ func (chars *Chars) NumLines(atMost int) (int, bool) {
}
func (chars *Chars) optionalRunes() []rune {
if chars.IsBytes() {
if chars.inBytes {
return nil
}
return *(*[]rune)(unsafe.Pointer(&chars.slice))
@@ -252,7 +127,7 @@ func (chars *Chars) Length() int {
// String returns the string representation of a Chars object.
func (chars *Chars) String() string {
return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, mayFold: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.IsBytes(), chars.MayFoldToAscii(), chars.trimLengthKnown, chars.trimLength, chars.Index)
return fmt.Sprintf("Chars{slice: []byte(%q), inBytes: %v, trimLengthKnown: %v, trimLength: %d, Index: %d}", chars.slice, chars.inBytes, chars.trimLengthKnown, chars.trimLength, chars.Index)
}
// TrimLength returns the length after trimming leading and trailing whitespaces
@@ -343,8 +218,6 @@ func (chars *Chars) ToString() string {
return unsafe.String(unsafe.SliceData(chars.slice), len(chars.slice))
}
// ToRunes returns the text as runes. In rune mode the result aliases the text
// and must not be mutated, see Runes. In byte mode it is a fresh slice.
func (chars *Chars) ToRunes() []rune {
if runes := chars.optionalRunes(); runes != nil {
return runes
@@ -374,12 +247,6 @@ func (chars *Chars) Prepend(prefix string) {
} else {
chars.slice = append([]byte(prefix), chars.slice...)
}
for _, r := range prefix {
if MayFoldToAscii(r) {
chars.flags |= flagMayFold
break
}
}
}
func (chars *Chars) Lines(multiLine bool, maxLines int, wrapCols int, wrapSignWidth int, tabstop int, wrapWord bool) ([][]rune, bool) {
+20 -162
View File
@@ -2,106 +2,19 @@ package util
import (
"fmt"
"math/rand"
"strings"
"testing"
"unicode/utf8"
"unsafe"
)
func TestCountRunes(t *testing.T) {
for _, str := range []string{
"", "a", "abc", "한글", "🎉🎉", "\tabc한글 ",
strings.Repeat("漢字", 50), strings.Repeat("a", 33) + "é",
} {
if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp {
t.Errorf("countRunes(%q) = %d, expected %d", str, got, exp)
}
}
}
func TestCountRunesRandom(t *testing.T) {
rng := rand.New(rand.NewSource(1))
// Exact on valid UTF-8
for trial := range 20000 {
var sb strings.Builder
for range rng.Intn(20) {
r := rune(rng.Intn(utf8.MaxRune + 1))
for r >= 0xD800 && r <= 0xDFFF {
r = rune(rng.Intn(utf8.MaxRune + 1))
}
sb.WriteRune(r)
}
str := sb.String()
if got, exp := countRunes([]byte(str)), utf8.RuneCountInString(str); got != exp {
t.Fatalf("trial %d: countRunes(%q) = %d, expected %d", trial, str, got, exp)
}
}
// Never an overcount on arbitrary bytes, so the capacity hint never truncates
for trial := range 20000 {
buf := make([]byte, rng.Intn(40))
rng.Read(buf)
if got, exp := countRunes(buf), utf8.RuneCount(buf); got > exp {
t.Fatalf("trial %d: countRunes(%x) = %d, overcounts %d", trial, buf, got, exp)
}
}
}
// ToChars must produce exactly what a []rune conversion produces, including
// one RuneError per invalid byte, and must size the rune slice exactly when
// the input is valid UTF-8.
func TestToCharsIntegrity(t *testing.T) {
rng := rand.New(rand.NewSource(2))
check := func(buf []byte, exactCap bool) {
chars := ToChars(buf)
exp := []rune(string(buf))
if chars.Length() != len(exp) {
t.Fatalf("ToChars(%x).Length() = %d, expected %d", buf, chars.Length(), len(exp))
}
for i, r := range exp {
if chars.Get(i) != r {
t.Fatalf("ToChars(%x).Get(%d) = %q, expected %q", buf, i, chars.Get(i), r)
}
}
if runes := chars.optionalRunes(); runes != nil && exactCap && cap(runes) != len(exp) {
t.Fatalf("ToChars(%x) cap = %d, expected %d", buf, cap(runes), len(exp))
}
}
for range 5000 {
var sb strings.Builder
sb.WriteString("ascii")
for range 1 + rng.Intn(10) {
r := rune(0x80 + rng.Intn(utf8.MaxRune-0x80))
for r >= 0xD800 && r <= 0xDFFF {
r = rune(0x80 + rng.Intn(utf8.MaxRune-0x80))
}
sb.WriteRune(r)
}
check([]byte(sb.String()), true)
}
// Invalid UTF-8: still correct, capacity may grow
for range 5000 {
buf := make([]byte, 1+rng.Intn(40))
rng.Read(buf)
buf[rng.Intn(len(buf))] |= 0x80 // force the rune path
check(buf, false)
}
}
func TestToCharsAscii(t *testing.T) {
chars := ToChars([]byte("foobar"))
if !chars.IsBytes() || chars.ToString() != "foobar" {
if !chars.inBytes || chars.ToString() != "foobar" || !chars.inBytes {
t.Error()
}
}
func TestCharsLength(t *testing.T) {
chars := ToChars([]byte("\tabc한글 "))
if chars.IsBytes() || chars.Length() != 8 || chars.TrimLength() != 5 {
if chars.inBytes || chars.Length() != 8 || chars.TrimLength() != 5 {
t.Error()
}
}
@@ -194,80 +107,25 @@ func TestCharsLinesWrapWord(t *testing.T) {
t.Errorf("Expected first line 'abcdefghij', got %q", string(lines2[0]))
}
// Tab as word boundary
chars3 := ToChars([]byte("hello\tworld"))
lines3, _ := chars3.Lines(false, 100, 7, 0, 8, true)
// "hello\t" should break at tab (width of tab at pos 5 with tabstop 8 = 3, total width = 8 > 7)
// Actually RunesWidth: 'h'=1,'e'=1,'l'=1,'l'=1,'o'=1,'\t'=3 = 8 > 7, overflowIdx=5
// Then word-wrap scans back and finds no space/tab before idx 5 (tab IS at idx 5 but we check line[k-1])
// Wait - let me think: overflowIdx=5, we check k=5 -> line[4]='o', k=4 -> line[3]='l'... no space/tab found
// Falls back to character wrap: "hello" | "\tworld"
if len(lines3) < 2 {
t.Errorf("Expected at least 2 lines for tab test, got %d: %v", len(lines3), lines3)
}
// wrapWord=false still character-wraps
chars3 := ToChars([]byte("hello world"))
lines3, _ := chars3.Lines(false, 100, 8, 0, 8, false)
if len(lines3) != 2 {
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines3), lines3)
chars4 := ToChars([]byte("hello world"))
lines4, _ := chars4.Lines(false, 100, 8, 0, 8, false)
if len(lines4) != 2 {
t.Errorf("Expected 2 lines with wrapWord=false, got %d: %v", len(lines4), lines4)
}
if string(lines3[0]) != "hello wo" {
t.Errorf("Expected first line 'hello wo', got %q", string(lines3[0]))
}
}
// Chars is one per input line, so its size matters. It has no spare padding,
// which is why new state goes in the flags byte rather than a field.
// Derive the expectation from the slice header so the invariant holds on
// 32-bit builds too, where the header is 12 bytes and Chars is 20.
func TestCharsSize(t *testing.T) {
var slice []byte
// flags 1 + trimLengthKnown 1 + trimLength 2 + Index 4, no padding
want := unsafe.Sizeof(slice) + 8
if size := unsafe.Sizeof(Chars{}); size != want {
t.Errorf("unsafe.Sizeof(Chars{}) = %d, expected %d", size, want)
}
}
func TestMayFoldFlag(t *testing.T) {
for _, c := range []struct {
text string
fold bool
}{
{"한글/src", false}, {"漢字", false}, {"мир", false}, {"🎉", false},
{"café", true}, {"Müller", true}, {"Å", true}, {"full", true},
} {
chars := ToChars([]byte(c.text))
if chars.MayFoldToAscii() != c.fold {
t.Errorf("ToChars(%q).MayFoldToAscii() = %v, expected %v", c.text, chars.MayFoldToAscii(), c.fold)
}
if runes := RunesToChars([]rune(c.text)); runes.MayFoldToAscii() != c.fold {
t.Errorf("RunesToChars(%q).MayFoldToAscii() = %v, expected %v", c.text, runes.MayFoldToAscii(), c.fold)
}
}
// Prepend can introduce foldable runes
chars := ToChars([]byte("한글"))
if chars.MayFoldToAscii() {
t.Fatal("baseline should not be foldable")
}
chars.Prepend("é")
if !chars.MayFoldToAscii() {
t.Error("Prepend of a foldable prefix must set the flag")
}
}
// Runes and ToRunes alias the text in rune mode, so a consumer that mutates
// what they return changes the text without updating the cached fold bit. This
// verifies the aliasing so the read-only contract on those methods is not
// silently dropped later.
func TestRuneSlicesAliasTheText(t *testing.T) {
chars := ToChars([]byte("한글abc"))
runes := chars.Runes()
if runes == nil {
t.Fatal("expected rune mode")
}
if &runes[0] != &chars.ToRunes()[0] {
t.Error("Runes and ToRunes should return the same backing array")
}
if chars.MayFoldToAscii() {
t.Fatal("baseline should not be foldable")
}
// Demonstrates why callers must copy: the flag does not follow the text.
runes[0] = 'e'
if chars.MayFoldToAscii() {
t.Error("flag unexpectedly updated")
}
if got := chars.ToString(); got != "e글abc" {
t.Errorf("expected the write to reach the text, got %q", got)
if string(lines4[0]) != "hello wo" {
t.Errorf("Expected first line 'hello wo', got %q", string(lines4[0]))
}
}
-42
View File
@@ -1,53 +1,18 @@
package fzf
import (
"fmt"
"os/exec"
)
const zellijBorderLabelEnv = internalEnvPrefix + "ZELLIJ_LABEL_PANE"
// Errors are ignored. The pane may be gone
func setZellijBorderLabel(pane string, label string) {
// Strip ANSI sequences fzf would otherwise render itself
label, _, _ = extractColor(label, nil, nil)
// '--' so that a label starting with a hyphen is not parsed as a flag
exec.Command("zellij", "action", "rename-pane", "-p", pane, "--", label).Run()
}
func runZellij(args []string, opts *Options) (int, error) {
// Use the native Zellij border by default, consistent with tmux, so that
// the pane can be moved and resized with the mouse. Set before
// popupArgStr so that it does not inject an fzf border. fzf draws its own
// border instead when a border style is explicitly specified.
if nativeBorder(opts) {
opts.Tmux.border = true
}
argStr, dir := popupArgStr(args, opts)
zellijArgs := []string{
"run", "--floating", "--close-on-exit", "--block-until-exit",
"--cwd", dir,
}
ownsLabel := false
if !opts.Tmux.border {
zellijArgs = append(zellijArgs, "--borderless", "true")
} else {
// Set --border-label as the name of the pane, displayed on the
// native border. The label is left to fzf when it draws its own
// border with the label on it (border-native with an explicit
// --border style). Empty otherwise, to override the default name
// (the running command). Passed as a single argument in the
// --name=label form; the detached form fails to parse when the
// label starts with a hyphen. No escaping is needed beyond
// stripping ANSI sequences fzf would otherwise render itself.
// --border-label-pos is ignored.
label := ""
ownsLabel = noBorderSpecified(opts)
if ownsLabel {
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
}
zellijArgs = append(zellijArgs, "--name="+label)
}
switch opts.Tmux.position {
case posUp:
@@ -70,14 +35,7 @@ func runZellij(args []string, opts *Options) (int, error) {
if err != nil {
return nil, err
}
if ownsLabel {
// 'zellij run' cannot set the new pane's environment, so the
// command exports it itself. exec avoids a lingering shell
zellijArgs = append(zellijArgs, sh, "-c", fmt.Sprintf(`export %s="$ZELLIJ_PANE_ID"; exec %s %s`,
zellijBorderLabelEnv, escapeSingleQuote(sh), escapeSingleQuote(temp)))
} else {
zellijArgs = append(zellijArgs, sh, temp)
}
return exec.Command("zellij", zellijArgs...), nil
}, opts, true)
}
+1 -8
View File
@@ -136,7 +136,6 @@ class Tmux
rescue Minitest::Assertion
retries += 1
raise if retries > 5
retry
end
send_keys 'clear', :Enter
@@ -177,12 +176,6 @@ class Tmux
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
end
# Paste with bracketed paste control codes so fzf sees
# bracketed-paste-begin/end around the content
def paste_bracketed(str)
system('tmux', 'setb', str, ';', 'pasteb', '-p', '-t', win)
end
def capture
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
end
@@ -302,7 +295,7 @@ class Tmux
if @shell == :nushell
message = "Prepare[#{tries}]"
send_keys 'C-u', 'C-l'
sleep(0.2)
sleep 0.2
send_keys ' ', 'C-u', :Enter, message
self.until { |lines| lines[-1] == message }
else
-186
View File
@@ -479,19 +479,6 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal '> 10', lines[-1] }
end
def test_bind_replace_query_does_not_mutate_item
tmux.send_keys "echo '한글abcde' | #{fzf('--bind=ctrl-j:replace-query,ctrl-o:clear-query')}", :Enter
tmux.until { |lines| assert_equal ' 1/1', lines[-2] }
tmux.send_keys 'C-j'
tmux.until { |lines| assert_equal '> 한글abcde', lines[-1] }
# Editing away from the end used to write into the item itself
tmux.send_keys :Left, :BSpace
tmux.until { |lines| assert_equal '> 한글abce', lines[-1] }
tmux.send_keys 'C-o'
tmux.until { |lines| assert_equal '>', lines[-1] }
tmux.until { |lines| assert_equal '> 한글abcde', lines[-3] }
end
def test_select_all_deselect_all_toggle_all
tmux.send_keys "seq 100 | #{fzf('--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all --multi')}", :Enter
tmux.until { |lines| assert_equal ' 100/100 (0)', lines[-2] }
@@ -984,24 +971,6 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_includes lines[1], ' aabravo/aabravo' }
end
def test_transform_put
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:transform:echo put'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys :a
tmux.until { |lines| assert_equal '> a', lines.last }
tmux.send_keys :b
tmux.until { |lines| assert_equal '> ab', lines.last }
end
# The async callback runs in a later iteration, but 'put' must still insert
# the key that triggered the bg-transform (snapshot of the scheduling event).
def test_bg_transform_put
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:bg-transform:sleep 0.5; echo put'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys 'ab'
tmux.until { |lines| assert_equal '> ba', lines.last }
end
def test_accept_non_empty
tmux.send_keys %(seq 1000 | #{fzf('--print-query --bind enter:accept-non-empty')}), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
@@ -1175,130 +1144,6 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal 10, lines.match_count }
end
def test_wait_action
tmux.send_keys %((seq 100; sleep 15) | #{FZF} --bind 'start:search(1)+wait+best'), :Enter
tmux.until { |lines| assert_equal 20, lines.match_count }
tmux.until { |lines| assert lines.any_include?('20/100 (..)') }
tmux.send_keys 'C-c'
tmux.until { |lines| refute lines.any_include?('20/100 (..)') }
# Ctrl-C cancels the wait; fzf keeps running and accepts input again
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
end
def test_wait_action_start
# 'start:wait' blocks on the initial load until reading completes
tmux.send_keys %((seq 100; sleep 15) | #{FZF} --bind 'start:wait'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.until { |lines| assert lines.any_include?('(..)') }
tmux.send_keys 'C-c'
tmux.until { |lines| refute lines.any_include?('(..)') }
# Ctrl-C cancels the wait; fzf keeps running and accepts input again
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
end
def test_wait_action_bg_transform
# A bg-transform result is unrelated to the wait, so it's applied while the
# wait is still blocking rather than dropped. The long read keeps the wait
# blocked so the (instant) bg-transform completes during the block; the
# header must show while '(..)' is still displayed.
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:bg-transform-header(echo hello)+search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && lines.any_include?('hello')) }
end
def test_wait_action_bg_transform_actions
# Actions parsed from a generic bg-transform result are also applied
# while wait-blocked
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:bg-transform(echo change-header:hello)+search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && lines.any_include?('hello')) }
end
def test_wait_action_bg_transform_join
# A 'wait' in a bg-transform result body joins the ongoing wait; the
# actions after it run when the wait unblocks instead of being dropped
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:bg-transform(echo change-header{hello}+wait+change-footer{world})+search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && lines.any_include?('hello') && !lines.any_include?('world')) }
tmux.until { |lines| assert(lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_query_change
# Query-editing actions must also make wait block; accept must run on the
# results of the new query, not the stale ones
tmux.send_keys %(seq 100 | #{fzf("--bind 'space:change-query(55)+wait+accept'")}), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.send_keys :Space
assert_equal '55', fzf_output
end
def test_wait_action_trigger_join
# A wait armed inside a triggered chord defers the remaining actions of
# the outer binding as well
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:trigger(x)+change-footer(world)' --bind 'x:search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && !lines.any_include?('world')) }
tmux.until { |lines| assert(lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_trigger_siblings
# Chords after a wait-arming chord are deferred, not dropped
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:trigger(x,y)+change-footer(world)' --bind 'x:search(5)+wait' --bind 'y:change-header(hello)'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && !lines.any_include?('hello') && !lines.any_include?('world')) }
tmux.until { |lines| assert(lines.any_include?('hello') && lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_cancel_rearm
# Deferral works even when the wait is cancelled and re-armed within a
# single action list. --query keeps 'cancel' non-fatal in case the Space
# arrives after the initial wait has already unblocked.
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --query 5 --bind 'start:wait' --bind 'space:cancel+trigger(x)+change-footer(world)' --bind 'x:search(5)+wait'), :Enter
tmux.until { |lines| assert lines.any_include?('(..)') }
tmux.send_keys :Space
tmux.until { |lines| assert(lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_jump
# A pending jump action must survive the wake-up event after unblock
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --jump-labels abc --bind 'start:search(5)+wait+jump'), :Enter
tmux.until { |lines| assert_equal 'a 5', lines[-3] }
tmux.send_keys 'a'
tmux.until { |lines| assert_equal '> 5', lines[-3] }
end
def test_wait_action_bracketed_paste
# A wait armed by a binding fired from a pasted character must not break
# bracketed paste handling: paste-end passes through the block so
# t.pasting is cleared and the pending search is dispatched
tmux.send_keys %(seq 100 | #{FZF} --bind 'a:put(a)+wait+first'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.paste_bracketed('1a2')
# Search for the edited query must run; blocked forever before the fix
tmux.until { |lines| assert_equal 0, lines.match_count }
# Filtering must still work afterwards
tmux.send_keys 'C-u', '55'
tmux.until { |lines| assert_equal 1, lines.match_count }
end
def test_wait_action_expect
# --expect keys are ignored while wait-blocked, like the rest of the input
tmux.send_keys %((seq 100; sleep 2) | #{fzf('--expect ctrl-t --bind start:wait')}), :Enter
tmux.until { |lines| assert lines.any_include?('(..)') }
tmux.send_keys 'C-t'
# fzf must still be running; the wait unblocks when loading completes
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.until { |lines| refute lines.any_include?('(..)') }
tmux.send_keys 'C-t'
assert_equal %w[ctrl-t 1], fzf_output_lines
end
def test_track_blocked_bg_transform
# A bg-transform result completing while track-blocked is applied, not
# dropped. The header must show while '+T*' is still displayed.
tmux.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:bg-transform-header(echo hello)+reload(sleep 2; seq 100)'", :Enter
tmux.until { |lines| assert_includes lines[-2], '+T' }
tmux.send_keys 'C-r'
tmux.until { |lines| assert(lines.any_include?('+T*') && lines.any_include?('hello')) }
end
def test_clear_selection
tmux.send_keys %(seq 100 | #{FZF} --multi --bind space:clear-selection), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
@@ -1542,17 +1387,6 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_includes lines, '> 1' }
end
def test_result_final_event
tmux.send_keys %[(seq 100; sleep 1; seq 100) | #{FZF} \\
--query 1 \\
--bind 'result:transform-header(echo "R=$FZF_MATCH_COUNT")' \\
--bind 'result-final:transform-footer(echo "F=$FZF_MATCH_COUNT")'], :Enter
tmux.until { |lines| assert lines.any_include?('R=20') }
tmux.until { |lines| refute lines.any_include?('F=20') }
tmux.until { |lines| assert lines.any_include?('R=40') }
tmux.until { |lines| assert lines.any_include?('F=40') }
end
def test_every_event
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(cat #{tempname})'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
@@ -2483,26 +2317,6 @@ class TestCore < TestInteractive
end
end
def test_env_current_item_size_limit
preview = %[(echo START; env | grep '^FZF_CURRENT_ITEM='; echo END) > #{tempname}]
# Large item (> 64 KB) is omitted so it cannot overflow ARG_MAX and break exec
tmux.send_keys %(head -c 70000 /dev/zero | tr '\\0' a | #{FZF} --preview-window 0 --preview "#{preview}"), :Enter
wait do
content = File.exist?(tempname) ? File.read(tempname) : ''
assert_includes content, 'END'
refute_includes content, 'FZF_CURRENT_ITEM='
end
tmux.send_keys :Enter
FileUtils.rm_f(tempname)
# Smaller item is exported as usual
tmux.send_keys %(head -c 1000 /dev/zero | tr '\\0' a | #{FZF} --preview-window 0 --preview "#{preview}"), :Enter
wait do
content = File.exist?(tempname) ? File.read(tempname) : ''
assert_includes content, 'END'
assert_includes content, 'FZF_CURRENT_ITEM=' + ('a' * 1000)
end
end
def test_abort_action_chain
tmux.send_keys %(seq 100 | #{FZF} --bind 'load:accept+up+up' > #{tempname}), :Enter
wait do
+21 -290
View File
@@ -248,7 +248,7 @@ class TestLayout < TestInteractive
tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter
expected = <<~OUTPUT
line2$ >
5/5
5/5
PREVIEW
@@ -268,7 +268,7 @@ class TestLayout < TestInteractive
5/5
5/5
>
OUTPUT
tmux.until { assert_block(expected, it) }
@@ -597,7 +597,7 @@ class TestLayout < TestInteractive
2
1
header
19/97
19/97
> 1
@@ -628,7 +628,7 @@ class TestLayout < TestInteractive
hello
100/100
100/100
>
BLOCK
tmux.until { assert_block(block2, it) }
@@ -648,7 +648,7 @@ class TestLayout < TestInteractive
2
1
98/98
98/98
>
BLOCK
tmux.until { assert_block(block1, it) }
@@ -663,7 +663,7 @@ class TestLayout < TestInteractive
1
hello
98/98
98/98
>
BLOCK
tmux.until { assert_block(block2, it) }
@@ -679,7 +679,7 @@ class TestLayout < TestInteractive
4
> 3
98/98
98/98
>
2
@@ -718,7 +718,7 @@ class TestLayout < TestInteractive
2
1
98/98
98/98
>
BLOCK
tmux.until { assert_block(block1, it) }
@@ -734,7 +734,7 @@ class TestLayout < TestInteractive
hello
98/98
98/98
>
BLOCK
tmux.until { assert_block(block2, it) }
@@ -750,7 +750,7 @@ class TestLayout < TestInteractive
4
> 3
98/98
98/98
>
2
@@ -767,7 +767,7 @@ class TestLayout < TestInteractive
2
1
98/98
98/98
>
hello
@@ -810,7 +810,7 @@ class TestLayout < TestInteractive
2
1
header
19/97
19/97
> 1
@@ -825,7 +825,7 @@ class TestLayout < TestInteractive
11
> 10
list
19/97
19/97
> 1
3
@@ -1039,9 +1039,9 @@ class TestLayout < TestInteractive
103 101 103 101 102 102 2/2 103
5/5 102 2/2 102 103 101 103 101 > 2/2 101 101 >
> 103 > 103 102 102 > 102 5/5 102
5/5 103 2/2 103 101 103 > 103
5/5 103 2/2 103 101 103 > 103
> > 102 101
2/2 103 101 102
2/2 103 101 102
> 102 > 103
103
@@ -1099,9 +1099,9 @@ class TestLayout < TestInteractive
> 102 > 102 > 101 > 101 101 101 101 > 1
3/3 101 1/1 101 102 102 102 102 102
> 102 > 102 HEAD 101 HEAD HEAD HEAD 101 1/1 101
3/3 102 1/1 1/1 1/1 102 > 102 3/3 >
3/3 102 1/1 1/1 1/1 102 > 102 3/3 >
> HEAD > > > HEAD HEAD >
1/1
1/1
> 101 101
102 102
HEAD HEAD
@@ -1159,9 +1159,9 @@ class TestLayout < TestInteractive
> 102 > 102 > 101 > 101 > 3 > 3 > 3 101 3
3/3 101 1/1 101 102 102 102
> 102 > 102 HEAD 101 HEAD 101 1/1 101
3/3 102 1/1 102 > 102 3/3 >
3/3 102 1/1 102 > 102 3/3 >
> HEAD > HEAD HEAD >
1/1
1/1
> 101 101
102 102
HEAD HEAD
@@ -1191,7 +1191,7 @@ class TestLayout < TestInteractive
label
header
10/10
10/10
>
BLOCK
@@ -1228,282 +1228,13 @@ class TestLayout < TestInteractive
end
end
def test_separator_with_input_border
# Border line below input does not face the list; separator is shown
tmux.send_keys %(seq 100 | #{FZF} --input-border bottom), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# List border faces the input instead; separator is hidden
tmux.send_keys %(seq 100 | #{FZF} --input-border bottom --list-border rounded), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Border line above input faces the list; separator is hidden
tmux.send_keys %(seq 100 | #{FZF} --input-border top), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_input_border_reverse
# Border line above input does not face the list; separator is shown
tmux.send_keys %(seq 100 | #{FZF} --layout reverse --input-border top), :Enter
block = <<~BLOCK
>
100/100
> 1
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Border line below input faces the list; separator is hidden
tmux.send_keys %(seq 100 | #{FZF} --layout reverse --input-border bottom), :Enter
block = <<~BLOCK
>
100/100
> 1
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_preview_next
# Preview window at 'next' position sits next to the input section, so the
# header border does not hide the separator
tmux.send_keys %(seq 100 | #{FZF} --header foo --header-border rounded --no-list-border --preview 'echo hi' --preview-window next,1,border-none), :Enter
block = <<~BLOCK
hi
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Same when 'next' position comes from a threshold alternative
tmux.send_keys %(seq 100 | #{FZF} --header foo --header-border rounded --no-list-border --preview 'echo hi' --preview-window 'up,40%,<9999(next,1,border-none)'), :Enter
tmux.until { assert_block(block, it) }
teardown
setup
# A border of the preview window at 'next' position facing the input
# section hides the separator
tmux.send_keys %(seq 100 | #{FZF} --preview : --preview-window next,3), :Enter
block = <<~BLOCK
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Same when every spec in the threshold chain is a bordered preview
# window at 'next' position
tmux.send_keys %(seq 100 | #{FZF} --preview : --preview-window 'next,3,<15(next,3)'), :Enter
tmux.until { assert_block(block, it) }
end
def test_separator_with_preview_next_toggle
tmux.send_keys %(seq 100 | #{FZF} --preview 'echo hi' --preview-window next,3,hidden --bind space:toggle-preview), :Enter
hidden = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(hidden, it) }
tmux.send_keys :Space
shown = <<~BLOCK
hi
100/100
>
BLOCK
tmux.until { assert_block(shown, it) }
tmux.send_keys :Space
tmux.until { assert_block(hidden, it) }
end
def test_separator_with_preview_next_alternative_only
# 'next' appears only in a threshold alternative and there is no
# previewer, so the input section stays embedded in the list window and
# needs the separator
tmux.send_keys %(seq 100 | #{FZF} --preview-window '<9999(next)' --list-border rounded), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_preview_action_at_next
# preview(...) action can display an ad-hoc preview window at 'next'
# position at any time; the list border must not hide the separator
tmux.send_keys %(seq 100 | #{FZF} --list-border rounded --preview-window 'next,border-left,5' --bind 'space:preview(echo hi)'), :Enter
block = <<~BLOCK
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
hi
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_change_preview_window_keeps_forced_preview
# Relayout triggered by the separator visibility change must not drop a
# preview window opened via the preview(...) action
tmux.send_keys %(seq 100 | #{FZF} --info hidden --input-border left --list-border --preview-window 'up,50%,<5(next)' --bind 'space:preview(echo AD-HOC)' --bind 'x:change-preview-window(up,50%,<5(up))'), :Enter
tmux.until { |lines| assert_includes lines.join, '│ ─' }
tmux.send_keys :Space
tmux.until do |lines|
assert_includes lines.join, 'AD-HOC'
assert_includes lines.join, '│ ─'
end
# Only the inactive threshold alternative changes; separator is hidden
# by the list border, and the forced preview window must survive
tmux.send_keys :x
tmux.until do |lines|
assert_includes lines.join, 'AD-HOC'
refute_includes lines.join, '│ ─'
end
end
def test_separator_with_change_preview_window_chain
# change-preview-window that only alters a non-active spec must still
# trigger a relayout when it changes the separator visibility
tmux.send_keys %(seq 100 | #{FZF} --info hidden --layout reverse --preview 'echo hi' --preview-window 'next,3,border-none,<100(next,3,border-top)' --bind 'space:change-preview-window(next,3,border-top,<100(next,3,border-top))'), :Enter
block = <<~BLOCK
>
hi
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
>
hi
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Also when only the drawn bar changes; the info line row count is
# constant with the default info style
tmux.send_keys %(seq 100 | #{FZF} --layout reverse --preview 'echo hi' --preview-window 'next,3,border-none,<100(next,3,border-top)' --bind 'space:change-preview-window(next,3,border-top,<100(next,3,border-top))'), :Enter
block = <<~BLOCK
>
100/100
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
>
100/100
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Also when compare finds a content-layout difference (scroll offset)
tmux.send_keys %(seq 100 | #{FZF} --info hidden --preview 'echo hi' --preview-window 'next,3,<2(up,3)' --bind 'space:change-preview-window(next,3,+1,<2(next,3))'), :Enter
block = <<~BLOCK
>
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_inline_header_border
# Inline header is drawn inside the list border; bare header lines sit
# next to the input section, so the separator is shown
tmux.send_keys %(seq 100 | #{FZF} --list-border rounded --header foo --header-lines 2 --header-border inline --header-lines-border none), :Enter
block = <<~BLOCK
2
1
98/98
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Header lines are embedded in the list border along with the inline
# header; the list border faces the input section and hides the separator
tmux.send_keys %(seq 100 | #{FZF} --list-border rounded --header foo --header-lines 2 --header-border inline), :Enter
block = <<~BLOCK
2
1
foo
98/98
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_header_border_no_pointer_and_marker
tmux.send_keys %(seq 10 | #{FZF} --header-lines 1 --header-border sharp --no-list-border --pointer '' --marker ''), :Enter
block = <<~BLOCK
1
9/9
9/9
>
BLOCK
tmux.until { assert_block(block, it) }
-67
View File
@@ -100,47 +100,6 @@ module TestShell
tmux.until { |lines| assert_equal '/tmp', lines[-1] }
end
def test_alt_c_symlink
base = '/tmp/fzf-test-alt-c-symlink'
FileUtils.rm_rf(base)
FileUtils.mkdir_p("#{base}/real/subdir")
FileUtils.ln_s("#{base}/real", "#{base}/link")
tmux.prepare
tmux.send_keys "cd #{base}/link", :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'subdir'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.prepare
tmux.send_keys :pwd, :Enter
tmux.until { |lines| assert_equal "#{base}/link/subdir", lines[-1] }
ensure
FileUtils.rm_rf(base)
end
def test_alt_c_absolute_cmd
base = '/tmp/fzf-test-alt-c-absolute'
FileUtils.rm_rf(base)
FileUtils.mkdir_p(base)
set_var('FZF_ALT_C_COMMAND', "echo #{base}")
tmux.prepare
tmux.send_keys 'cd /tmp', :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.prepare
tmux.send_keys :pwd, :Enter
tmux.until { |lines| assert_equal base, lines[-1] }
ensure
FileUtils.rm_rf(base)
end
def test_ctrl_r
tmux.prepare
tmux.send_keys 'echo 1st', :Enter
@@ -965,18 +924,6 @@ class TestZsh < TestBase
end
end
# Duplicate 'chpwd' calls overcount visits => skews rank tracking tools (e.g. 'zoxide')
def test_alt_c_chpwd_hook_once
tmux.send_keys "chpwd() { echo 'chpwd hook fired' >&2 }", :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal(1, lines.count { |l| l.include?('chpwd hook fired') })
end
end
# Helper function to run test with Perl and again with Awk
def self.test_perl_and_awk(name, &block)
define_method(:"test_#{name}") do
@@ -1011,20 +958,6 @@ class TestZsh < TestBase
tmux.send_keys 'C-l', 'C-r'
end
test_perl_and_awk 'ctrl_r_exit_code' do
exit_file = "#{tempname}-exit"
# Wrapper captures status, as widgets return values don't propagate to "$?"
# A non-zero status causes the shell to beep when the widget exits (man zshzle)
tmux.send_keys %(f() { zle fzf-history-widget; echo $? > #{exit_file}; zle reset-prompt } && zle -N f && bindkey "^R" f), :Enter
prepare_ctrl_r_test
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'C-g' # abort
tmux.send_keys "cat #{exit_file}", :Enter
tmux.until { |lines| assert_equal '130', lines[-1] }
ensure
FileUtils.rm_f(exit_file)
end
test_perl_and_awk 'ctrl_r_accept_or_print_query' do
set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query')
prepare_ctrl_r_test
-126
View File
@@ -1,126 +0,0 @@
# frozen_string_literal: true
require 'shellwords'
require 'tmpdir'
require_relative 'lib/common'
# Tests for running fzf in a tmux floating pane (--popup on tmux 3.7 or above)
class TestTmux < TestInteractive
def setup
super
# Cannot rely on the exit status; tmux versions before 3.7 exit
# normally with empty output for an unknown command name
supported = IO.popen(%w[tmux list-commands new-pane], err: File::NULL, &:read).include?('new-pane')
skip('floating panes not supported') unless supported
end
def test_floating_pane
tmux.send_keys "seq 100 | #{fzf('--popup center,80% --margin 0')}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
# Border text is empty when no label is given
pane = floating_pane
refute_nil pane
assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format')
assert_equal '', pane_option(pane, '@fzf-border-label')
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
assert_equal '99', fzf_output
end
def test_floating_pane_killed
tmux.send_keys "seq 100 | #{FZF} --popup bottom,50% --margin 0; echo code:$?", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
assert system('tmux', 'kill-pane', '-t', pane)
tmux.until { |lines| assert lines.any_include?('code:130') }
end
def test_floating_pane_border_label
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --border-label ' #fzf-label 100% '))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
# The label is held in a user option, not in the pane title, which is
# left to the user. '#' is doubled so that the label is drawn as it is
# written, instead of '#[...]' being taken as a style directive
assert_equal ' ##fzf-label 100% ', pane_option(pane, '@fzf-border-label')
assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format')
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
refute_equal ' #fzf-label 100% ', title.chomp
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
def test_floating_pane_change_border_label
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --bind 'space:change-border-label( #[fg=red] 100% )'))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
assert_equal '', pane_option(pane, '@fzf-border-label')
tmux.send_keys :Space
wait { assert_equal ' ##[fg=red] 100% ', pane_option(pane, '@fzf-border-label') }
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
# A program running in the pane owns the pane title, but not the label
def test_floating_pane_border_label_not_affected_by_title
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --border-label ' label ' --bind 'space:execute-silent(printf "\\033]2;hijacked\\033\\\\" > /dev/tty)'))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
tmux.send_keys :Space
wait do
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
assert_equal 'hijacked', title.chomp
end
assert_equal ' label ', pane_option(pane, '@fzf-border-label')
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
def test_floating_pane_become
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --bind 'enter:become(echo became-{})'))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
tmux.send_keys :Enter
assert_equal 'became-1', fzf_output
end
def test_explicit_border_falls_back_to_popup
# display-popup requires an attached client, which the test environment
# may not have; intercept it with a tmux shim on PATH
dir = Dir.mktmpdir
real = `command -v tmux`.chomp
shim = File.join(dir, 'tmux')
File.write(shim, <<~SH)
#!/bin/sh
if [ "$1" = display-popup ]; then
echo popup-used >&2
exit 0
fi
exec #{real.shellescape} "$@"
SH
FileUtils.chmod(0o755, shim)
tmux.send_keys "seq 100 | PATH=#{dir.shellescape}:$PATH #{FZF} --popup center --border rounded", :Enter
tmux.until { |lines| assert lines.any_include?('popup-used') }
refute floating_pane
ensure
FileUtils.remove_entry(dir) if dir
end
private
# stderr is merged in so that an unset option, which prints 'invalid
# option' to stderr and nothing to stdout, is not read as an empty value
def pane_option(pane, name)
IO.popen(['tmux', 'show-options', '-p', '-t', pane, '-v', name], err: %i[child out], &:read).chomp
end
def floating_pane
format = "\#{pane_id} \#{pane_floating_flag}"
lines = IO.popen(['tmux', 'list-panes', '-t', tmux.win, '-F', format]) { |io| io.readlines(chomp: true) }
lines.filter_map { |line| line.split.first if line.end_with?(' 1') }.first
end
end