mirror of
https://github.com/junegunn/fzf
synced 2026-08-10 20:01:42 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 336cfdef02 | |||
| eee9672eb0 |
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
$version="0.74.2"
|
||||
$version="0.73.1"
|
||||
|
||||
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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}") \
|
||||
|
||||
@@ -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
@@ -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--
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
"漢", "漢字", "한", "한글", "мир", "м", "ελλ", "🎉", "é", "ß", "Å", "İ", "f",
|
||||
"漢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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = ¤t
|
||||
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()}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user