Compare commits

...

30 Commits

Author SHA1 Message Date
dependabot[bot] 066308b33b Bump github/codeql-action from 4 to 4.37.4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 13:43:49 +00:00
Junegunn Choi 3337be9d45 0.74.2 2026-08-01 09:07:37 +09:00
LangLangBart e36576429d fix(zsh): avoid double call for 'chpwd' in alt-c
fix suggested by @lucc; closes #4879
2026-07-31 01:32:32 +09:00
LangLangBart ca51a57aed test(zsh): verify chpwd fires once in alt-c widget 2026-07-31 01:32:32 +09:00
Junegunn Choi b66153825f Update CHANGELOG 2026-07-30 00:53:50 +09:00
Junegunn Choi 8cf40cb372 Do not split preview header through an image
--preview-window ~N draws the header in its own pass, resuming the body
in the middle of an image: an image arrives as one line but takes up many
rows. ~N is typically global and meant for text, while the same preview
command also renders images.
2026-07-29 23:01:11 +09:00
Junegunn Choi 57631684d8 Escape trailing semicolon in tmux command argument
tmux ends a command at an argument whose last character is ';', so a
--border-label ending in one was stored truncated. escapeTmuxSeparator
only covered a value that was exactly ';'.

  fzf --popup --border-label 'foo;'   ->  @fzf-border-label was 'foo'
2026-07-29 20:59:53 +09:00
Junegunn Choi 50e6c026ec Separate floating pane border label from pane title
--border-label of a tmux floating pane was stored in the pane title and
read back by pane-border-format as '#{pane_title}', so any program in the
pane could replace the border text by setting the title. On Zellij the
name was only set when the pane was created. Either way the built-in
label actions could not touch it, as fzf draws no border of its own
there.

- tmux: hold the label in pane-scoped @fzf-border-label. Set the option
  even without a label, so a later change-border-label has somewhere to
  write. Drop select-pane -T; the pane title is left to the user
- Zellij: run 'zellij action rename-pane' on label change. 'zellij run'
  has no option to set the environment of the new pane, so the command
  exports the target itself
- change-border-label and transform-border-label now update the native
  border, through the setter nativeLabelSetter picks by multiplexer
- The pane is named in a __FZF_INTERNAL_ variable that runProxy withholds
  from the environment replay, so the same name in the outer environment
  cannot redirect the update to another pane
2026-07-29 20:59:52 +09:00
Junegunn Choi b5f2ba4fd4 Wrap bare Kitty graphics sequences in tmux passthrough
A Kitty graphics command is an APC sequence, which tmux takes as a
request to set the pane title. A bare one therefore never reaches the
terminal and overwrites the title on the way, which is visible on the
border of a floating pane. Programs are expected to wrap it in the tmux
passthrough sequence themselves, but 'kitten icat --clear' does not,
unlike the image data it emits right after.

Sixel and iTerm2 sequences are left alone; tmux renders the former
itself and neither affects the title.

Related: #4870
2026-07-29 20:31:46 +09:00
dependabot[bot] d39595ae66 Bump ruby/setup-ruby from 1.314.0 to 1.321.0 (#4874)
Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.314.0 to 1.321.0.
- [Release notes](https://github.com/ruby/setup-ruby/releases)
- [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb)
- [Commits](https://github.com/ruby/setup-ruby/compare/9eb537ca036ebaed86729dcb9309076e4c5c3b74...95ef2b042f9d7a56d8268cba8559e2842e2ad01b)

---
updated-dependencies:
- dependency-name: ruby/setup-ruby
  dependency-version: 1.321.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 23:21:20 +09:00
dependabot[bot] 548dfcb068 Bump json from 2.9.1 to 2.19.9 (#4873)
Bumps [json](https://github.com/ruby/json) from 2.9.1 to 2.19.9.
- [Release notes](https://github.com/ruby/json/releases)
- [Changelog](https://github.com/ruby/json/blob/master/CHANGES.md)
- [Commits](https://github.com/ruby/json/compare/v2.9.1...v2.19.9)

---
updated-dependencies:
- dependency-name: json
  dependency-version: 2.19.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 23:20:57 +09:00
dependabot[bot] 4215d89e11 Bump actions/setup-go from 6 to 7 (#4867)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 23:20:25 +09:00
dependabot[bot] 62f96109a3 Bump actions/labeler from 6 to 7 (#4876)
Bumps [actions/labeler](https://github.com/actions/labeler) from 6 to 7.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 23:19:27 +09:00
dependabot[bot] ad41cb21ed Bump github.com/mattn/go-isatty from 0.0.22 to 0.0.24 (#4875)
Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.22 to 0.0.24.
- [Commits](https://github.com/mattn/go-isatty/compare/v0.0.22...v0.0.24)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-isatty
  dependency-version: 0.0.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 23:18:58 +09:00
Junegunn Choi 13a83e3475 Strengthen fast-path equivalence tests
- Vary normalize in the fuzz targets; document why the exhaustive sweep
  keeps it fixed (no-op for ASCII input)
- Seed the fuzz targets with patterns of the right length; single-char
  seeds were all two-char and got rejected
- Note that the disable* hook forbids parallel test execution
2026-07-27 21:33:01 +09:00
Junegunn Choi f779e6a4df Add fast path for two-character patterns in FuzzyMatchV2
For two ASCII characters, rows 0 and 1 of the score matrix collapse to
scalar running state, so Phase 2 and Phase 3 fuse into one pass with no
score arrays. withPos stores the two rows for the backtrace. Up to 1.4x
on two-char queries, the most common multi-char length.

Verify both fast paths against the general algorithm with exhaustive
(every short string over a class-complete alphabet) and fuzz tests,
runnable via the new make fuzz target.
2026-07-27 21:19:37 +09:00
LangLangBart 232722145e fix(zsh): fzf-history-widget exit status for perl
fix suggested by @Toliak; closes #4871
2026-07-27 21:16:59 +09:00
LangLangBart 7129f9ef5e test(zsh): check capture for fzf exit status in widget 2026-07-27 21:16:59 +09:00
Junegunn Choi 0efef298d2 Skip all-zero passes in radix sort of results
Precompute OR of all sort keys; a byte position that is zero across every
key contributes a no-op pass. Default two-criteria setup leaves the low 32
bits zero, so 4 of 8 passes are skipped without a histogram scan. 2x on the
sort, 3-8% on high-match queries. No regression when all bytes are used.
2026-07-24 21:38:18 +09:00
Junegunn Choi 2209a94e13 Update CHANGELOG 2026-07-23 21:30:47 +09:00
Junegunn Choi 9c401f9c9a Add fast path for single-character patterns in FuzzyMatchV2
For single ASCII character, jump between occurrences with vectorized
search and score each directly from bonus matrix instead of running
full pipeline with prefilter and score arrays. 2x faster on 100K-line
benchmark corpus (5.4ms -> 2.5ms). Matters most on first keystroke
where candidate set is largest and result cache cannot help.
2026-07-23 21:27:29 +09:00
Junegunn Choi 224310d7a6 Fix nondeterministic highlight positions in FuzzyMatchV2 backtrace
Phase 3 writes C rows only from column F[r], but tie-breaking in the
backtrace could read row i+1 at column j+1 left of F[i+1]. With reused
slab, that cell holds data from previously processed item, so highlight
positions in equal-score ties depended on processing order.
2026-07-23 20:47:31 +09:00
Junegunn Choi 235a726fae Unregister interrupt signal handler when Loop exits
Registration persisted across Run() calls, so host program using fzf as
library would keep trapping SIGINT/SIGTERM/SIGHUP after fzf finished
2026-07-20 13:12:12 +09:00
Junegunn Choi dc384afb70 Terminate resize notification when Loop exits
- Pass Loop context to notifyOnResize
- Windows: stop polling goroutine, close CONOUT$ handle on ctx.Done
- Unix: unregister SIGWINCH handler on ctx.Done
2026-07-20 13:09:06 +09:00
Cyrus 6e65d867dc Detect terminal resize on Windows (fix #4790)
Windows has no SIGWINCH and notifyOnResize was an empty TODO, so fzf
running in --height mode never noticed console size changes. Poll the
console screen buffer dimensions every 100ms and push a signal through
the same channel SIGWINCH uses on Unix. Full-screen mode is unaffected
as it goes through tcell which emits its own resize events.
2026-07-19 23:53:02 +08:00
bitraid b163463079 fish: Don't use exported variable for history command (#4863)
The history command is no longer loaded by an exported variable
because its value might be overwritten by user initialization
scripts of global/non-interactive shell session types.

The command is also now improved in the following ways:
- The timestamp/date prefix has always the $fish_color_comment
color, even on fish versions where the syntax highlighting colors
are not supported.
- The $fish_color_comment value is taken from the current shell
session (there are cases where the variable might be undefined in
a non-interactive subshell).

Fix #4862
2026-07-19 10:38:31 +03:00
Junegunn Choi eae8d9d27e 0.74.1 2026-07-18 21:50:12 +09:00
Junegunn Choi e58c5e6bd8 Hide separator next to preview window at 'next' position when decidable
Decision must not depend on threshold chain resolution
(computePreviewSize consults noSeparatorLine); separator is hidden only
when every spec in the chain displays a 'next' preview window with a
border line facing the input section. Otherwise err on the side of
showing it.

- Without active previewer, preview position is resolved from first spec
  alone, so 'next' in a threshold alternative does not create input
  window and cannot hide the separator
- Empty preview command is undecidable; preview(...) action can display
  ad-hoc window at 'next' position at any time
- change-preview-window relayouts or repaints when separator visibility
  changes even if the active spec is unchanged, without dropping preview
  window forced by preview(...) action
2026-07-18 16:43:23 +09:00
Junegunn Choi 7fe4831be6 Apply facing-line rule to input border as well
Visible input border suppressed separator even when it draws no line
toward list section (bottom in default layout, top in reverse, vertical).
Suppress only when input border has a line on the list side; otherwise
fall through to adjacent section check.

- Requires header window to have content before treating it as facing
  section; hasHeaderWindow returns true with empty header when input
  border is visible
- Extract inputBorderFacesList and resolveInputBorderShape shared by
  options and terminal
- Add tests for separator suppression rules
2026-07-18 16:43:23 +09:00
Junegunn Choi 6030226835 Hide default separator when a border separates input from list
- Applies when adjacent section (header, header lines, or list) draws
  border line facing input section

    # No separator below header border
    fzf --style full --input-border none --header foo

    # Separator shown; nothing separates input from list
    fzf --style full --input-border none --header foo --no-header-border

- Evaluated dynamically so change-header/toggle-header update it
- Inline header is drawn inside list border and not adjacent to input;
  decide from section actually next to it
- Separator is kept when a preview window can be at 'next' position,
  right next to the input section
2026-07-18 16:43:23 +09:00
34 changed files with 1559 additions and 150 deletions
+3 -3
View File
@@ -33,12 +33,12 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@v4.37.4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v4 uses: github/codeql-action/autobuild@v4.37.4
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4 uses: github/codeql-action/analyze@v4.37.4
+1 -1
View File
@@ -12,6 +12,6 @@ jobs:
label: label:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@v6 - uses: actions/labeler@v7
with: with:
configuration-path: .github/labeler.yml configuration-path: .github/labeler.yml
+2 -2
View File
@@ -23,12 +23,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: "1.23" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1 uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with: with:
ruby-version: 3.4.6 ruby-version: 3.4.6
+2 -2
View File
@@ -20,12 +20,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: "1.23" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1 uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with: with:
ruby-version: 3.0.0 ruby-version: 3.0.0
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-go@v6 - uses: actions/setup-go@v7
with: with:
go-version: stable go-version: stable
+32
View File
@@ -1,8 +1,40 @@
CHANGELOG CHANGELOG
========= =========
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 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 - Rendering improvements
- Each frame is now wrapped in synchronized update mode (mode 2026) to reduce flickering on supported terminals - 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 - Reduced rendering output by 10-23% by skipping redundant SGR sequences
+1 -1
View File
@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
ast (2.4.2) ast (2.4.2)
json (2.9.1) json (2.19.9)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
minitest (5.25.4) minitest (5.25.4)
parallel (1.26.3) parallel (1.26.3)
+10
View File
@@ -97,6 +97,16 @@ test: $(SOURCES)
itest: itest:
ruby test/runner.rb 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; do \
echo "== $$t =="; \
$(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \
done
bench: bench:
cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem
+1 -1
View File
@@ -18,7 +18,7 @@ triggered by a tag push.
2. Verify file consistency, sign the tag, and push the tag. 2. Verify file consistency, sign the tag, and push the tag.
```sh ```sh
make tag VERSION=0.74.0 make tag VERSION=0.74.2
``` ```
`make tag` runs `prerelease` first (checks that the version `make tag` runs `prerelease` first (checks that the version
+1 -1
View File
@@ -4,7 +4,7 @@ require (
github.com/charlievieth/fastwalk v1.0.14 github.com/charlievieth/fastwalk v1.0.14
github.com/gdamore/tcell/v2 v2.9.0 github.com/gdamore/tcell/v2 v2.9.0
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
github.com/mattn/go-isatty v0.0.22 github.com/mattn/go-isatty v0.0.24
github.com/rivo/uniseg v0.4.7 github.com/rivo/uniseg v0.4.7
golang.org/x/sys v0.35.0 golang.org/x/sys v0.35.0
golang.org/x/term v0.34.0 golang.org/x/term v0.34.0
+2 -2
View File
@@ -8,8 +8,8 @@ github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMD
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= github.com/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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 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/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+1 -1
View File
@@ -2,7 +2,7 @@
set -u set -u
version=0.74.0 version=0.74.2
auto_completion= auto_completion=
key_bindings= key_bindings=
update_config=2 update_config=2
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.74.0" $version="0.74.2"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition $fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+1 -1
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf\-tmux 1 "Jul 2026" "fzf 0.74.0" "fzf\-tmux - open fzf in tmux split pane" .TH fzf\-tmux 1 "Aug 2026" "fzf 0.74.2" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME .SH NAME
fzf\-tmux - open fzf in tmux split pane fzf\-tmux - open fzf in tmux split pane
+12 -4
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
.. ..
.TH fzf 1 "Jul 2026" "fzf 0.74.0" "fzf - a command-line fuzzy finder" .TH fzf 1 "Aug 2026" "fzf 0.74.2" "fzf - a command-line fuzzy finder"
.SH NAME .SH NAME
fzf - a command-line fuzzy finder fzf - a command-line fuzzy finder
@@ -426,9 +426,13 @@ 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 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 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 moving and resizing it, so it is used by default and \fBborder\-native\fR is
implied. On tmux, \fB\-\-border\-label\fR is set as the title of the pane, implied. \fB\-\-border\-label\fR is displayed on the native border, and
and is displayed on the border if \fBpane\-border\-status\fR is enabled in \fBchange\-border\-label\fR and \fBtransform\-border\-label\fR update it
tmux (\fB\-\-border\-label\-pos\fR is ignored). (\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 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 with \fB\-\-border\fR, so that it is the only border shown. \fBnone\fR and
@@ -823,6 +827,10 @@ A synonym for \fB\-\-info=hidden\fB
The given string will be repeated to form the horizontal separator on the info The given string will be repeated to form the horizontal separator on the info
line (default: '─' or '\-' depending on \fB\-\-no\-unicode\fR). 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. ANSI color codes are supported.
.TP .TP
+9 -12
View File
@@ -127,14 +127,21 @@ function fzf_key_bindings
set -l -- total_lines (count $command_line) set -l -- total_lines (count $command_line)
set -l -- fzf_query (string escape -- $command_line[$current_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 '' \ set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \ '--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \
'--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \ '--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-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="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(eval $FZF_DEFAULT_COMMAND)"' \ "--bind='shift-delete:execute-silent(eval builtin history delete -Ce -- (string escape -n -- (string split0 -- <{+sf3..})))+reload($FZF_DEFAULT_COMMAND)'" \
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \ "--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --read0 --print0 --with-shell='(status fish-path)\\ -c) '--accept-nth=3.. --delimiter="\t" --tabstop=4 --ansi --read0 --print0 --with-shell='(status fish-path)\\ -c)
# Add dynamic preview options if preview command isn't already set by user # Add dynamic preview options if preview command isn't already set by user
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS" if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
@@ -147,16 +154,6 @@ function fzf_key_bindings
set -lx FZF_DEFAULT_OPTS_FILE 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 2>/dev/null; or set_color normal)"%F %a %T%t%s%t"(set_color normal)'
else
set -a -- FZF_DEFAULT_COMMAND '--show-time="%F %a %T%t%s%t"'
end
# Merge history from other sessions before searching # Merge history from other sessions before searching
test -z "$fish_private_mode"; and builtin history merge test -z "$fish_private_mode"; and builtin history merge
+2 -2
View File
@@ -115,7 +115,7 @@ fzf-cd-widget() {
# working directory. # working directory.
# If failed, fallback to the unexpanded path to surface the error to the user. # 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`. # NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
dir=$(builtin cd >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}") dir=$(builtin cd -q >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
zle push-line # Clear buffer. Auto-restored on next prompt. zle push-line # Clear buffer. Auto-restored on next prompt.
BUFFER="builtin cd -- ${(q)dir}" BUFFER="builtin cd -- ${(q)dir}"
zle accept-line zle accept-line
@@ -139,11 +139,11 @@ fzf-history-widget() {
# as the associative 'history' array, which maps event numbers to full history # 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. # 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 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[@]}" | 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; }' | 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=$(__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))" FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
extracted_with_perl=1
else else
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' | 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}") \ 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}") \
+229 -1
View File
@@ -425,6 +425,217 @@ 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
)
// 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) { 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. // Assume that pattern is given in lowercase if case-insensitive.
// First check if there's a match and calculate bonus for each position. // First check if there's a match and calculate bonus for each position.
@@ -447,6 +658,12 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab) 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 // Phase 1. Optimized search for ASCII string
minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive) minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive)
if minIdx < 0 { if minIdx < 0 {
@@ -455,6 +672,13 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
// fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString()) // fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString())
N = maxIdx - minIdx 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 // Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages
offset16 := 0 offset16 := 0
offset32 := 0 offset32 := 0
@@ -630,6 +854,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
s2 = H[I+j0-1] s2 = H[I+j0-1]
} }
row := i
if s > s1 && (s > s2 || s == s2 && preferMatch) { if s > s1 && (s > s2 || s == s2 && preferMatch) {
*pos = append(*pos, j+minIdx) *pos = append(*pos, j+minIdx)
if i == 0 { if i == 0 {
@@ -637,7 +862,10 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
} }
i-- i--
} }
preferMatch = C[I+j0] > 1 || I+width+j0+1 < len(C) && C[I+width+j0+1] > 0 // 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
j-- j--
} }
} }
+95
View File
@@ -218,3 +218,98 @@ func TestLongStringWithNormalize(t *testing.T) {
unicodeString := string(bytes) + " Minímal example" unicodeString := string(bytes) + " Minímal example"
assertMatch2(t, FuzzyMatchV1, false, true, false, unicodeString, "minim", 30001, 30006, 140) assertMatch2(t, FuzzyMatchV1, false, true, false, unicodeString, "minim", 30001, 30006, 140)
} }
func TestResultPositionsWithReusedSlab(t *testing.T) {
// Backtrace positions in equal-score ties must not depend on data
// a previous match left in the slab
pattern := []rune("co/")
target := util.ToChars([]byte("core_color/view/server.txt"))
_, freshPos := FuzzyMatchV2(false, false, true, &target, pattern, true, util.MakeSlab(100*1024, 2048))
slab := util.MakeSlab(100*1024, 2048)
dirty := util.ToChars([]byte("completion/keybinding/client/handler/writer_index.txt"))
FuzzyMatchV2(false, false, true, &dirty, pattern, true, slab)
_, reusedPos := FuzzyMatchV2(false, false, true, &target, pattern, true, slab)
if len(*freshPos) != len(*reusedPos) {
t.Fatalf("position count mismatch: %v vs %v", *freshPos, *reusedPos)
}
for i := range *freshPos {
if (*freshPos)[i] != (*reusedPos)[i] {
t.Errorf("positions differ with reused slab: %v vs %v", *freshPos, *reusedPos)
break
}
}
}
// TestFuzzyMatchV2TwoEquivalence verifies that the two-character fast path
// produces the same Result and positions as the general algorithm across
// case sensitivity, direction, and withPos, using a reused slab to surface
// any stale-data reads in the backtrace.
func TestFuzzyMatchV2TwoEquivalence(t *testing.T) {
words := []string{"src", "main", "core", "config", "parser", "render", "server",
"client", "index", "handler", "util", "list", "cache", "reader"}
exts := []string{".go", ".rb", ".py", ".md", ".c", ".txt"}
// Deterministic corpus (LCG), plus adversarial short/repeated items
corpus := []util.Chars{}
seed := uint32(12345)
next := func(n int) int { seed = seed*1664525 + 1013904223; return int(seed>>8) % n }
for i := 0; i < 4000; i++ {
depth := 2 + next(4)
s := ""
for d := 0; d < depth; d++ {
if d > 0 {
s += "/"
}
s += words[next(len(words))]
if next(5) == 0 {
s += "_" + words[next(len(words))]
}
}
s += exts[next(len(exts))]
corpus = append(corpus, util.ToChars([]byte(s)))
}
for _, s := range []string{"", "a", "ab", "aa", "aXb", "a/b", "//", "..", "abcabc",
"AaBb", "x.y.z", "a_b_c", "CoreCore", " co"} {
corpus = append(corpus, util.ToChars([]byte(s)))
}
pats := []string{"co", "ab", "aa", "//", "..", "sr", "a/", "_c", "oo", "Ab",
"z.", "b.", "1a", "ll", "re", "er", "Co"}
slab := util.MakeSlab(100*1024, 2048)
for _, cs := range []bool{false, true} {
for _, fwd := range []bool{true, false} {
for _, wp := range []bool{false, true} {
for _, p := range pats {
pattern := []rune(p)
for j := range corpus {
disableTwo = true
rg, pg := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
disableTwo = false
rt, pt := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
if rg != rt {
t.Fatalf("Result cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
cs, fwd, wp, p, corpus[j].ToString(), rg, rt)
}
if (pg == nil) != (pt == nil) || (pg != nil && !equalInts(*pg, *pt)) {
t.Fatalf("Pos cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
cs, fwd, wp, p, corpus[j].ToString(), pg, pt)
}
}
}
}
}
}
}
func equalInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+157
View File
@@ -0,0 +1,157 @@
package algo
// Equivalence tests for the single- and two-character fast paths against the
// general FuzzyMatchV2 algorithm, which serves as the oracle.
//
// 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) }
+4 -1
View File
@@ -3696,7 +3696,10 @@ func (opts *Options) noSeparatorLine() bool {
if opts.Inputless { if opts.Inputless {
return true return true
} }
sep := opts.Separator == nil && !opts.InputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0 // 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
return noSeparatorLine(opts.InfoStyle, sep) return noSeparatorLine(opts.InfoStyle, sep)
} }
+69
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
"github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/tui"
@@ -19,10 +20,75 @@ import (
const becomeSuffix = ".become" 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 { func escapeSingleQuote(str string) string {
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'" 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) { func popupArgStr(args []string, opts *Options) (string, string) {
fzf, rest := args[0], args[1:] fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"} args = []string{"--bind=ctrl-z:ignore"}
@@ -125,6 +191,9 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
for _, pairStr := range os.Environ() { for _, pairStr := range os.Environ() {
pair := strings.SplitN(pairStr, "=", 2) pair := strings.SplitN(pairStr, "=", 2)
if strings.HasPrefix(pair[0], internalEnvPrefix) {
continue
}
if validIdentifier.MatchString(pair[0]) { if validIdentifier.MatchString(pair[0]) {
exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1]))) 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], "%%") { } else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") {
+65
View File
@@ -0,0 +1,65 @@
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)
}
+12
View File
@@ -369,9 +369,21 @@ func radixSortResults(a []Result, tac bool, scratch []Result) []Result {
src, dst := a, buf src, dst := a, buf
scattered := 0 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 { for pass := range 8 {
shift := uint(pass) * 8 shift := uint(pass) * 8
if byte(keyOR>>shift) == 0 {
continue
}
var count [256]int var count [256]int
for i := range src { for i := range src {
count[byte(sortKey(&src[i])>>shift)]++ count[byte(sortKey(&src[i])>>shift)]++
+252 -30
View File
@@ -57,8 +57,11 @@ var offsetComponentRegex *regexp.Regexp
var offsetTrimCharsRegex *regexp.Regexp var offsetTrimCharsRegex *regexp.Regexp
var passThroughBeginRegex *regexp.Regexp var passThroughBeginRegex *regexp.Regexp
var passThroughEndTmuxRegex *regexp.Regexp var passThroughEndTmuxRegex *regexp.Regexp
var sixelBeginRegex *regexp.Regexp
var ttyin *os.File var ttyin *os.File
var inTmux = len(os.Getenv("TMUX")) > 0
const clearCode string = "\x1b[2J" const clearCode string = "\x1b[2J"
// Number of maximum focus events to process synchronously // Number of maximum focus events to process synchronously
@@ -92,6 +95,7 @@ func init() {
*/ */
passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`) passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`)
passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`) passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`)
sixelBeginRegex = regexp.MustCompile(`^\x1bP[0-9;]*q`)
} }
type jumpMode int type jumpMode int
@@ -271,6 +275,7 @@ type Terminal struct {
ghost string ghost string
separator labelPrinter separator labelPrinter
separatorLen int separatorLen int
separatorAuto bool
spinner []string spinner []string
promptString string promptString string
prompt func() prompt func()
@@ -467,6 +472,7 @@ type Terminal struct {
clickFooterLine int clickFooterLine int
clickFooterColumn int clickFooterColumn int
proxyScript string proxyScript string
setNativeLabel func(string)
numLinesCache map[int32]numLinesCacheValue numLinesCache map[int32]numLinesCacheValue
raw bool raw bool
lastActivity time.Time lastActivity time.Time
@@ -1139,6 +1145,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
printer: opts.Printer, printer: opts.Printer,
printsep: opts.PrintSep, printsep: opts.PrintSep,
proxyScript: opts.ProxyScript, proxyScript: opts.ProxyScript,
setNativeLabel: nativeLabelSetter(),
merger: em, merger: em,
passMerger: em, passMerger: em,
resultMerger: em, resultMerger: em,
@@ -1248,13 +1255,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
} }
// Determine input border shape // Determine input border shape
if t.inputBorderShape == tui.BorderLine { t.inputBorderShape = resolveInputBorderShape(t.layout, t.inputBorderShape)
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. // 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 // Shapes missing either one (none/phantom/line/single-sided) fall back to a plain
@@ -1299,14 +1300,16 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
} }
} }
// Disable separator by default if input border is set // The default separator can be suppressed at runtime depending on the
if opts.Separator == nil && !t.inputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0 { // window layout (see separatorLength)
bar := "─" t.separatorAuto = opts.Separator == nil
if opts.Separator != nil { bar := "─"
bar = *opts.Separator if opts.Separator != nil {
} else if !t.unicode { bar = *opts.Separator
bar = "-" } else if !t.unicode {
} bar = "-"
}
if len(bar) > 0 {
t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true) t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true)
} }
@@ -1695,7 +1698,129 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) {
} }
func (t *Terminal) noSeparatorLine() bool { func (t *Terminal) noSeparatorLine() bool {
return t.inputless || noSeparatorLine(t.infoStyle, t.separatorLen > 0) 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()
} }
func getScrollbar(perLine int, total int, height int, offset int) (int, int) { func getScrollbar(perLine int, total int, height int, offset int) (int, int) {
@@ -3330,6 +3455,7 @@ func (t *Terminal) printInfoImpl() {
} }
pos := 0 pos := 0
line := 0 line := 0
separatorLen := t.separatorLength()
maxHeight := t.window.Height() maxHeight := t.window.Height()
move := func(y int, x int, clear bool) bool { move := func(y int, x int, clear bool) bool {
if y < 0 || y >= maxHeight { if y < 0 || y >= maxHeight {
@@ -3357,7 +3483,7 @@ func (t *Terminal) printInfoImpl() {
str = string(trimmed) str = string(trimmed)
width = maxWidth width = maxWidth
} }
move(line, pos, t.separatorLen == 0) move(line, pos, separatorLen == 0)
if t.reading { if t.reading {
t.window.CPrint(tui.ColSpinner, str) t.window.CPrint(tui.ColSpinner, str)
} else { } else {
@@ -3366,7 +3492,7 @@ func (t *Terminal) printInfoImpl() {
pos += width pos += width
} }
printSeparator := func(fillLength int, pad bool) { printSeparator := func(fillLength int, pad bool) {
if t.separatorLen > 0 { if separatorLen > 0 {
t.separator(t.window, fillLength) t.separator(t.window, fillLength)
t.window.Print(" ") t.window.Print(" ")
} else if pad { } else if pad {
@@ -3375,7 +3501,7 @@ func (t *Terminal) printInfoImpl() {
} }
if t.infoStyle == infoHidden { if t.infoStyle == infoHidden {
if t.separatorLen > 0 { if separatorLen > 0 {
if !move(line+1, 0, false) { if !move(line+1, 0, false) {
return return
} }
@@ -3438,7 +3564,7 @@ func (t *Terminal) printInfoImpl() {
} }
switch t.infoStyle { switch t.infoStyle {
case infoDefault: case infoDefault:
if !move(line+1, 0, t.separatorLen == 0) { if !move(line+1, 0, separatorLen == 0) {
return return
} }
printSpinner() printSpinner()
@@ -3522,7 +3648,7 @@ func (t *Terminal) printInfoImpl() {
} }
if t.infoStyle == infoInlineRight { if t.infoStyle == infoInlineRight {
if t.separatorLen > 0 { if separatorLen > 0 {
if !move(line+1, 0, false) { if !move(line+1, 0, false) {
return return
} }
@@ -4553,15 +4679,23 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
height := t.pwindow.Height() height := t.pwindow.Height()
body := t.previewer.lines body := t.previewer.lines
headerLines := t.activePreviewOpts.headerLines 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 // Do not enable preview header lines if it's value is too large
if headerLines > 0 && headerLines < min(len(body), height) { if headerLines > 0 && headerLines < min(len(body), height) {
scrollLines -= headerLines
header := t.previewer.lines[0:headerLines] header := t.previewer.lines[0:headerLines]
body = t.previewer.lines[headerLines:] // A separate header pass would resume the body inside an image, which
// Always redraw header // takes up more rows than the line it arrives on
t.renderPreviewText(height, header, 0, false) if !containsImage(header) {
t.pwindow.MoveAndClear(t.pwindow.Y(), 0) // 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, -t.previewer.offset+headerLines, unchanged) t.renderPreviewText(height, body, lineNo, unchanged)
if !unchanged { if !unchanged {
t.pwindow.FinishFill() t.pwindow.FinishFill()
@@ -4572,7 +4706,7 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
} }
effectiveHeight := height - headerLines effectiveHeight := height - headerLines
barLength, barStart := getScrollbar(1, len(body), effectiveHeight, min(len(body)-effectiveHeight, t.previewer.offset-headerLines)) barLength, barStart := getScrollbar(1, scrollLines, effectiveHeight, min(scrollLines-effectiveHeight, t.previewer.offset-headerLines))
t.renderPreviewScrollbar(headerLines, barLength, barStart) t.renderPreviewScrollbar(headerLines, barLength, barStart)
} }
@@ -4636,6 +4770,66 @@ func findPassThrough(line string) []int {
return []int{loc[0], loc[1] + pos + 2} 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) { func extractPassThroughs(line string) ([]string, string) {
passThroughs := []string{} passThroughs := []string{}
transformed := "" transformed := ""
@@ -4876,7 +5070,7 @@ Loop:
} else { } else {
t.pwindow.Move(y, x) t.pwindow.Move(y, x)
} }
t.tui.PassThrough(passThrough) t.tui.PassThrough(wrapPassThrough(passThrough, inTmux))
if requiredLines > 0 { if requiredLines > 0 {
if y+requiredLines == height { if y+requiredLines == height {
@@ -6067,6 +6261,7 @@ func (t *Terminal) Loop() error {
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
signal.Stop(intChan)
return return
case s := <-intChan: case s := <-intChan:
// Don't quit by SIGINT while executing because it should be for the executing command and not for fzf itself // Don't quit by SIGINT while executing because it should be for the executing command and not for fzf itself
@@ -6079,7 +6274,7 @@ func (t *Terminal) Loop() error {
if !t.tui.ShouldEmitResizeEvent() { if !t.tui.ShouldEmitResizeEvent() {
resizeChan := make(chan os.Signal, 1) resizeChan := make(chan os.Signal, 1)
notifyOnResize(resizeChan) // Non-portable notifyOnResize(ctx, resizeChan) // Non-portable
go func() { go func() {
for { for {
select { select {
@@ -7133,6 +7328,10 @@ func (t *Terminal) Loop() error {
if t.border != nil { if t.border != nil {
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false) t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false)
req(reqRedrawBorderLabel) 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: case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel:
@@ -8157,6 +8356,8 @@ func (t *Terminal) Loop() error {
case actChangePreviewWindow: case actChangePreviewWindow:
// NOTE: We intentionally use "previewOpts" instead of "activePreviewOpts" here // NOTE: We intentionally use "previewOpts" instead of "activePreviewOpts" here
currentPreviewOpts := t.previewOpts currentPreviewOpts := t.previewOpts
wasNoSeparatorLine := t.noSeparatorLine()
wasSeparatorLength := t.separatorLength()
// Reset preview options and apply the additional options // Reset preview options and apply the additional options
t.previewOpts = t.initialPreviewOpts t.previewOpts = t.initialPreviewOpts
@@ -8177,8 +8378,28 @@ func (t *Terminal) Loop() error {
a.a = strings.Join(append(tokens[1:], tokens[0]), "|") 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 // Full redraw
switch currentPreviewOpts.compare(t.activePreviewOpts, &t.previewOpts) { switch currentPreviewOpts.compare(t.activePreviewOpts, &t.previewOpts) {
case previewOptsSame:
checkSeparator()
case previewOptsDifferentLayout: case previewOptsDifferentLayout:
// Preview command can be running in the background if the size of // Preview command can be running in the background if the size of
// the preview window is 0 but not 'hidden' // the preview window is 0 but not 'hidden'
@@ -8186,7 +8407,7 @@ func (t *Terminal) Loop() error {
// FIXME: One-time preview window can't reappear once hidden // FIXME: One-time preview window can't reappear once hidden
// fzf --bind space:preview:ls --bind 'enter:change-preview-window:down|left|up|hidden|' // fzf --bind space:preview:ls --bind 'enter:change-preview-window:down|left|up|hidden|'
updatePreviewWindow(t.hasPreviewWindow() && !t.activePreviewOpts.hidden) updatePreviewWindow(keepForcedPreview)
if wasHidden && t.hasPreviewWindow() { if wasHidden && t.hasPreviewWindow() {
// Restart // Restart
refreshPreview(t.previewOpts.command) refreshPreview(t.previewOpts.command)
@@ -8200,6 +8421,7 @@ func (t *Terminal) Loop() error {
case previewOptsDifferentContentLayout: case previewOptsDifferentContentLayout:
t.previewed.version = 0 t.previewed.version = 0
req(reqPreviewRefresh) req(reqPreviewRefresh)
checkSeparator()
} }
// Adjust scroll offset // Adjust scroll offset
+83
View File
@@ -549,6 +549,89 @@ 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 */ /* utilities section */
// Item represents one line in fzf UI. Usually it is relative path to files and folders. // Item represents one line in fzf UI. Usually it is relative path to files and folders.
+6 -1
View File
@@ -3,6 +3,7 @@
package fzf package fzf
import ( import (
"context"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@@ -10,8 +11,12 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
func notifyOnResize(resizeChan chan<- os.Signal) { func notifyOnResize(ctx context.Context, resizeChan chan<- os.Signal) {
signal.Notify(resizeChan, syscall.SIGWINCH) signal.Notify(resizeChan, syscall.SIGWINCH)
go func() {
<-ctx.Done()
signal.Stop(resizeChan)
}()
} }
func notifyStop(p *os.Process) { func notifyStop(p *os.Process) {
+49 -2
View File
@@ -3,11 +3,58 @@
package fzf package fzf
import ( import (
"context"
"os" "os"
"syscall"
"time"
"golang.org/x/sys/windows"
) )
func notifyOnResize(resizeChan chan<- os.Signal) { const resizePollInterval = 100 * time.Millisecond
// TODO
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 notifyStop(p *os.Process) { func notifyStop(p *os.Process) {
+48 -37
View File
@@ -46,21 +46,35 @@ func tmuxFloatingPaneInfo() (int, int, bool) {
return width, height, true return width, height, true
} }
// A lone ';' argument is a command separator to tmux, aborting the whole // tmux ends a command at an argument ending in ';', so a trailing one is
// command at parse time // escaped. Elsewhere it is not special
func escapeTmuxSeparator(str string) string { func escapeTmuxSeparator(str string) string {
if str == ";" { if strings.HasSuffix(str, ";") {
return `\;` return str[:len(str)-1] + `\;`
} }
return str return str
} }
// Escape a string for use as the pane title; select-pane -T expands format // '#[...]' is still processed when a substituted value is drawn, so '#' is
// expressions denoted by '#', but not time conversion specifiers // doubled. '#{...}' is already literal
func escapeTmuxTitle(str string) string { func escapeTmuxFormat(str string) string {
return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##")) 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 // Convert sizeSpec to the number of cells, clamped between the minimum
// footprint of 3, including the border, and the window size // footprint of 3, including the border, and the window size
func tmuxDim(spec sizeSpec, window int) int { func tmuxDim(spec sizeSpec, window int) int {
@@ -128,35 +142,28 @@ func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeigh
// remain-on-exit is on. // remain-on-exit is on.
setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; ` setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; `
// Set --border-label as the title of the floating pane, and as its // --border-label lives in a pane-scoped user option that
// pane-border-format so that it is displayed on the border when // pane-border-format reads back, so a program in the pane cannot
// pane-border-status is enabled. Without a label, the border text // replace it by setting the title. Set even when empty, so
// is cleared so that the default pane status content (e.g. the // change-border-label can fill it in and the default pane status is
// pane title) is not shown. pane-border-format is pane-scoped, // not shown. pane-border-status is a window option until the next
// but pane-border-status is a window option that only becomes // tmux release, so it is left alone.
// pane-scoped in the next release of tmux, so it is left alone.
// https://github.com/tmux/tmux/commit/7a18fa281db3 // https://github.com/tmux/tmux/commit/7a18fa281db3
// --border-label-pos is ignored. // --border-label-pos is ignored. With an explicit --border fzf draws
// The label is left to fzf when it draws its own border with the // the label itself, but the native border cannot be removed.
// label on it. '--border=none' is not the case; fzf would not ownsLabel := noBorderSpecified(opts)
// display the label, but the native border of a floating pane label := ""
// cannot be removed, so display the label on it nonetheless. if ownsLabel {
format := ""
if opts.BorderLabel.label != "" &&
(opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine ||
opts.BorderShape == tui.BorderNone) {
// Strip ANSI sequences fzf would otherwise render itself // Strip ANSI sequences fzf would otherwise render itself
label, _, _ := extractColor(opts.BorderLabel.label, nil, nil) label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
if label != "" {
setup += fmt.Sprintf(`tmux select-pane -t "$TMUX_PANE" -T %s 2> /dev/null; `,
escapeSingleQuote(escapeTmuxTitle(label)))
// The title is displayed verbatim; substituted values are
// not expanded again
format = "#{pane_title}"
}
} }
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; `, setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" pane-border-format %s 2> /dev/null; `,
escapeSingleQuote(format)) 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", paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal) setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
// Unzoom the window first; creating a floating pane over a zoomed // Unzoom the window first; creating a floating pane over a zoomed
@@ -193,16 +200,20 @@ exit "$code"`, newPane, code, signal, signal, code, code, code)
}, opts, true) }, 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 // 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 // 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 // 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 // when a border style is explicitly specified with --border, so that the
// fzf-drawn border is the only one shown. 'none' and 'line' are treated as no // fzf-drawn border is the only one shown.
// border; fzf draws no box for either, and 'line' only makes sense with
// --height.
func nativeBorder(opts *Options) bool { func nativeBorder(opts *Options) bool {
return opts.Tmux.border || opts.BorderShape == tui.BorderUndefined || return opts.Tmux.border || noBorderSpecified(opts)
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone
} }
func runTmux(args []string, opts *Options) (int, error) { func runTmux(args []string, opts *Options) (int, error) {
+25 -11
View File
@@ -2,37 +2,51 @@ package fzf
import "testing" import "testing"
func TestEscapeTmuxTitle(t *testing.T) { func TestEscapeTmuxFormat(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
given string given string
expected string expected string
}{ }{
{"", ""}, {"", ""},
{" fzf ", " fzf "}, {" fzf ", " fzf "},
{"#", "##"},
{"##", "####"},
{" C# notes #S ", " C## notes ##S "},
{"100%", "100%"}, {"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"}, {"; rm", "; rm"},
{" ; ", " ; "}, {"C#;", `C##\;`},
} { } {
if actual := escapeTmuxTitle(tc.given); actual != tc.expected { if actual := escapeTmuxFormat(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual) t.Errorf("expected %q, got %q", tc.expected, actual)
} }
} }
} }
func TestEscapeTmuxTitleSeparator(t *testing.T) { func TestEscapeTmuxSeparator(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
given string given string
expected 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 := escapeTmuxTitle(tc.given); actual != tc.expected { if actual := escapeTmuxSeparator(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual) t.Errorf("expected %q, got %q", tc.expected, actual)
} }
} }
+22 -5
View File
@@ -1,11 +1,20 @@
package fzf package fzf
import ( import (
"fmt"
"os/exec" "os/exec"
"github.com/junegunn/fzf/src/tui"
) )
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) { func runZellij(args []string, opts *Options) (int, error) {
// Use the native Zellij border by default, consistent with tmux, so that // Use the native Zellij border by default, consistent with tmux, so that
// the pane can be moved and resized with the mouse. Set before // the pane can be moved and resized with the mouse. Set before
@@ -20,6 +29,7 @@ func runZellij(args []string, opts *Options) (int, error) {
"run", "--floating", "--close-on-exit", "--block-until-exit", "run", "--floating", "--close-on-exit", "--block-until-exit",
"--cwd", dir, "--cwd", dir,
} }
ownsLabel := false
if !opts.Tmux.border { if !opts.Tmux.border {
zellijArgs = append(zellijArgs, "--borderless", "true") zellijArgs = append(zellijArgs, "--borderless", "true")
} else { } else {
@@ -33,8 +43,8 @@ func runZellij(args []string, opts *Options) (int, error) {
// stripping ANSI sequences fzf would otherwise render itself. // stripping ANSI sequences fzf would otherwise render itself.
// --border-label-pos is ignored. // --border-label-pos is ignored.
label := "" label := ""
if opts.BorderShape == tui.BorderUndefined || opts.BorderShape == tui.BorderLine || ownsLabel = noBorderSpecified(opts)
opts.BorderShape == tui.BorderNone { if ownsLabel {
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil) label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
} }
zellijArgs = append(zellijArgs, "--name="+label) zellijArgs = append(zellijArgs, "--name="+label)
@@ -60,7 +70,14 @@ func runZellij(args []string, opts *Options) (int, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
zellijArgs = append(zellijArgs, sh, temp) 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 return exec.Command("zellij", zellijArgs...), nil
}, opts, true) }, opts, true)
} }
+290 -21
View File
@@ -248,7 +248,7 @@ class TestLayout < TestInteractive
tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter
expected = <<~OUTPUT expected = <<~OUTPUT
line2$ > line2$ >
5/5 5/5
PREVIEW PREVIEW
@@ -268,7 +268,7 @@ class TestLayout < TestInteractive
5/5 5/5
> >
OUTPUT OUTPUT
tmux.until { assert_block(expected, it) } tmux.until { assert_block(expected, it) }
@@ -597,7 +597,7 @@ class TestLayout < TestInteractive
2 2
1 1
header header
19/97 19/97
> 1 > 1
@@ -628,7 +628,7 @@ class TestLayout < TestInteractive
hello hello
100/100 100/100
> >
BLOCK BLOCK
tmux.until { assert_block(block2, it) } tmux.until { assert_block(block2, it) }
@@ -648,7 +648,7 @@ class TestLayout < TestInteractive
2 2
1 1
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block1, it) } tmux.until { assert_block(block1, it) }
@@ -663,7 +663,7 @@ class TestLayout < TestInteractive
1 1
hello hello
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block2, it) } tmux.until { assert_block(block2, it) }
@@ -679,7 +679,7 @@ class TestLayout < TestInteractive
4 4
> 3 > 3
98/98 98/98
> >
2 2
@@ -718,7 +718,7 @@ class TestLayout < TestInteractive
2 2
1 1
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block1, it) } tmux.until { assert_block(block1, it) }
@@ -734,7 +734,7 @@ class TestLayout < TestInteractive
hello hello
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block2, it) } tmux.until { assert_block(block2, it) }
@@ -750,7 +750,7 @@ class TestLayout < TestInteractive
4 4
> 3 > 3
98/98 98/98
> >
2 2
@@ -767,7 +767,7 @@ class TestLayout < TestInteractive
2 2
1 1
98/98 98/98
> >
hello hello
@@ -810,7 +810,7 @@ class TestLayout < TestInteractive
2 2
1 1
header header
19/97 19/97
> 1 > 1
@@ -825,7 +825,7 @@ class TestLayout < TestInteractive
11 11
> 10 > 10
list list
19/97 19/97
> 1 > 1
3 3
@@ -1039,9 +1039,9 @@ class TestLayout < TestInteractive
103 101 103 101 102 102 2/2 103 103 101 103 101 102 102 2/2 103
5/5 102 2/2 102 103 101 103 101 > 2/2 101 101 > 5/5 102 2/2 102 103 101 103 101 > 2/2 101 101 >
> 103 > 103 102 102 > 102 5/5 102 > 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 > > 102 101
2/2 103 101 102 2/2 103 101 102
> 102 > 103 > 102 > 103
103 103
@@ -1099,9 +1099,9 @@ class TestLayout < TestInteractive
> 102 > 102 > 101 > 101 101 101 101 > 1 > 102 > 102 > 101 > 101 101 101 101 > 1
3/3 101 1/1 101 102 102 102 102 102 3/3 101 1/1 101 102 102 102 102 102
> 102 > 102 HEAD 101 HEAD HEAD HEAD 101 1/1 101 > 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 > > HEAD > > > HEAD HEAD >
1/1 1/1
> 101 101 > 101 101
102 102 102 102
HEAD HEAD HEAD HEAD
@@ -1159,9 +1159,9 @@ class TestLayout < TestInteractive
> 102 > 102 > 101 > 101 > 3 > 3 > 3 101 3 > 102 > 102 > 101 > 101 > 3 > 3 > 3 101 3
3/3 101 1/1 101 102 102 102 3/3 101 1/1 101 102 102 102
> 102 > 102 HEAD 101 HEAD 101 1/1 101 > 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 > > HEAD > HEAD HEAD >
1/1 1/1
> 101 101 > 101 101
102 102 102 102
HEAD HEAD HEAD HEAD
@@ -1191,7 +1191,7 @@ class TestLayout < TestInteractive
label label
header header
10/10 10/10
> >
BLOCK BLOCK
@@ -1228,13 +1228,282 @@ class TestLayout < TestInteractive
end end
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 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 tmux.send_keys %(seq 10 | #{FZF} --header-lines 1 --header-border sharp --no-list-border --pointer '' --marker ''), :Enter
block = <<~BLOCK block = <<~BLOCK
1 1
9/9 9/9
> >
BLOCK BLOCK
tmux.until { assert_block(block, it) } tmux.until { assert_block(block, it) }
+26
View File
@@ -965,6 +965,18 @@ class TestZsh < TestBase
end end
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 # Helper function to run test with Perl and again with Awk
def self.test_perl_and_awk(name, &block) def self.test_perl_and_awk(name, &block)
define_method(:"test_#{name}") do define_method(:"test_#{name}") do
@@ -999,6 +1011,20 @@ class TestZsh < TestBase
tmux.send_keys 'C-l', 'C-r' tmux.send_keys 'C-l', 'C-r'
end 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 test_perl_and_awk 'ctrl_r_accept_or_print_query' do
set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query') set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query')
prepare_ctrl_r_test prepare_ctrl_r_test
+45 -6
View File
@@ -17,9 +17,11 @@ class TestTmux < TestInteractive
def test_floating_pane def test_floating_pane
tmux.send_keys "seq 100 | #{fzf('--popup center,80% --margin 0')}", :Enter tmux.send_keys "seq 100 | #{fzf('--popup center,80% --margin 0')}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count } tmux.until { |lines| assert_equal 100, lines.item_count }
# Border text is cleared when no label is given # Border text is empty when no label is given
format = IO.popen(['tmux', 'show-options', '-p', '-t', floating_pane, 'pane-border-format'], &:read) pane = floating_pane
assert_includes format, "''" 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.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count } tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter tmux.send_keys :Enter
@@ -40,10 +42,41 @@ class TestTmux < TestInteractive
tmux.until { |lines| assert_equal 100, lines.item_count } tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane pane = floating_pane
refute_nil 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) title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
assert_equal ' #fzf-label 100% ', title.chomp refute_equal ' #fzf-label 100% ', title.chomp
format = IO.popen(['tmux', 'show-options', '-p', '-t', pane, 'pane-border-format'], &:read) tmux.send_keys :Enter
assert_includes format, "\#{pane_title}" 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 tmux.send_keys :Enter
assert_equal '1', fzf_output assert_equal '1', fzf_output
end end
@@ -79,6 +112,12 @@ class TestTmux < TestInteractive
private 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 def floating_pane
format = "\#{pane_id} \#{pane_floating_flag}" format = "\#{pane_id} \#{pane_floating_flag}"
lines = IO.popen(['tmux', 'list-panes', '-t', tmux.win, '-F', format]) { |io| io.readlines(chomp: true) } lines = IO.popen(['tmux', 'list-panes', '-t', tmux.win, '-F', format]) { |io| io.readlines(chomp: true) }