Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub 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
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
2026-08-01 09:07:37 +09:00
LangLangBartandJunegunn Choi e36576429d fix(zsh): avoid double call for 'chpwd' in alt-c
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
fix suggested by @lucc; closes #4879
2026-07-31 01:32:32 +09:00
LangLangBartandJunegunn Choi ca51a57aed test(zsh): verify chpwd fires once in alt-c widget 2026-07-31 01:32:32 +09:00
Junegunn Choi b66153825f Update CHANGELOG
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d39595ae66 Bump ruby/setup-ruby from 1.314.0 to 1.321.0 (#4874)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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
LangLangBartandJunegunn Choi 232722145e fix(zsh): fzf-history-widget exit status for perl
fix suggested by @Toliak; closes #4871
2026-07-27 21:16:59 +09:00
LangLangBartandJunegunn Choi 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
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
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
CodeQL / Analyze (go) (push) Waiting to run
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
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
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
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
bitraidandGitHub b163463079 fish: Don't use exported variable for history command (#4863)
CodeQL / Analyze (go) (push) Canceled after 0s
build / build (push) Canceled after 0s
Test fzf on macOS / build (push) Canceled after 0s
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
32 changed files with 1081 additions and 106 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
+17
View File
@@ -1,6 +1,23 @@
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 - 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
+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.1 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.1 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.1" $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.1" "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
+8 -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.1" "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
+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) }
+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)]++
+87 -8
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
@@ -468,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
@@ -1140,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,
@@ -4673,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()
@@ -4692,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)
} }
@@ -4756,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 := ""
@@ -4996,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 {
@@ -6187,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
@@ -6199,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 {
@@ -7253,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:
+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)
} }
+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) }