Compare commits

..

41 Commits

Author SHA1 Message Date
Junegunn Choi f5fbfd848e Let bw theme inherit overridden colors
- Mark derived color slots undefined so fg/bg/list-bg propagate
- Add missing Footer slot (was rendering black)
2026-06-07 18:12:19 +09:00
Junegunn Choi dea72834ed Keep base fg/bg when resolving colors in bw theme
Trailing prompt space and other base-colored segments no longer reset to terminal default
2026-06-07 18:12:19 +09:00
LangLangBart abee152255 test: ALT-C regression tests
discussed in https://github.com/junegunn/fzf/issues/4816
2026-06-05 16:24:27 +09:00
LangLangBart bf114bcc21 test: install nushell in Dockerfile
ref: https://docs.docker.com/reference/dockerfile/#here-documents
2026-06-05 16:24:27 +09:00
Junegunn Choi 838ac7554b make lint 2026-06-02 20:23:23 +09:00
Junegunn Choi ae78a5c56d Allow bare put action in transform output
transform/bg-transform now permit bare `put`, inserting the key that
triggered the action (`a:transform:echo put` puts `a`).
2026-06-02 20:21:04 +09:00
Yi-Yo Chiang 7d647c70c2 [shell][zsh] Don't resolve symlinks in ALT-c (#4816)
This way ALT-c behaves more aligned with `cd`.

Imagine a setup like:
```
/foo -> foo_real
/foo_real/bar
```

Right now if we first `cd foo` (a symlink to `foo_real`), and
then use ALT-c to goto `bar`, then we would end up executing
`cd /foo_real/bar` instead of `cd /foo/bar`. `$PWD = /foo_real/bar`.

For comparison, if we first `cd foo` and then `cd bar`, we end up with
`$PWD = /foo/bar`.

This commit changes the internal logic of `fzf-cd-widget` to first run
`cd <result of FZF_ALT_C_COMMAND>` in a subshell to simulate the
behavior of `cd`, and then insert the target PWD into the shell history.
This way we get behavior consistent with the builtin `cd` command, while
also recording reusable shell history.
2026-05-31 12:38:12 +09:00
Junegunn Choi 6bd17f8f9a Revert "[shell][zsh] Don't resolve symlinks in ALT-c (#4816) (#4817)"
This reverts commit 249a6df4a4.
2026-05-31 12:37:05 +09:00
Junegunn Choi 249a6df4a4 [shell][zsh] Don't resolve symlinks in ALT-c (#4816) (#4817)
This way ALT-c behaves more aligned with `cd`.

Imagine a setup like:
```
/foo -> foo_real
/foo_real/bar
```

Right now if we first `cd foo` (a symlink to `foo_real`), and
then use ALT-c to goto `bar`, then we would end up executing
`cd /foo_real/bar` instead of `cd /foo/bar`. `$PWD = /foo_real/bar`.

For comparison, if we first `cd foo` and then `cd bar`, we end up with
`$PWD = /foo/bar`.

This commit changes the internal logic of `fzf-cd-widget` to first run
`cd <result of FZF_ALT_C_COMMAND>` in a subshell to simulate the
behavior of `cd`, and then insert the target PWD into the shell history.
This way we get behavior consistent with the builtin `cd` command, while
also recording reusable shell history.

Co-authored-by: Yi-Yo Chiang <5255547+silverneko@users.noreply.github.com>

Close #4816
2026-05-31 12:34:58 +09:00
Junegunn Choi a50619388d [install] Fix empty-shell detection in install script
${#shells} is the string length, not the shell count.

Thanks to @matheus-pacifico for the report.

Close #4813
2026-05-28 23:02:34 +09:00
Copilot 5ef8dea36e Prevent nushell source contamination in install shell loop (#4812) 2026-05-28 10:26:43 +09:00
Junegunn Choi 845752f305 Update README 2026-05-25 22:28:11 +09:00
Junegunn Choi 9a61a1457d Bump action versions for Node.js 24 support 2026-05-25 21:11:25 +09:00
Junegunn Choi dfcacb443d Allow manual dispatch of Winget workflow 2026-05-25 21:09:25 +09:00
Junegunn Choi 5412f39b84 Use PAT in release workflow
Releases created with the default GITHUB_TOKEN do not trigger other
workflows (anti-recursion). Winget workflow therefore did not fire
on v0.73.1. Switch to RELEASE_PAT (registered in the `release`
environment) so the release is authored by the user.
2026-05-25 21:08:48 +09:00
Junegunn Choi 07c5cd4185 Fix typo in CHANGELOG 2026-05-25 14:39:05 +09:00
Junegunn Choi ce4bef7595 0.73.1 2026-05-25 14:35:03 +09:00
Junegunn Choi 25868a62f7 Address code scanning alerts 2026-05-25 14:31:51 +09:00
Junegunn Choi 7963a2c658 server: avoid O(n^2) body accumulation in HTTP listener
- handleHttpRequest used `body += text` per token, allocating a new
  backing array on every append (O(n^2) total copy work)
- a single ~390 KB POST monopolised the single-threaded server for
  ~8 s, blocking all other --listen clients
- switch to strings.Builder for amortised O(n)

Reported with fix by Michal Majchrowicz and Marcin Wyczechowski
(AFINE Team).
2026-05-25 14:10:52 +09:00
Junegunn Choi 4b23aa45a8 Skip FZF_CURRENT_ITEM export when item contains NUL
- exec(2) rejects env entries containing NUL, breaking preview and
  other child commands when the input has NUL bytes
- skip the export and document the limitation

Fix #2395
2026-05-25 14:06:50 +09:00
Junegunn Choi 3953d1c649 Add make tag for release tagging
- `prerelease`: version-consistency grep across CHANGELOG, install,
  install.ps1, and both man pages (extracted from `release` target)
- `tag`: depends on `prerelease`; signs and pushes the version tag
- RELEASE.md: replace manual tag/push steps with `make tag`
2026-05-24 01:19:01 +09:00
Junegunn Choi 5e137613d3 Add GitHub Actions release workflow
- Triggers on tag push (v*); fires real release
- workflow_dispatch for dry runs (--snapshot --skip=publish)
- Gated by `release` environment with required reviewer
- RELEASE.md documents tag-only push flow and dispatch testing
2026-05-24 00:59:41 +09:00
Junegunn Choi a24deef77b 0.73.0 2026-05-24 00:15:16 +09:00
Junegunn Choi 1b6e17ca39 nushell: [uninstall] Query nu for autoload directory
Mirror the install fix. Fall back to the XDG path when `nu` is no
longer on PATH (e.g. nushell already uninstalled).
2026-05-24 00:13:24 +09:00
Junegunn Choi 94f6daa61c nushell: [install] Query nu for autoload directory
Nushell uses platform-native config dirs (Application Support on
macOS, AppData on Windows), so $XDG_CONFIG_HOME/nushell/autoload is
wrong outside Linux and the generated file is silently ignored.
Ask `nu` for `$nu.user-autoload-dirs` instead. Safe because the
earlier shells loop already drops `nushell` from $shells when `nu`
is not on PATH.
2026-05-23 23:36:24 +09:00
Junegunn Choi 02594f8dbc nushell: Rename duplicate __fzf_defaults in completion.nu
`fzf --nushell` concatenates key-bindings.nu and completion.nu, both
of which defined `__fzf_defaults`, causing the resulting autoload file
to fail parsing:

  Error: nu::parser::duplicate_command_def

    x Duplicate command definition within a block.
       ,-[/Users/jg/.config/nushell/autoload/_fzf_integration.nu:211:5]
   210 | # Helper to build default fzf options list
   211 | def __fzf_defaults [prepend: string, append: string]: nothing -> string {
       :     ------+------
       :           `-- defined more than once
   212 |   let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') ...
       `----

Rename the completion.nu copy to `__fzf_defaults_completion`.
2026-05-23 23:34:04 +09:00
Junegunn Choi f81cb1939c nushell: [install] Print absolute path to the generated file 2026-05-23 23:32:00 +09:00
Simon Désaulniers 290b18d9fe shell: nushell integration scripts (#4630)
Co-authored-by: imsys <911254+imsys@users.noreply.github.com>
Co-authored-by: Grzegorz Zalewski (Greg) <12560152+zalewskigrzegorz@users.noreply.github.com>
Co-authored-by: René Jochum <rene@jochum.dev>
Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-23 23:13:46 +09:00
Junegunn Choi ccedd064ca Fix integer overflow in FuzzyMatchV2 guard on 32-bit builds
On 32-bit platforms (GOARCH=386, arm), N*M overflows int when N is
large and M approaches 1000, wrapping negative. The wrapped value
slips past both `N*M > cap(slab.I16)` and `M > 1000`, so the V1
fallback is skipped and alloc16 panics on a negative slice bound.

Cast to int64 before multiplying.

Affects shipped 32-bit ARM builds (linux_armv5/6/7, windows_armv5/6/7).

Reported with fix by Michal Majchrowicz and Marcin Wyczechowski
(AFINE Team).
2026-05-23 23:09:58 +09:00
dependabot[bot] d4352a013d Bump github.com/mattn/go-isatty from 0.0.20 to 0.0.22 (#4785)
Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.20 to 0.0.22.
- [Commits](https://github.com/mattn/go-isatty/compare/v0.0.20...v0.0.22)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-isatty
  dependency-version: 0.0.22
  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-05-23 23:09:26 +09:00
dependabot[bot] 665bef56ea Bump ruby/setup-ruby from 1.299.0 to 1.308.0 (#4799)
Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.299.0 to 1.308.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/3ff19f5e2baf30647122352b96108b1fbe250c64...97ecb7b512899eb71ab1bf2310a624c6f1589ac6)

---
updated-dependencies:
- dependency-name: ruby/setup-ruby
  dependency-version: 1.308.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>
Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2026-05-23 23:08:58 +09:00
dependabot[bot] e912cdb3e4 Bump actions/dependency-review-action from 4 to 5 (#4794)
Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4 to 5.
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/dependency-review-action
  dependency-version: '5'
  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>
Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2026-05-23 23:08:43 +09:00
Junegunn Choi de1fca99d5 Export FZF_CURRENT_ITEM to child processes (#4803)
Close #4802
2026-05-23 22:28:03 +09:00
Junegunn Choi 677e854850 Add --preview-window=next position (#4801)
Places preview adjacent to input on the list side: above input in the
default layout, below it in --layout=reverse.

  fzf --preview 'cat {}' --preview-window=next

Close #4798
2026-05-23 10:32:19 +09:00
Junegunn Choi 67319aed0b Fix rendering glitch with preview window on the left + footer 2026-05-19 18:54:35 +09:00
Junegunn Choi 367177d911 Fix RuboCop errors 2026-05-19 18:54:15 +09:00
Junegunn Choi 5819e5ff2f Fix bg-transform reload/exclude
Async callbacks fire a later iteration than the one that scheduled
them, so newCommand/reloadSync/denylist must persist across iterations.

  fzf --bind 'space:bg-transform:echo reload:date'
2026-05-18 18:48:00 +09:00
Junegunn Choi fcc3c6acce Add every(N) bind event and FZF_IDLE_TIME env var (#4797)
- every(N) fires every N seconds (fractional, floored to 0.01s)
- Encoded as tui.Every with duration in Char as milliseconds, so
  every(1) and every(2) coexist as distinct keymap entries
- FZF_IDLE_TIME exposes whole seconds since the last user activity
  (keystroke or mouse event); pair with every() for idle-based
  patterns like auto-accept/auto-quit

Close #1211
2026-05-17 23:54:59 +09:00
Junegunn Choi e0d081906f Reward non-word match at word boundary
A non-word character (e.g. '.') used to receive a flat bonusNonWord
regardless of context. Now it gets bonusBoundaryWhite at the start of
input and bonusBoundaryDelimiter right after a delimiter, matching the
treatment of word characters at the same boundaries.

Without this, '.completion' matching '.completion' lost to
'bash_completion.d/completions/X' because the consecutive chunk anchor
in the long path (the 'c' after '/') received bonusBoundaryDelimiter
while the exact match's '.' was capped at bonusNonWord.

Fix #4795
2026-05-15 00:46:36 +09:00
Junegunn Choi 263eb4732f Strip UTF-8-encoded C1 control characters from rendered items
The display sanitizer already stripped raw 8-bit C1 bytes (0x80-0x9F)
because they decode to RuneError as standalone bytes. Their valid UTF-8
encodings (0xC2 0x80 .. 0xC2 0x9F) decode to the same code points but
were passed through, allowing a filename or input line containing CSI
(U+009B), OSC (U+009D), or DCS (U+0090) to inject terminal control
sequences when rendered.
2026-05-05 09:56:59 +09:00
Junegunn Choi b4a86a9c8a Preserve wrap state across change-preview-window
toggle-preview-wrap (and -wrap-word) modifies t.activePreviewOpts.wrap,
but change-preview-window resets t.previewOpts to t.initialPreviewOpts,
discarding the user's toggle. Carry wrap and wrapWord over so toggles
survive a layout change. Explicit wrap / nowrap tokens in the new spec
still win, so cycling and the empty-token reset are unaffected.

Close #4791
2026-05-02 15:40:41 +09:00
39 changed files with 2157 additions and 312 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ jobs:
- name: 'Checkout Repository'
uses: actions/checkout@v5
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@v5
+7 -2
View File
@@ -28,12 +28,17 @@ jobs:
go-version: "1.23"
- name: Setup Ruby
uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
with:
ruby-version: 3.4.6
- name: Install packages
run: sudo apt-get install --yes zsh fish tmux shfmt
run: |
sudo install -d -m 0755 /etc/apt/keyrings
wget -qO- https://apt.fury.io/nushell/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | sudo tee /etc/apt/sources.list.d/fury-nushell.list
sudo apt-get update
sudo apt-get install --yes zsh fish tmux shfmt nushell
- name: Install Ruby gems
run: bundle install
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
go-version: "1.23"
- name: Setup Ruby
uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
with:
ruby-version: 3.0.0
+76
View File
@@ -0,0 +1,76 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to validate (e.g. 0.73.0).'
type: string
required: true
permissions:
contents: write
jobs:
release:
runs-on: macos-latest
environment: release
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: stable
- name: Determine version
id: ver
run: |
if [ "${{ github.event_name }}" = "push" ]; then
v=${GITHUB_REF_NAME#v}
else
v='${{ inputs.version }}'
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Resolved version: '$v'"
- name: Verify version consistency
run: |
set -e
V='${{ steps.ver.outputs.version }}'
R=$(echo "$V" | sed 's/\./\\./g')
grep -q "^${R}$" CHANGELOG.md
grep -qF "\"fzf ${V}\"" man/man1/fzf.1
grep -qF "\"fzf ${V}\"" man/man1/fzf-tmux.1
grep -qF "${V}" install
grep -qF "${V}" install.ps1
- name: Extract release notes
run: |
set -e
mkdir -p tmp
V='${{ steps.ver.outputs.version }}'
R=$(echo "$V" | sed 's/\./\\./g')
sed -n "/^${R}$/,/^[0-9]/p" CHANGELOG.md \
| tail -r | sed '1,/^ *$/d' | tail -r | sed '1,2d' \
| tee tmp/release-note
- name: Run goreleaser
uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: >-
${{ github.event_name == 'push'
&& 'release --clean --release-notes tmp/release-note'
|| 'release --snapshot --clean --skip=publish' }}
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
+7
View File
@@ -2,6 +2,12 @@ name: Publish to Winget
on:
release:
types: [released]
workflow_dispatch:
inputs:
release-tag:
description: 'Release tag to submit (e.g. v0.73.1)'
required: true
type: string
jobs:
publish:
@@ -10,5 +16,6 @@ jobs:
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with:
identifier: junegunn.fzf
release-tag: ${{ inputs.release-tag || github.event.release.tag_name }}
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
token: ${{ secrets.WINGET_TOKEN }}
+35
View File
@@ -1,6 +1,41 @@
CHANGELOG
=========
0.73.1
------
- Bug fixes
- Skip `$FZF_CURRENT_ITEM` export when the item contains a NUL byte; `exec(2)` rejects the env, breaking preview and other child commands (#4806)
- Fixed O(n^2) HTTP body accumulation in `--listen`; a single ~390 KB request could block the single-threaded server for ~8 s (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
0.73.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.73.0/_
- Nushell integration via `fzf --nushell` and the installer (#4630) (@sim590)
- New `--preview-window=next` position that places the preview adjacent to the input section, on the list side: above the input in the default layout, below it in `--layout=reverse` (#4798)
- Timer-driven `every(N)` event for `--bind`, where `N` is seconds
- Added `$FZF_IDLE_TIME` (whole seconds) and `$FZF_IDLE_TIME_MS` (milliseconds), holding the elapsed time since the last user activity
- Pair with `every(N)` to build idle-based behavior such as auto-accept or auto-quit (#1211)
```sh
# Live process list; --track --id-nth 2 keeps the cursor on the same PID across reloads
fzf --header-lines 1 --track --id-nth 2 --bind 'start,every(2):reload-sync:ps -ef'
# Auto-accept after 10 seconds of inactivity, with a countdown in the footer after 5s
fzf --bind 'every(1):bg-transform:
if [[ $FZF_IDLE_TIME -lt 5 ]]; then echo change-footer:
elif [[ $FZF_IDLE_TIME -lt 10 ]]; then echo "change-footer:auto-accept in $((10 - FZF_IDLE_TIME))s"
else echo accept
fi'
```
- Added `$FZF_CURRENT_ITEM` for shells where quoting `{}` is awkward (#4802)
- Bug fixes
- Scoring: non-word characters at the start of input or after a delimiter now receive the same boundary bonus as word characters (#4795)
- `change-preview-window` no longer resets `wrap` / `wrap-word` state set via `toggle-preview-wrap` / `toggle-preview-wrap-word` (#4791)
- Stripped UTF-8-encoded C1 control characters from rendered items to prevent terminal control-sequence injection
- Fixed integer-overflow panic in `FuzzyMatchV2` on 32-bit builds (Michal Majchrowicz, Marcin Wyczechowski, AFINE Team)
- Fixed `bg-transform` `reload` / `exclude` payloads being dropped
- Fixed rendering glitch with preview window on the left combined with footer
0.72.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.72.0/_
+11 -1
View File
@@ -1,5 +1,15 @@
FROM rubylang/ruby:3.4.1-noble
RUN apt-get update -y && apt install -y git make golang zsh fish tmux
RUN apt-get update && apt-get install -y git make golang zsh fish tmux
# https://www.nushell.sh/book/installation.html
RUN <<EOF
set -ex
apt-get install -y wget gnupg
wget -qO- https://apt.fury.io/nushell/gpg.key | gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | tee /etc/apt/sources.list.d/fury-nushell.list
apt-get update
apt-get install -y nushell
EOF
RUN gem install --no-document -v 5.22.3 minitest
RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
RUN echo '. ~/.bashrc' >> ~/.bash_profile
+14 -1
View File
@@ -117,6 +117,19 @@ generate:
build:
goreleaser build --clean --snapshot --skip=post-hooks
prerelease:
# Check if version numbers are properly updated
grep -q ^$(VERSION_REGEX)$$ CHANGELOG.md
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf.1
grep -qF '"fzf $(VERSION_TRIM)"' man/man1/fzf-tmux.1
grep -qF $(VERSION) install
grep -qF $(VERSION) install.ps1
@echo "OK: all files consistent at $(VERSION)"
tag: prerelease
git tag -s v$(VERSION) -m v$(VERSION)
git push origin v$(VERSION)
release:
# Make sure that the tests pass and the build works
TAGS=tcell make test
@@ -206,4 +219,4 @@ update:
$(GO) get -u
$(GO) mod tidy
.PHONY: all generate build release test itest bench lint install clean docker docker-test update fmt
.PHONY: all generate build prerelease tag release test itest bench lint install clean docker docker-test update fmt
+58 -15
View File
@@ -25,22 +25,22 @@
---
fzf is a general-purpose command-line fuzzy finder.
fzf is a general-purpose command-line fuzzy finder and an interactive terminal toolkit.
<img src="https://raw.githubusercontent.com/junegunn/i/master/fzf-preview.png" width=640>
It's an interactive filter program for any kind of list; files, command
history, processes, hostnames, bookmarks, git commits, etc. It implements
a "fuzzy" matching algorithm, so you can quickly type in patterns with omitted
characters and still get the results you want.
Whether you're selecting files, browsing command history, previewing data,
navigating complex datasets with fuzzy matching, or creating custom menus and
workflows, fzf provides the building blocks to turn shell scripts into rich
terminal applications.
Highlights
----------
- **Portable** -- Distributed as a single binary for easy installation
- **Fast** -- Optimized to process millions of items instantly
- **Versatile** -- Fully customizable through an event-action binding mechanism
- **All-inclusive** -- Comes with integrations for Bash, Zsh, Fish, Vim, and Neovim
- **Portable** // Distributed as a single binary for easy installation
- **Fast** // Optimized to process millions of items in milliseconds
- **Programmable** // Event-driven architecture for building custom terminal interfaces and workflows
- **Batteries-included** // Comes with integrations for Bash, Zsh, Fish, Nushell, Vim, and Neovim
Table of Contents
-----------------
@@ -81,6 +81,7 @@ Table of Contents
* [Supported commands (bash)](#supported-commands-bash)
* [Custom fuzzy completion](#custom-fuzzy-completion)
* [Fuzzy completion for fish](#fuzzy-completion-for-fish)
* [Fuzzy completion for Nushell](#fuzzy-completion-for-nushell)
* [Vim plugin](#vim-plugin)
* [Advanced topics](#advanced-topics)
* [Customizing for different types of input](#customizing-for-different-types-of-input)
@@ -210,10 +211,18 @@ Add the following line to your shell configuration file.
# Set up fzf key bindings
fzf --fish | source
```
* Nushell -- Nushell does not support piping into `source`, so the install
script generates a file in the autoload directory. If you didn't use the
install script, you can manually set it up:
```nu
# Generate the integration script
mkdir ($nu.default-config-dir | path join "autoload")
fzf --nushell | save -f ($nu.default-config-dir | path join "autoload" "_fzf_integration.nu")
```
> [!NOTE]
> `--bash`, `--zsh`, and `--fish` options are only available in fzf 0.48.0 or
> later. If you have an older version of fzf, or want finer control, you can
> `--bash`, `--zsh`, `--fish`, and `--nushell` options are only available in
> recent versions of fzf. If you have an older version of fzf, or want finer control, you can
> source individual script files in the [/shell](/shell) directory. The
> location of the files may vary depending on the package manager you use.
> Please refer to the package documentation for more information.
@@ -227,6 +236,8 @@ Add the following line to your shell configuration file.
> * bash: `FZF_CTRL_R_COMMAND= FZF_ALT_C_COMMAND= eval "$(fzf --bash)"`
> * zsh: `FZF_CTRL_R_COMMAND= FZF_ALT_C_COMMAND= source <(fzf --zsh)`
> * fish: `fzf --fish | FZF_CTRL_R_COMMAND= FZF_ALT_C_COMMAND= source`
> * nushell: add to your `env.nu`:
> `$env.FZF_CTRL_R_COMMAND = ""; $env.FZF_ALT_C_COMMAND = ""`
>
> Setting the variables after sourcing the script will have no effect.
@@ -506,7 +517,7 @@ Key bindings for command-line
-----------------------------
By [setting up shell integration](#setting-up-shell-integration), you can use
the following key bindings in bash, zsh, and fish.
the following key bindings in bash, zsh, fish, and Nushell.
- `CTRL-T` - Paste the selected files and directories onto the command-line
- The list is generated using `--walker file,dir,follow,hidden` option
@@ -522,15 +533,16 @@ the following key bindings in bash, zsh, and fish.
```
- Can be disabled by setting `FZF_CTRL_T_COMMAND` to an empty string when
sourcing the script
- `CTRL-R` - Paste the selected command from history onto the command-line. With fish shell, it is possible to select multiple commands.
- `CTRL-R` - Paste the selected command from history onto the command-line.
- Select multiple commands with `TAB`.
- If you want to see the commands in chronological order, press `CTRL-R`
again which toggles sorting by relevance
- Press `ALT-R` to toggle "raw" mode where you can see the surrounding items
of a match. In this mode, you can press `CTRL-N` and `CTRL-P` to move
between the matching items only.
- Press `CTRL-/` or `ALT-/` to toggle line wrapping
- Press `SHIFT-DELETE` to delete the selected commands (bash and fish)
- Fish shell only:
- Press `SHIFT-DELETE` to delete the selected commands
- Press `ALT-ENTER` to reformat and insert the selected commands
- Press `ALT-T` to cycle through command prefix (timestamp, date/time, none)
- Set `FZF_CTRL_R_OPTS` to pass additional options to fzf
@@ -574,7 +586,7 @@ More tips can be found on [the wiki page](https://github.com/junegunn/fzf/wiki/C
Fuzzy completion
----------------
Shell integration also provides fuzzy completion for bash, zsh, and fish.
Shell integration also provides fuzzy completion for bash, zsh, fish, and Nushell.
### Files and directories
@@ -823,6 +835,37 @@ function _fzf_post_complete_foo
end
```
### Fuzzy completion for Nushell
Fuzzy completion in Nushell works via the
[external completer](https://www.nushell.sh/cookbook/external_completers.html)
mechanism. There are some differences compared to bash and zsh:
- On Nushell >= 0.103.0, the external completer is no longer called for
built-in commands (e.g. `cd`, `ls`). Fuzzy completion with `**<TAB>` only
works for external commands.
- Custom completers can be defined via the `$env.FZF_COMPLETERS` record in
your `config.nu`. Each entry is a closure that receives the prefix and the
command spans, and returns either a list of candidate strings or a record
`{ candidates: [...], opts: [...] }` for custom fzf options:
```nu
$env.FZF_COMPLETERS = {
pacman: {|prefix, spans|
let sub = $spans | skip 1 | first
let candidates = (if ($sub =~ "-[SF]") { ^pacman -Slq | lines
} else if ($sub =~ "-[QR]") { ^pacman -Qq | lines
} else { [] })
{ candidates: $candidates, opts: ["--preview", "pacman -Si {}"] }
}
}
```
See [shell/completion-examples.nu](shell/completion-examples.nu) for more
examples.
- The following environment variables are supported:
`FZF_COMPLETION_TRIGGER`, `FZF_COMPLETION_OPTS`,
`FZF_COMPLETION_PATH_OPTS`, `FZF_COMPLETION_DIR_OPTS`,
`FZF_COMPLETION_DIR_COMMANDS`.
Vim plugin
----------
+54
View File
@@ -0,0 +1,54 @@
Release process
===============
Building, signing, notarizing, and publishing is handled by
[`.github/workflows/release.yml`](.github/workflows/release.yml),
triggered by a tag push.
## Steps
1. Update version in the following files and commit on `master`:
- `CHANGELOG.md`
- `main.go`
- `install`
- `install.ps1`
- `man/man1/fzf.1`
- `man/man1/fzf-tmux.1`
2. Verify file consistency, sign the tag, and push the tag.
```sh
make tag VERSION=0.73.1
```
`make tag` runs `prerelease` first (checks that the version
appears in CHANGELOG.md, both man pages, install, and install.ps1)
and only signs + pushes the tag if the checks pass.
Only the tag is pushed; `master` on origin still points to the
old version, so `/master/install` keeps resolving against existing
binaries during the publish window.
3. The workflow fires on the tag push and pauses on the `release`
environment gate. Approve it in the Actions tab to release.
4. After the GitHub release is published, fast-forward `master`:
```sh
git push origin master
```
## Testing the workflow
To exercise the workflow without firing a real release:
1. Actions tab -> **Release** -> **Run workflow**.
2. Pick a branch and enter the version currently on that branch
(the version-consistency check requires the input to match the
files in the checked-out tree).
3. Approve the `release` environment gate when prompted.
4. Goreleaser runs with `--snapshot --skip=publish`. Signing and
notarization run; only the GitHub release upload is skipped.
Use this to validate the workflow YAML, version-extraction logic,
the macOS runner setup, and the signing/notarization credentials.
+1 -1
View File
@@ -4,7 +4,7 @@ require (
github.com/charlievieth/fastwalk v1.0.14
github.com/gdamore/tcell/v2 v2.9.0
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-isatty v0.0.22
github.com/rivo/uniseg v0.4.7
golang.org/x/sys v0.35.0
golang.org/x/term v0.34.0
+2 -3
View File
@@ -8,8 +8,8 @@ github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMD
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -33,7 +33,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+30 -4
View File
@@ -2,11 +2,11 @@
set -u
version=0.72.0
version=0.73.1
auto_completion=
key_bindings=
update_config=2
shells="bash zsh fish"
shells="bash zsh fish nushell"
prefix='~/.fzf'
prefix_expand=~/.fzf
fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish
@@ -27,6 +27,7 @@ usage: $0 [OPTIONS]
--no-bash Do not set up bash configuration
--no-zsh Do not set up zsh configuration
--no-fish Do not set up fish configuration
--no-nushell Do not set up nushell configuration
EOF
}
@@ -56,6 +57,7 @@ for opt in "$@"; do
--no-bash) shells=${shells/bash/} ;;
--no-zsh) shells=${shells/zsh/} ;;
--no-fish) shells=${shells/fish/} ;;
--no-nushell) shells=${shells/nushell/} ;;
*)
echo "unknown option: $opt"
help
@@ -224,12 +226,14 @@ fi
[[ $* =~ "--bin" ]] && exit 0
for s in $shells; do
if ! command -v "$s" > /dev/null; then
bin=$s
[[ $s == nushell ]] && bin=nu
if ! command -v "$bin" > /dev/null; then
shells=${shells/$s/}
fi
done
if [[ ${#shells} -lt 3 ]]; then
if [[ -z ${shells// /} ]]; then
echo "No shell configuration to be updated."
exit 0
fi
@@ -248,6 +252,7 @@ fi
echo
for shell in $shells; do
[[ $shell == nushell ]] && continue
fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
[[ $shell == fish ]] && continue
@@ -368,6 +373,7 @@ fi
echo
for shell in $shells; do
[[ $shell == fish ]] && continue
[[ $shell == nushell ]] && continue
[ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
done
@@ -436,6 +442,25 @@ if [[ $shells =~ fish ]]; then
fi
fi
if [[ $shells =~ nushell ]]; then
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
echo "Setting up Nushell integration ..."
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
mkdir -p "$nushell_autoload_dir"
dest="$nushell_autoload_dir/_fzf_integration.nu"
echo -n " Generate $dest ... "
if [[ $key_bindings -eq 1 && $auto_completion -eq 1 ]]; then
"$fzf_base"/bin/fzf --nushell > "$dest"
elif [[ $key_bindings -eq 1 ]]; then
cp "$fzf_base/shell/key-bindings.nu" "$dest"
else
cp "$fzf_base/shell/completion.nu" "$dest"
fi
echo "OK"
echo
fi
fi
if [ $update_config -eq 1 ]; then
echo 'Finished. Restart your shell or reload config file.'
if [[ $shells =~ bash ]]; then
@@ -445,6 +470,7 @@ if [ $update_config -eq 1 ]; then
fi
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
[[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fish_user_key_bindings # fish'
[[ $shells =~ nushell ]] && echo ' # nushell: files are loaded automatically from autoload directory'
echo
echo 'Use uninstall script to remove fzf.'
echo
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.72.0"
$version="0.73.1"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+12 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/junegunn/fzf/src/protector"
)
var version = "0.72"
var version = "0.73"
var revision = "devel"
//go:embed shell/key-bindings.bash
@@ -29,6 +29,12 @@ var zshCompletion []byte
//go:embed shell/key-bindings.fish
var fishKeyBindings []byte
//go:embed shell/key-bindings.nu
var nushellKeyBindings []byte
//go:embed shell/completion.nu
var nushellCompletion []byte
//go:embed shell/completion.fish
var fishCompletion []byte
@@ -71,6 +77,11 @@ func main() {
printScript("completion.fish", fishCompletion)
return
}
if options.Nushell {
printScript("key-bindings.nu", nushellKeyBindings)
printScript("completion.nu", nushellCompletion)
return
}
if options.Help {
fmt.Print(fzf.Usage)
return
+1 -1
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf\-tmux 1 "Apr 2026" "fzf 0.72.0" "fzf\-tmux - open fzf in tmux split pane"
.TH fzf\-tmux 1 "May 2026" "fzf 0.73.1" "fzf\-tmux - open fzf in tmux split pane"
.SH NAME
fzf\-tmux - open fzf in tmux split pane
+46 -1
View File
@@ -21,7 +21,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
..
.TH fzf 1 "Apr 2026" "fzf 0.72.0" "fzf - a command-line fuzzy finder"
.TH fzf 1 "May 2026" "fzf 0.73.1" "fzf - a command-line fuzzy finder"
.SH NAME
fzf - a command-line fuzzy finder
@@ -993,9 +993,14 @@ border line.
\fBdown
\fBleft
\fBright
\fBnext
\fRDetermines the layout of the preview window.
* \fBnext\fR places the preview window adjacent to the input section, on
the list side: above the input in the default layout, below the input
in \fB\-\-layout=reverse\fR.
* If the argument contains \fB:hidden\fR, the preview window will be hidden by
default until \fBtoggle\-preview\fR action is triggered.
@@ -1368,6 +1373,12 @@ Print script to set up Fish shell integration
e.g. \fBfzf \-\-fish | source\fR
.TP
.B "\-\-nushell"
Print script to set up Nushell shell integration
e.g. \fBfzf \-\-nushell | save \-f ~/.config/nushell/autoload/_fzf_integration.nu\fR
.SS OTHERS
.TP
.B "\-\-no\-mouse"
@@ -1470,6 +1481,8 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_POS " Vertical position of the cursor in the list starting from 1"
.br
.BR FZF_CURRENT_ITEM " Text of the current item (unset if the list is empty)"
.br
.BR FZF_WRAP " The line wrapping mode (char, word) when enabled"
.br
.BR FZF_QUERY " Current query string"
@@ -1500,6 +1513,10 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_KEY " The name of the last key pressed"
.br
.BR FZF_IDLE_TIME " Whole seconds since the last user activity"
.br
.BR FZF_IDLE_TIME_MS " Milliseconds since the last user activity"
.br
.BR FZF_PORT " Port number when \-\-listen option is used"
.br
.BR FZF_SOCK " Unix socket path when \-\-listen option is used"
@@ -1514,6 +1531,10 @@ fzf exports the following environment variables to its child processes.
.br
.BR FZF_RAW " Only in raw mode. 1 if the current item matches, 0 otherwise"
.PP
.B FZF_CURRENT_ITEM
is omitted when the item contains a NUL byte, because exec(2) cannot pass it.
.SH EXTENDED SEARCH MODE
Unless specified otherwise, fzf will start in "extended\-search mode". In this
@@ -1939,6 +1960,30 @@ variables starting from 1. It optionally sets \fBFZF_CLICK_FOOTER_WORD\fR
if clicked on a word.
.RE
\fIevery(N)\fR
.RS
Triggered every \fIN\fR seconds (\fIN\fR can be a fractional number, e.g.
\fB0.5\fR). The minimum interval is \fB0.01\fR seconds; values are floored
to that.
Combine with the \fBFZF_IDLE_TIME\fR (whole seconds) and
\fBFZF_IDLE_TIME_MS\fR (milliseconds) environment variables to build
idle\-based behavior without a separate event.
e.g.
\fB# Live process list, refreshed every 2 seconds.
# --track --id-nth 2 keeps the cursor on the same PID across reloads.
fzf \-\-header\-lines 1 \-\-track \-\-id\-nth 2 \\
\-\-bind 'start,every(2):reload\-sync:ps \-ef'
# Auto\-accept after 10 seconds of inactivity, with a countdown in the footer after 5s.
fzf \-\-bind 'every(1):bg\-transform:
if [[ $FZF_IDLE_TIME \-lt 5 ]]; then echo change\-footer:
elif [[ $FZF_IDLE_TIME \-lt 10 ]]; then echo "change\-footer:auto\-accept in $((10 \- FZF_IDLE_TIME))s"
else echo accept
fi'\fR
.RE
.SS AVAILABLE ACTIONS:
A key or an event can be bound to one or more of the following actions.
+91
View File
@@ -0,0 +1,91 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ completion-examples.nu
#
# Example custom completers for fzf's Nushell integration.
#
# To use these, add the desired entries to $env.FZF_COMPLETERS in your
# config.nu. Each closure receives two arguments:
# - prefix: the text before the trigger (e.g. "vim" in "vim **<TAB>")
# - spans: the full command as a list of words (e.g. ["pacman", "-S", "vim**"])
#
# A closure can return either:
# - a list of candidate strings (fzf will use default options), or
# - a record with the following optional fields:
# candidates: list<string> # candidates to feed to fzf
# opts: list<string> # custom fzf options (default: ["-m"])
# post: closure (|sel| ...) # post-processing of the selected item
#
# Simple example:
# $env.FZF_COMPLETERS = {
# git: {|prefix, spans| ["branch-main", "branch-dev", "branch-feature"]}
# }
# --- pacman / paru ---
# Completes package names for pacman and paru.
# Uses the spans to distinguish between subcommands:
# -S (sync), -F (files): list available packages from repos
# -Q (query), -R (remove): list installed packages
# Returns a record with custom fzf options for package preview.
$env.FZF_COMPLETERS = {}
$env.FZF_COMPLETERS.pacman = {|prefix, spans|
let sub = $spans | skip 1 | first
let candidates = (if ($sub =~ "-[SF]") {
^pacman -Slq | lines
} else if ($sub =~ "-[QR]") {
^pacman -Qq | lines
} else {
[]
})
{
candidates: $candidates
opts: ["-m", "--preview", "pacman -Si {}", "--prompt", "Package > "]
}
}
$env.FZF_COMPLETERS.paru = {|prefix, spans|
let sub = $spans | skip 1 | first
let candidates = (if ($sub =~ "-[SF]") {
^pacman -Slq | lines
} else if ($sub =~ "-[QR]") {
^pacman -Qq | lines
} else {
[]
})
{
candidates: $candidates
opts: ["-m", "--preview", "pacman -Si {}", "--prompt", "Package > "]
}
}
# --- pass (password-store) ---
# Completes entry names from ~/.password-store.
# Returns a simple list (no custom fzf options needed).
$env.FZF_COMPLETERS.pass = {|prefix, spans|
try {
ls ~/.password-store/**/*.gpg
| get name
| each {$in | str replace -r '^.*?\.password-store/(.*).gpg' '${1}'}
} catch {
[]
}
}
# --- Example with post-processing hook ---
# The "post" closure transforms the selected line after fzf returns.
# This is useful when the displayed line contains more information than
# what you want inserted on the command line (e.g. extracting a PID from
# a full "ps" output line).
#
# $env.FZF_COMPLETERS.mycommand = {|prefix, spans|
# {
# candidates: (^some-command | lines)
# opts: ["+m", "--header-lines=1"]
# post: {|selection| $selection | split row ' ' | get 0}
# }
# }
+489
View File
@@ -0,0 +1,489 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ completion.nu
# An implementation of completion.nu
# This loads FZF as a Nushell External Completer
# https://www.nushell.sh/cookbook/external_completers.html
# --- Default Environment Variables ---
# These can be overridden in your config.nu or environment.
# Example: $env.FZF_COMPLETION_TRIGGER = "!<TAB>"
# - $env.FZF_TMUX (default: 0)
# - $env.FZF_TMUX_OPTS (default: empty)
# - $env.FZF_TMUX_HEIGHT (default: 40%)
# - $env.FZF_COMPLETION_TRIGGER (default: '**')
# - $env.FZF_COMPLETION_OPTS (default: empty)
# - $env.FZF_COMPLETION_PATH_OPTS (default: empty)
# - $env.FZF_COMPLETION_DIR_OPTS (default: empty)
$env.FZF_COMPLETION_TRIGGER = $env.FZF_COMPLETION_TRIGGER? | default '**'
# Options for fzf completion in general. e.g. '--border'
$env.FZF_COMPLETION_OPTS = $env.FZF_COMPLETION_OPTS? | default ''
# Options specific to path completion. e.g. '--extended'
$env.FZF_COMPLETION_PATH_OPTS = $env.FZF_COMPLETION_PATH_OPTS? | default ''
# Options specific to directory completion. e.g. '--extended'
$env.FZF_COMPLETION_DIR_OPTS = $env.FZF_COMPLETION_DIR_OPTS? | default ''
$env.FZF_COMPLETION_DIR_COMMANDS = $env.FZF_COMPLETION_DIR_COMMANDS? | default ['cd', 'pushd', 'rmdir']
# --- Helper Functions ---
# Helper to build default fzf options list
def __fzf_defaults_completion [prepend: string, append: string]: nothing -> string {
let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') --min-height 20+ --bind=ctrl-z:ignore ($prepend)"
let opts_file = if ($env.FZF_DEFAULT_OPTS_FILE? | default '' | is-not-empty) {
try { open --raw ($env.FZF_DEFAULT_OPTS_FILE) | str trim } catch { '' }
} else {
''
}
let default_opts = $env.FZF_DEFAULT_OPTS? | default ''
$"($base) ($opts_file) ($default_opts) ($append)" | str trim
}
# Wrapper for running fzf or fzf-tmux
def __fzf_comprun [ context_name: string # e.g., "fzf-completion" , "fzf-helper" - mainly for potential debugging
, query: string # The initial query string for fzf
, fzf_opts_arg: list<string> # Remaining options for fzf/fzf-tmux
] {
let stdin_content = try {
# Collect stdin into a single string. Adjust if structured data is expected.
$in | into string
} catch {
null # Set to null if there's no stdin or an error occurs reading it
}
let fzf_default_opts = (__fzf_defaults_completion "" ($env.FZF_COMPLETION_OPTS | default ''))
let fzf_prefinal_opt = ['--query', $query, '--reverse'] | append $fzf_opts_arg
# Get the configured height, defaulting to '40%'
let height_opt = $env.FZF_TMUX_HEIGHT? | default '40%'
# Determine if fzf should generate its own candidates via walker
let has_walker = ($fzf_prefinal_opt | find '--walker' | is-not-empty)
# Check for custom comprun function (Nu equivalent)
if (which _fzf_comprun | is-not-empty) {
# Note: Nushell doesn't have a direct equivalent to Zsh/Bash `type -t _fzf_comprun`.
# This check assumes a user might define a custom command named `_fzf_comprun`.
_fzf_comprun $context_name $query ...$fzf_prefinal_opt # Pass args correctly to custom function
} else if ($env.TMUX_PANE? | default '' | into string | is-not-empty) and (($env.FZF_TMUX? | default 0) != 0 or ($env.FZF_TMUX_OPTS? | is-not-empty)) {
# Running inside tmux, use fzf-tmux
let final_fzf_opts = if ($env.FZF_TMUX_OPTS? | is-not-empty) {
$env.FZF_TMUX_OPTS | split row ' ' | append ['--'] | append $fzf_prefinal_opt
} else {
# Use the default -d option with the configured height for fzf-tmux
['-d', $height_opt, '--'] | append $fzf_prefinal_opt
}
if $has_walker or ($stdin_content == null) {
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
} else {
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
}
} else {
# Not in tmux or not configured for fzf-tmux, use fzf directly
let final_fzf_opts = $fzf_prefinal_opt
if $has_walker or ($stdin_content == null) {
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
} else {
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts, FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
}
}
}
# Generate host list for ssh/telnet
def __fzf_list_hosts [] {
# Translate the Zsh pipeline using Nu commands and external tools
let ssh_configs = try { open ~/.ssh/config | lines } catch { [] }
let ssh_configs_d = try { open ~/.ssh/config.d/* | lines } catch { [] }
let ssh_config_global = try { open /etc/ssh/ssh_config | lines } catch { [] }
let known_hosts = try { open ~/.ssh/known_hosts | lines } catch { [] }
let hosts_file = try { open /etc/hosts | lines } catch { [] }
[
(
# Process ssh config files
$ssh_configs | append $ssh_configs_d | append $ssh_config_global
| where {|it| ($it | str downcase | str starts-with 'host') or ($it | str downcase | str starts-with 'hostname') }
| parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword
| default { hosts: null } # Handle lines that don't match regex
| get hosts
| where {|it| $it != null }
| split row ' '
| where {|it| not ($it =~ '[*?%]') } # Exclude patterns containing *, ?, or %
)
(
# Process known_hosts file
$known_hosts | parse --regex '^(?:\[)?(?<hosts>[a-z0-9.,:_-]+)' # Extract hostnames (possibly in [], possibly comma-separated) - added underscore
| default { hosts: null }
| get hosts
| where {|it| $it != null }
| each { |it| $it | split row ',' } # Split comma-separated hosts if any
| flatten
)
(
# Process /etc/hosts file
$hosts_file | where { |it| not ($it | str starts-with '#') } # Ignore comments
| where { |it| not ($it | str trim | is-empty) } # Ignore empty lines
| where { |it| not ($it | str contains '0.0.0.0') } # Ignore 0.0.0.0
| str replace --regex '#.*$' '' # Remove trailing comments
| parse --regex '^\s*\S+\s+(?<hosts>.+)' # Extract hosts part (after IP)
| default { hosts: null }
| get hosts
| where {|it| $it != null }
| split row ' ' # Split multiple hosts on the same line
)
]
| flatten # Combine all lists into a single stream
| where {|it| not ($it | is-empty) } # Remove empty entries
| sort | uniq # Sort and remove duplicates
}
# Base function for path/directory completion
def __fzf_generic_path_completion [ prefix: string # The text before the trigger
, fzf_opts_arg: list<string> # Extra options for fzf
, suffix: string # Suffix to add to selection (e.g. , "/")
] {
# --- Determine walker root and initial query from the prefix ---
mut walker_root = "."
mut initial_query = ""
if ($prefix | is-empty) {
# Case: "**"
$walker_root = "."
$initial_query = ""
} else if ($prefix | str contains (char separator)) {
# Case: "dir/subdir/partial**" or "dir/**"
$walker_root = $prefix | path dirname
$initial_query = $prefix | path basename
# Handle edge case where prefix ends with separator, e.g., "dir/"
if ($prefix | str ends-with (char separator)) {
# Remove trailing separator to get the intended directory
$walker_root = $prefix | str substring 0..-2
$initial_query = ""
}
# Ensure walker_root isn't empty if prefix was like "/file**"
# or if path dirname returned empty string for some reason (e.g. prefix="file/")
if ($walker_root | is-empty) {
if ($prefix | str starts-with (char separator)) {
$walker_root = (char separator)
} else if ($prefix | str ends-with (char separator)) {
$walker_root = $prefix | str substring 0..-2
} else { $walker_root = "." } # Fallback if dirname weirdly fails
}
} else {
# Case: "partial**" (no slashes)
$walker_root = "."
$initial_query = $prefix
}
# --- Prepare FZF options ---
let completion_type_opts = if $suffix == '/' {
$env.FZF_COMPLETION_DIR_OPTS? | default '' | split row ' ' | where {not ($in | is-empty)}
} else {
$env.FZF_COMPLETION_PATH_OPTS? | default '' | split row ' ' | where {not ($in | is-empty)}
}
let walker_type = if ($suffix == '/') {
"dir,follow"
} else {
"file,dir,follow,hidden"
}
# Expand tilde so fzf receives a valid absolute path as walker-root
let needs_tilde_rewrite = ($walker_root | str starts-with '~')
let walker_root_expanded = ($walker_root | path expand)
# Use the 'walker_root' calculated at the beginning
let fzf_all_opts = ["--scheme=path", "--walker", $walker_type, "--walker-root", $walker_root_expanded] | append $fzf_opts_arg
| append $completion_type_opts
# Call FZF run
let fzf_selection = ( __fzf_comprun "fzf-path-completion-walker" $initial_query $fzf_all_opts ) | str trim
# --- Return Result ---
if ($fzf_selection | is-not-empty) {
# Restore tilde prefix if the user originally typed ~/
let home = $nu.home-dir | path expand
let result = if $needs_tilde_rewrite {
$fzf_selection | lines | each {|line| $line | str replace $home '~' } | str join ' '
} else {
$fzf_selection | lines | str join ' '
}
[$result]
} else {
[]
}
}
# Specific path completion wrapper
def _fzf_path_completion [prefix: string] {
# Zsh args: base, lbuf, _fzf_compgen_path, "-m", "", " "
# Nu: prefix, empty command name (use find), ["-m"], "", " "
__fzf_generic_path_completion $prefix ["-m"] ""
}
# General completion helper for commands that feed a list to fzf
# This is called by ssh, kill, and user-defined completers.
def _fzf_complete [ query: string # The initial query string for fzf
, data_gen_closure: closure # Closure that generates candidates
, fzf_opts_arg: list<string> # Extra options for fzf (like -m, +m)
, --post_process_closure: closure # Closure to process the selected item (optional)
] {
# Generate candidates using the provided command
let candidates = try {
do $data_gen_closure
} catch {
# Capture the actual error object provided by the catch block
let actual_error = $in
# Print a more informative error message including the actual error details
print -e $"Error executing data_gen closure. Closure code: ($data_gen_closure). Actual error: ($actual_error)"
[]
}
# Run fzf and get selection
let fzf_selection = $candidates | to text
| __fzf_comprun "fzf-helper" $query $fzf_opts_arg
| str trim # Trim potential trailing newline from fzf
# Apply post-processing if closure provided and selection is not empty
let processed_selection = if ($fzf_selection | is-not-empty) and ($post_process_closure | is-not-empty) {
# Call the post-processing closure with the selection
try {
do $post_process_closure $fzf_selection
} catch {
print -e $"Error executing post_process closure: ($post_process_closure)"
$fzf_selection # Return original selection on error
}
} else {
$fzf_selection
}
if not ($processed_selection | is-empty) {
[($processed_selection | lines | str join ' ')]
} else {
[]
}
}
# SSH/Telnet completion
def _fzf_complete_ssh [ prefix: string
, input_line_before_trigger: string
] {
let words = ($input_line_before_trigger | split row ' ')
let word_count = $words | length
# Find the index of the word being completed (which is the prefix)
# If prefix is empty, completion happens after a space, index is word_count
# If prefix is not empty, it's the last word, index is word_count - 1
let completion_index = if ($prefix | is-empty) { $word_count } else { $word_count - 1 }
mut handled = false
mut completion_result = [] # List of completion strings to return
# Check for -i, -F, -E flags immediately preceding the cursor position
if $completion_index > 0 {
let prev_arg = ($words | get ($completion_index - 1))
if ($prev_arg in ['-i', '-F', '-E']) {
$handled = true
# Call path completion with the current prefix
$completion_result = (_fzf_path_completion $prefix)
}
}
# If not handled by path completion, do host completion
if not $handled {
let user_part = if ($prefix | str contains "@") { ($prefix | split row "@" | first) + "@" } else { "" }
# The part after '@' (or the whole prefix if no '@') is the initial query for fzf
let query = if ($prefix | str contains "@") { $prefix | split row "@" | last } else { $prefix }
let host_candidates_gen = {||
__fzf_list_hosts
| each {|host_item| $user_part + $host_item } # Prepend user@ if present in prefix
}
# Zsh options: +m -- ; Nu: pass ["+m"]
# Pass the host part of the prefix to _fzf_complete for the initial query
let selected_host = (_fzf_complete $query $host_candidates_gen ["+m"]) # Pass host_prefix here
if not ($selected_host | is-empty) {
$completion_result = $selected_host # _fzf_complete returns a list
}
}
$completion_result
}
# Kill completion post-processor (extracts PID)
def _fzf_complete_kill_post_get_pid [selected_line: string] {
# Assuming standard ps output where PID is the second column
$selected_line | lines | each { $in | from ssv --noheaders | get 0.column1 } | to text
}
# Kill completion to get process PID
def _fzf_complete_kill [query: string] {
let ps_gen_closure = {|| # Define ps generator as a closure
# Try standard ps, then busybox, then cygwin format approximation
# Use `^ps` to ensure external command execution
try {
^ps -eo user,pid,ppid,start,time,command | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
} catch {
try {
^ps -eo user,pid,ppid,time,args | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
} catch {
try {
^ps --everyone --full --windows | complete | if $in.exit_code == 0 { $in.stdout | lines } else { error make {msg: "ps failed"} }
} catch {
print -e "Error: ps command failed."
[] # Return empty list on failure
}
}
}
}
# Note: Complex Zsh FZF bindings for kill (click-header transformer) are omitted for simplicity.
# Users can set custom bindings via FZF_DEFAULT_OPTS if needed.
let kill_post_closure = {|selected_line| _fzf_complete_kill_post_get_pid $selected_line }
let fzf_opts = ["-m", "--header-lines=1", "--no-preview", "--wrap", "--color", "fg:dim,nth:regular"]
_fzf_complete $query $ps_gen_closure $fzf_opts --post_process_closure $kill_post_closure
}
# --- Main FZF External Completer ---
# This function is registered with Nushell's external completion system.
# It gets called when Tab is pressed.
let fzf_external_completer = {|spans|
let trigger: string = $env.FZF_COMPLETION_TRIGGER? | default '**'
if ($trigger | is-empty) { return null } # Cannot work with empty trigger
if (($spans | length ) == 0) { return null } # Nothing to complete
let last_span = $spans | last
if ($last_span | str ends-with $trigger) {
# --- Trigger Found ---
# Skip sudo to determine the actual command
let cmd_spans = if ($spans | first) == "sudo" { $spans | skip 1 } else { $spans }
let cmd_word = ($cmd_spans | first | default "")
# Calculate the prefix (part before the trigger in the last span)
let prefix = $last_span | str substring 0..(-1 * ($trigger | str length) - 1)
# Reconstruct the line content *before* the trigger for context
# This is an approximation based on spans
let line_without_trigger = $cmd_spans | take (($cmd_spans | length) - 1) | append $prefix | str join ' '
# --- Dispatch to Completer ---
mut completion_results = [] # Will hold the list of strings from the completer
# Check for user-defined completer in $env.FZF_COMPLETERS first.
# Users can define custom completers in their config.nu as a record of closures:
# $env.FZF_COMPLETERS = { git: {|prefix, spans| ... }, docker: {|prefix, spans| ... } }
# Each closure receives the prefix (text before the trigger) and the full
# command spans (e.g. ["pacman", "-S", "vim**"]), and should return either:
# - a list of candidate strings, or
# - a record { candidates: [...], opts: [...], post: {|sel| ...} } to pass
# custom fzf options and/or a post-processing closure.
# See shell/completion-examples.nu for examples.
let user_completers = ($env.FZF_COMPLETERS? | default {})
if ($cmd_word in $user_completers) {
let user_gen = ($user_completers | get $cmd_word)
let user_result = (do $user_gen $prefix $cmd_spans)
if ($user_result | describe | str starts-with 'record') {
let candidates = ($user_result | get candidates)
let fzf_opts = ($user_result | get opts? | default ["-m"])
let post = ($user_result | get post? | default null)
if ($post != null) {
$completion_results = (_fzf_complete $prefix {|| $candidates} $fzf_opts --post_process_closure $post)
} else {
$completion_results = (_fzf_complete $prefix {|| $candidates} $fzf_opts)
}
} else {
$completion_results = (_fzf_complete $prefix {|| $user_result} ["-m"])
}
} else {
match $cmd_word {
"ssh" | "scp" | "sftp" | "telnet" => { $completion_results = (_fzf_complete_ssh $prefix $line_without_trigger) }
"kill" => { $completion_results = (_fzf_complete_kill $prefix) }
_ if ($cmd_word in $env.FZF_COMPLETION_DIR_COMMANDS) => {
$completion_results = (__fzf_generic_path_completion $prefix [] "/")
}
_ => {
# Default to path completion if no specific command matches
$completion_results = (_fzf_path_completion $prefix)
}
}
}
# --- Return Results ---
# The _fzf_... functions return a list of completion strings.
# Nushell's completer expects the suggestions for the token being completed (prefix + trigger).
# The results from the helper functions should be the final desired strings.
# We don't need to manually add spaces; Nushell handles that.
$completion_results # Return the list directly
} else {
# --- Trigger Not Found ---
# Return null to let Nushell fall back to other completers (e.g., default file completion).
null
}
}
# --- WRAPPER AND REGISTRATION ---
# Guard against re-sourcing: wrapping the completer multiple times would
# nest wrappers and grow the call chain on every reload.
if ($env.__fzf_completer_registered? | default false) != true {
# Get the currently configured external completer, if any exists
let previous_external_completer = $env.config? | get completions? | get external? | get completer?
# Define the new wrapper completer
let fzf_wrapper_completer = {|spans|
# 1. Try the FZF completer logic first
let fzf_result = do $fzf_external_completer $spans
# 2. If FZF returned a result (a list, even an empty one), return it.
# `null` means FZF didn't handle it because the trigger wasn't present.
if $fzf_result != null {
$fzf_result
} else {
# 3. FZF didn't handle it, so call the previous completer (if it exists).
if $previous_external_completer != null {
do $previous_external_completer $spans
} else {
# 4. No previous completer, and FZF didn't handle it. Return null.
null
}
}
}
# Register the new wrapper completer
# This ensures external completions are enabled and sets our wrapper.
$env.config = $env.config | upsert completions {
external: {
enable: true
completer: $fzf_wrapper_completer
}
}
$env.__fzf_completer_registered = true
}
# vim: set sts=2 ts=2 sw=2 tw=120 et :
+166
View File
@@ -0,0 +1,166 @@
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ key-bindings.nu
#
# - $FZF_TMUX (default: 0)
# - $FZF_TMUX_OPTS
# - $FZF_TMUX_HEIGHT (default: 40%)
# - $FZF_CTRL_T_COMMAND (set to "" to disable)
# - $FZF_CTRL_T_OPTS
# - $FZF_CTRL_R_COMMAND (set to "" to disable)
# - $FZF_CTRL_R_OPTS
# - $FZF_ALT_C_COMMAND (set to "" to disable)
# - $FZF_ALT_C_OPTS
# Code provided by @igor-ramazanov
# Source: https://github.com/junegunn/fzf/issues/4122#issuecomment-2607368316
# Merge default options in the same order as bash/zsh:
# 1. --height, --min-height, --bind=ctrl-z:ignore, $prepend
# 2. $FZF_DEFAULT_OPTS_FILE contents
# 3. $FZF_DEFAULT_OPTS, $append
def __fzf_defaults [prepend: string, append: string]: nothing -> string {
let base = $"--height ($env.FZF_TMUX_HEIGHT? | default '40%') --min-height 20+ --bind=ctrl-z:ignore ($prepend)"
let opts_file = if ($env.FZF_DEFAULT_OPTS_FILE? | default '' | is-not-empty) {
try { open --raw ($env.FZF_DEFAULT_OPTS_FILE) | str trim } catch { '' }
} else {
''
}
let default_opts = $env.FZF_DEFAULT_OPTS? | default ''
$"($base) ($opts_file) ($default_opts) ($append)" | str trim
}
# Return the fzf command to use: fzf-tmux when inside tmux and
# FZF_TMUX is enabled or FZF_TMUX_OPTS is set, plain fzf otherwise.
def __fzfcmd []: nothing -> list<string> {
let in_tmux = ($env.TMUX_PANE? | default '' | into string | is-not-empty)
if $in_tmux {
let fzf_tmux = ($env.FZF_TMUX? | default 0 | into string)
let fzf_tmux_opts = ($env.FZF_TMUX_OPTS? | default '' | into string)
if ($fzf_tmux != '0') or ($fzf_tmux_opts | is-not-empty) {
let opts = if ($fzf_tmux_opts | is-not-empty) { $fzf_tmux_opts } else { $"-d($env.FZF_TMUX_HEIGHT? | default '40%')" }
return ['fzf-tmux' ...(($opts | split row ' ' | where { $in != '' })) '--']
}
}
['fzf']
}
export-env {
$env.FZF_CTRL_T_OPTS = $env.FZF_CTRL_T_OPTS? | default ""
$env.FZF_CTRL_R_OPTS = $env.FZF_CTRL_R_OPTS? | default ""
$env.FZF_ALT_C_OPTS = $env.FZF_ALT_C_OPTS? | default ""
}
# Directories
const alt_c = {
name: fzf_dirs
modifier: alt
keycode: char_c
mode: [emacs, vi_normal, vi_insert]
event: [
{
send: executehostcommand
cmd: "
let fzf_opts = (__fzf_defaults '--reverse --walker=dir,follow,hidden --scheme=path' $'($env.FZF_ALT_C_OPTS) +m');
let fzfcmd = (__fzfcmd);
let fzf_args = ($fzfcmd | skip 1);
let alt_c_cmd = ($env.FZF_ALT_C_COMMAND? | default null);
let result = if ($alt_c_cmd == null) or ($alt_c_cmd | is-empty) {
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args }
} else {
let fzf_cmd_str = ($fzfcmd | str join ' ');
let sh_cmd = [$alt_c_cmd '|' $fzf_cmd_str] | str join ' ';
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
};
if ($result | is-not-empty) { cd $result };
"
}
]
}
# History
const ctrl_r = {
name: fzf_history
modifier: control
keycode: char_r
mode: [emacs, vi_insert, vi_normal]
event: [
{
send: executehostcommand
cmd: "commandline edit --replace (
let fzf_opts = (__fzf_defaults '' $'--scheme=history --bind=ctrl-r:toggle-sort --wrap-sign \"\t↳ \" --highlight-line ($env.FZF_CTRL_R_OPTS) +m --read0');
let fzfcmd = (__fzfcmd);
let fzf_args = ($fzfcmd | skip 1);
# reverse | uniq: show most recent first, deduplicate keeping the latest.
# Nushell's `history` loads the full history as an in-memory table
# (bounded by $env.config.history.max_size, default 100,000), so
# reverse and uniq run on an already-materialized list. This is O(n)
# but acceptable for typical history sizes; unlike bash/zsh `fc -r`,
# there is no streaming primitive that would let fzf show the latest
# entries before the full list is consumed.
history
| get command
| reverse
| uniq
| str join (char -i 0)
| with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args --query (commandline) }
| decode utf-8
| str trim
)"
}
]
}
# Files
const ctrl_t = {
name: fzf_files
modifier: control
keycode: char_t
mode: [emacs, vi_normal, vi_insert]
event: [
{
send: executehostcommand
cmd: "
let fzf_opts = (__fzf_defaults '--reverse --walker=file,dir,follow,hidden --scheme=path' $'($env.FZF_CTRL_T_OPTS) -m');
let fzfcmd = (__fzfcmd);
let fzf_args = ($fzfcmd | skip 1);
let ctrl_t_cmd = ($env.FZF_CTRL_T_COMMAND? | default null);
let result = if ($ctrl_t_cmd == null) or ($ctrl_t_cmd | is-empty) {
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^($fzfcmd | first) ...$fzf_args }
} else {
let fzf_cmd_str = ($fzfcmd | str join ' ');
let sh_cmd = [$ctrl_t_cmd '|' $fzf_cmd_str] | str join ' ';
with-env { FZF_DEFAULT_OPTS: $fzf_opts, FZF_DEFAULT_OPTS_FILE: '' } { ^sh -c $sh_cmd }
};
let result = ($result | str replace --all (char newline) ' ' | str trim);
commandline edit --append $result;
commandline set-cursor --end
"
}
]
}
# Helper to check if a binding is enabled. A binding is disabled when
# the corresponding *_COMMAND variable is explicitly set to "".
# When not defined (null), the binding is enabled (using fzf's built-in walker).
def __fzf_binding_enabled [var_name: string]: nothing -> bool {
let val = ($env | get -o $var_name)
# null = not defined = enabled; "" = explicitly disabled
$val == null or ($val | into string | is-not-empty)
}
# Update the $env.config
export-env {
let fzf_names = ['fzf_files', 'fzf_dirs', 'fzf_history']
# Filter out any existing fzf bindings, then re-add the enabled ones.
# This allows re-sourcing to update bindings (e.g. after changing
# FZF_CTRL_T_COMMAND) without creating duplicates.
mut bindings = ($env.config.keybindings | where { |kb| $kb.name not-in $fzf_names })
if (__fzf_binding_enabled 'FZF_ALT_C_COMMAND') { $bindings = ($bindings | append $alt_c) }
if (__fzf_binding_enabled 'FZF_CTRL_R_COMMAND') { $bindings = ($bindings | append $ctrl_r) }
if (__fzf_binding_enabled 'FZF_CTRL_T_COMMAND') { $bindings = ($bindings | append $ctrl_t) }
$env.config.keybindings = $bindings
}
+7 -1
View File
@@ -110,8 +110,14 @@ fzf-cd-widget() {
zle redisplay
return 0
fi
# Use subshell expansion to get the absolute PWD of the target dir.
# This allows the recorded shell history to be reused even from a different
# working directory.
# If failed, fallback to the unexpanded path to surface the error to the user.
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
dir=$(builtin cd >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
zle push-line # Clear buffer. Auto-restored on next prompt.
BUFFER="builtin cd -- ${(q)dir:a}"
BUFFER="builtin cd -- ${(q)dir}"
zle accept-line
local ret=$?
unset dir # ensure this doesn't end up appearing in prompt expansion
+2 -2
View File
@@ -266,7 +266,7 @@ func charClassOf(char rune) charClass {
}
func bonusFor(prevClass charClass, class charClass) int16 {
if class > charNonWord {
if class >= charNonWord {
switch prevClass {
case charWhite:
// Word boundary after whitespace
@@ -443,7 +443,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
// we fall back to the greedy algorithm.
// Also, we should not allow a very long pattern to avoid 16-bit integer
// overflow in the score matrix. 1000 is a safe limit.
if slab != nil && N*M > cap(slab.I16) || M > 1000 {
if slab != nil && int64(N)*int64(M) > int64(cap(slab.I16)) || M > 1000 {
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
}
+9
View File
@@ -57,6 +57,15 @@ func TestFuzzyMatch(t *testing.T) {
scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3)
assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13,
scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter))
// Non-word character at start of input is treated as a strong boundary
assertMatch(t, fn, false, forward, ".vimrc", ".vimrc", 0, 6,
scoreMatch*6+int(bonusBoundaryWhite)*(bonusFirstCharMultiplier+5))
// Non-word character right after a delimiter inherits the delimiter boundary
assertMatch(t, fn, false, forward, "/.vimrc", ".vimrc", 1, 7,
scoreMatch*6+int(bonusBoundaryDelimiter)*(bonusFirstCharMultiplier+5))
// Non-word character in the middle of a word stays at bonusNonWord
assertMatch(t, fn, false, forward, "a.vimrc", ".vimrc", 1, 7,
scoreMatch*6+bonusBoundary*(bonusFirstCharMultiplier+5))
assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10,
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10,
+5 -1
View File
@@ -4,6 +4,7 @@ package fzf
import (
"fmt"
"maps"
"math"
"os"
"sync"
"time"
@@ -241,6 +242,9 @@ func Run(opts *Options) (int, error) {
denylist = make(map[int32]struct{})
denyMutex.Unlock()
}
if opts.HeaderLines > math.MaxInt32 {
opts.HeaderLines = math.MaxInt32
}
headerLines := int32(opts.HeaderLines)
headerUpdated := false
patternBuilder := func(runes []rune) *Pattern {
@@ -467,7 +471,7 @@ func Run(opts *Options) (int, error) {
terminal.UpdateCount(max(0, total-int(headerLines)), !reading, value.(*string))
if headerLines > 0 && !headerUpdated {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
headerUpdated = int32(total) >= headerLines
headerUpdated = total >= int(headerLines)
}
if heightUnknown && !deferred {
determine(!reading)
+47 -10
View File
@@ -4,12 +4,12 @@ import (
"errors"
"fmt"
"maps"
"math"
"os"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui"
@@ -159,7 +159,7 @@ Usage: fzf [options]
PREVIEW WINDOW
--preview=COMMAND Command to preview highlighted line ({})
--preview-window=OPT Preview window layout (default: right:50%)
[up|down|left|right][,SIZE[%]]
[up|down|left|right|next][,SIZE[%]]
[,[no]wrap[-word]][,[no]cycle][,[no]follow][,[no]info]
[,[no]hidden][,border-STYLE]
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
@@ -232,6 +232,7 @@ Usage: fzf [options]
--bash Print script to set up Bash shell integration
--zsh Print script to set up Zsh shell integration
--fish Print script to set up Fish shell integration
--nushell Print script to set up Nushell integration
HELP
--version Display version information and exit
@@ -331,6 +332,7 @@ const (
posLeft
posRight
posCenter
posNext // adjacent to the input section, on the list side
)
type tmuxOptions struct {
@@ -390,7 +392,7 @@ func (o *previewOpts) Toggle() {
o.hidden = !o.hidden
}
func (o *previewOpts) Border() tui.BorderShape {
func (o *previewOpts) Border(layout layoutType) tui.BorderShape {
shape := o.border
if shape == tui.BorderLine {
switch o.position {
@@ -402,6 +404,12 @@ func (o *previewOpts) Border() tui.BorderShape {
shape = tui.BorderRight
case posRight:
shape = tui.BorderLeft
case posNext:
if layout == layoutReverse {
shape = tui.BorderBottom
} else {
shape = tui.BorderTop
}
}
}
return shape
@@ -511,7 +519,7 @@ func parseLabelPosition(opts *labelOpts, arg string) error {
}
func (a previewOpts) aboveOrBelow() bool {
return a.size.size > 0 && (a.position == posUp || a.position == posDown)
return a.size.size > 0 && (a.position == posUp || a.position == posDown || a.position == posNext)
}
type previewOptsCompare int
@@ -578,6 +586,7 @@ type Options struct {
Bash bool
Zsh bool
Fish bool
Nushell bool
Man bool
Fuzzy bool
FuzzyAlgo algo.Algo
@@ -725,6 +734,7 @@ func defaultOptions() *Options {
Bash: false,
Zsh: false,
Fish: false,
Nushell: false,
Man: false,
Fuzzy: true,
FuzzyAlgo: algo.FuzzyMatchV2,
@@ -1257,7 +1267,14 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
add(tui.F12)
default:
runes := []rune(key)
if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
if strings.HasPrefix(lkey, "every(") && strings.HasSuffix(lkey, ")") {
evt, err := parseEveryEvent(key[6 : len(key)-1])
if err != nil {
return nil, list, err
}
chords[evt] = key
list = append(list, evt)
} else if len(key) == 10 && strings.HasPrefix(lkey, "ctrl-alt-") && isAlphabet(lkey[9]) {
r := rune(lkey[9])
evt := tui.CtrlAltKey(r)
if r == 'h' && !util.IsWindows() {
@@ -1299,6 +1316,21 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
return chords, list, nil
}
func parseEveryEvent(arg string) (tui.Event, error) {
secs, err := strconv.ParseFloat(strings.TrimSpace(arg), 64)
if err != nil || math.IsNaN(secs) || math.IsInf(secs, 0) || secs <= 0 {
return tui.Event{}, errors.New("every() requires a positive number of seconds")
}
if secs < 0.01 {
secs = 0.01
}
ms := math.Round(secs * 1000)
if ms > math.MaxInt32 {
return tui.Event{}, errors.New("every() interval is too large")
}
return tui.Event{Type: tui.Every, Char: rune(int32(ms))}, nil
}
func parseScheme(str string) (string, []criterion, error) {
str = strings.ToLower(str)
switch str {
@@ -1701,10 +1733,10 @@ Loop:
return masked
}
func parseSingleActionList(str string) ([]*action, error) {
func parseSingleActionList(str string, putAllowed bool) ([]*action, error) {
// We prepend a colon to satisfy argActionRegexp and remove it later
masked := maskActionContents(":" + str)[1:]
return parseActionList(masked, str, []*action{}, false)
return parseActionList(masked, str, []*action{}, putAllowed)
}
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) {
@@ -2010,8 +2042,7 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) error {
}
key = firstKey(keys)
}
putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char)
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed)
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], key.Printable())
if err != nil {
return err
}
@@ -2329,6 +2360,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.position = posLeft
case "right":
opts.position = posRight
case "next":
opts.position = posNext
case "rounded", "border", "border-rounded":
opts.border = tui.BorderRounded
case "border-line":
@@ -2521,6 +2554,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Bash = false
opts.Zsh = false
opts.Fish = false
opts.Nushell = false
opts.Help = false
opts.Version = false
opts.Man = false
@@ -2633,6 +2667,9 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--fish":
clearExitingOpts()
opts.Fish = true
case "--nushell":
clearExitingOpts()
opts.Nushell = true
case "-h", "--help":
clearExitingOpts()
opts.Help = true
@@ -3135,7 +3172,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--no-preview":
opts.Preview.command = ""
case "--preview-window":
str, err := nextString("preview window layout required: [up|down|left|right][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]")
str, err := nextString("preview window layout required: [up|down|left|right|next][,SIZE[%]][,border-STYLE][,wrap][,cycle][,hidden][,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES][,default]")
if err != nil {
return err
}
+35 -2
View File
@@ -299,6 +299,39 @@ func TestBind(t *testing.T) {
check(tui.F1.AsEvent(), "", actAbort)
}
func TestParseEveryEvent(t *testing.T) {
pairs, _, err := parseKeyChords("every(2),every(0.5)", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pairs) != 2 {
t.Errorf("expected 2 distinct every events, got %d", len(pairs))
}
if pairs[(tui.Event{Type: tui.Every, Char: 2000})] != "every(2)" {
t.Errorf("every(2) not registered")
}
if pairs[(tui.Event{Type: tui.Every, Char: 500})] != "every(0.5)" {
t.Errorf("every(0.5) not registered")
}
// Floor at 0.01s -> 10ms
pairs, _, err = parseKeyChords("every(0.001)", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pairs[(tui.Event{Type: tui.Every, Char: 10})] != "every(0.001)" {
t.Errorf("every(0.001) should floor to 10ms")
}
// Reject zero, negatives, and overflow (>= 2^31 ms = ~24.85 days)
for _, bad := range []string{"every(0)", "every(-1)", "every(abc)", "every()", "every(2147484)"} {
if _, _, err := parseKeyChords(bad, ""); err == nil {
t.Errorf("%s should be rejected", bad)
}
}
}
func TestColorSpec(t *testing.T) {
var base *tui.ColorTheme
theme := tui.Dark256
@@ -539,7 +572,7 @@ func TestValidateSign(t *testing.T) {
}
func TestParseSingleActionList(t *testing.T) {
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down")
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", false)
if len(actions) != 4 {
t.Errorf("Invalid number of actions parsed:%d", len(actions))
}
@@ -555,7 +588,7 @@ func TestParseSingleActionList(t *testing.T) {
}
func TestParseSingleActionListError(t *testing.T) {
_, err := parseSingleActionList("change-query(foobar)baz")
_, err := parseSingleActionList("change-query(foobar)baz", false)
if err == nil {
t.Errorf("Failed to detect error")
}
+4 -1
View File
@@ -198,7 +198,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
start := 0
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
if !theme.Colored {
return tui.NewColorPair(-1, -1, ansi.color.attr).MergeAttr(base)
// Ignore ANSI colors but keep the attributes. Retain the base
// colors (e.g. an overridden input-bg or list-bg) instead of
// resetting to the terminal default.
return tui.NewColorPair(base.Fg(), base.Bg(), ansi.color.attr).MergeAttr(base)
}
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
if base.ShouldStripColors() {
+5 -4
View File
@@ -153,7 +153,7 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
func (server *httpServer) handleHttpRequest(conn net.Conn) string {
contentLength := 0
apiKey := ""
body := ""
var bodyBuilder strings.Builder
answer := func(code string, message string) string {
message += "\n"
return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message)
@@ -175,7 +175,7 @@ func (server *httpServer) handleHttpRequest(conn net.Conn) string {
token := data[:found+len(crlf)]
return len(token), token, nil
}
if atEOF || len(body)+len(data) >= contentLength {
if atEOF || bodyBuilder.Len()+len(data) >= contentLength {
return 0, data, bufio.ErrFinalToken
}
return 0, nil, nil
@@ -218,7 +218,7 @@ Loop:
}
}
case 2: // Request body
body += text
bodyBuilder.WriteString(text)
}
}
@@ -234,12 +234,13 @@ Loop:
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
}
body := bodyBuilder.String()
if len(body) < contentLength {
return bad("incomplete request")
}
body = body[:contentLength]
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"))
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"), false)
if err != nil {
return bad(err.Error())
}
+288 -126
View File
@@ -436,6 +436,7 @@ type Terminal struct {
bgSemaphores map[action]chan struct{}
keyChan chan tui.Event
eventChan chan tui.Event
timerChan chan tui.Event
slab *util.Slab
theme *tui.ColorTheme
tui tui.Renderer
@@ -456,6 +457,7 @@ type Terminal struct {
proxyScript string
numLinesCache map[int32]numLinesCacheValue
raw bool
lastActivity time.Time
}
type numLinesCacheValue struct {
@@ -986,7 +988,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
// Minimum height required to render fzf excluding margin and padding
effectiveMinHeight := minHeight
if previewBox != nil && opts.Preview.aboveOrBelow() {
effectiveMinHeight += 1 + borderLines(opts.Preview.Border())
effectiveMinHeight += 1 + borderLines(opts.Preview.Border(opts.Layout))
}
if opts.noSeparatorLine() {
effectiveMinHeight--
@@ -1151,6 +1153,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
bgSemaphores: make(map[action]chan struct{}),
keyChan: make(chan tui.Event),
eventChan: make(chan tui.Event, 6), // start | (load + result + zero|one) | (focus) | (resize)
timerChan: make(chan tui.Event), // unbuffered: every() ticks coalesce when main loop is busy
tui: renderer,
ttyDefault: opts.TtyDefault,
ttyin: ttyin,
@@ -1158,6 +1161,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
executing: util.NewAtomicBool(false),
lastAction: actStart,
lastFocus: minItem.Index(),
lastActivity: time.Now(),
numLinesCache: make(map[int32]numLinesCacheValue)}
if opts.AcceptNth != nil {
t.acceptNth = opts.AcceptNth(t.delimiter)
@@ -1385,6 +1389,9 @@ func (t *Terminal) environImpl(forPreview bool) []string {
env = append(env, "FZF_QUERY="+string(t.input))
env = append(env, "FZF_ACTION="+t.lastAction.Name())
env = append(env, "FZF_KEY="+t.lastKey)
idleMs := time.Since(t.lastActivity).Milliseconds()
env = append(env, fmt.Sprintf("FZF_IDLE_TIME=%d", idleMs/1000))
env = append(env, fmt.Sprintf("FZF_IDLE_TIME_MS=%d", idleMs))
env = append(env, "FZF_PROMPT="+string(t.promptString))
env = append(env, "FZF_GHOST="+string(t.ghost))
env = append(env, "FZF_POINTER="+string(t.pointer))
@@ -1431,6 +1438,12 @@ func (t *Terminal) environImpl(forPreview bool) []string {
env = append(env, fmt.Sprintf("FZF_LINES=%d", t.areaLines))
env = append(env, fmt.Sprintf("FZF_COLUMNS=%d", t.areaColumns))
env = append(env, fmt.Sprintf("FZF_POS=%d", min(t.merger.Length(), t.cy+1)))
if item := t.currentItem(); item != nil {
// Skip if the value contains a NUL byte; exec(2) would reject the env.
if s := item.AsString(t.ansi); !strings.ContainsRune(s, 0) {
env = append(env, "FZF_CURRENT_ITEM="+s)
}
}
env = append(env, fmt.Sprintf("FZF_CLICK_HEADER_LINE=%d", t.clickHeaderLine))
env = append(env, fmt.Sprintf("FZF_CLICK_HEADER_COLUMN=%d", t.clickHeaderColumn))
env = append(env, fmt.Sprintf("FZF_CLICK_FOOTER_LINE=%d", t.clickFooterLine))
@@ -1645,12 +1658,11 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) {
wrap := t.wrap
t.wrap = false
t.withWindow(t.inputWindow, func() {
line := t.promptLine()
preTask := func(markerClass) int {
return 1
}
t.printHighlighted(
Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false, false, line, line, true, preTask, nil, 0)
Result{item: item}, tui.ColPrompt, tui.ColPrompt, false, false, false, 0, 0, true, preTask, nil, 0)
})
t.wrap = wrap
}
@@ -2098,12 +2110,13 @@ func calculateSize(base int, size sizeSpec, occupied int, minSize int) int {
}
func (t *Terminal) minPreviewSize(opts *previewOpts) (int, int) {
minPreviewWidth := 1 + borderColumns(opts.Border(), t.borderWidth)
minPreviewHeight := 1 + borderLines(opts.Border())
border := opts.Border(t.layout)
minPreviewWidth := 1 + borderColumns(border, t.borderWidth)
minPreviewHeight := 1 + borderLines(border)
switch opts.position {
case posLeft, posRight:
if len(t.scrollbar) > 0 && !opts.Border().HasRight() {
if len(t.scrollbar) > 0 && !border.HasRight() {
// Need a column to show scrollbar
minPreviewWidth++
}
@@ -2188,7 +2201,7 @@ func (t *Terminal) adjustMarginAndPadding() (int, int, [4]int, [4]int) {
if t.needPreviewWindow() {
minPreviewWidth, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts)
switch t.activePreviewOpts.position {
case posUp, posDown:
case posUp, posDown, posNext:
minAreaHeight += minPreviewHeight
minAreaWidth = max(minPreviewWidth, minAreaWidth)
case posLeft, posRight:
@@ -2213,7 +2226,7 @@ func (t *Terminal) hasHeaderWindow() bool {
if t.hasHeaderLinesWindow() {
return len(t.header0) > 0
}
if t.headerBorderShape.Visible() {
if t.headerBorderShape.Visible() || t.headerFirst {
return len(t.header0)+t.headerLines > 0
}
return t.inputBorderShape.Visible()
@@ -2251,6 +2264,9 @@ func (t *Terminal) determineHeaderLinesShape() (bool, tui.BorderShape) {
// Use header window instead
if len(t.header0) == 0 {
if t.headerFirst && shape == tui.BorderPhantom {
return true, shape
}
return false, t.headerBorderShape
}
@@ -2443,7 +2459,56 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
hasHeaderWindow := t.hasHeaderWindow()
hasFooterWindow := len(t.footer) > 0
hasHeaderLinesWindow, headerLinesShape := t.determineHeaderLinesShape()
hasInputWindow := !t.inputless && (t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow)
// computePreviewSize returns the size resizePreviewWindows will compute
// for opts and the minimum size for that axis: height/minPreviewHeight
// for vertical positions, width/minPreviewWidth for horizontal.
computePreviewSize := func(opts *previewOpts) (int, int) {
minPreviewWidth, minPreviewHeight := t.minPreviewSize(opts)
switch opts.position {
case posUp, posDown, posNext:
minWindowHeight := minHeight
if t.inputless {
minWindowHeight--
}
if t.noSeparatorLine() {
minWindowHeight--
}
return calculateSize(height, opts.size, minWindowHeight, minPreviewHeight), minPreviewHeight
case posLeft, posRight:
minListWidth := minWidth
if t.listBorderShape.HasLeft() {
minListWidth += 2
}
if t.listBorderShape.HasRight() {
minListWidth++
}
return calculateSize(width, opts.size, minListWidth, minPreviewWidth), minPreviewWidth
}
return 0, 0
}
// Walk the threshold chain to determine the previewOpts that
// resizePreviewWindows will actually settle on. We need this here
// because hasInputWindow and the availableLines adjustment below run
// before resizePreviewWindows, and t.activePreviewOpts still holds the
// previous frame's resolution.
effectivePreviewOpts := &t.previewOpts
if t.needPreviewWindow() {
opts := &t.previewOpts
for {
if opts.size.size == 0 || opts.threshold <= 0 || opts.alternative == nil {
break
}
if actual, _ := computePreviewSize(opts); actual >= opts.threshold {
break
}
opts = opts.alternative
if opts.hidden {
break
}
}
effectivePreviewOpts = opts
}
hasInputWindow := !t.inputless && (t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow || effectivePreviewOpts.position == posNext)
inputWindowHeight := 2
if t.noSeparatorLine() {
inputWindowHeight--
@@ -2463,9 +2528,9 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
// FIXME: Needed?
if t.needPreviewWindow() {
_, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts)
switch t.activePreviewOpts.position {
case posUp, posDown:
switch effectivePreviewOpts.position {
case posUp, posDown, posNext:
_, minPreviewHeight := t.minPreviewSize(effectivePreviewOpts)
availableLines -= minPreviewHeight
}
}
@@ -2616,6 +2681,51 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
// Set up preview window
noBorder := tui.MakeBorderStyle(tui.BorderNone, t.unicode)
cleanLeft := []int{}
// previewNextSize is pheight when the preview is placed adjacent to
// the input (position == "next"); inputBorderTop() reads it through the
// closure to push input past the preview band.
previewNextSize := 0
// inputBorderTop returns the canvas Y at which the input border window
// should be placed. It depends on wborder/t.window (set by the preview
// case), the layout, --header-first, and previewNextSize (set when
// posNext is active). Used both for placing the preview adjacent to
// input and later for placing the input window itself.
inputBorderTop := func() int {
w := t.wborder
if w == nil {
w = t.window
}
hasSeparateHeader := hasHeaderWindow && t.headerBorderShape != tui.BorderInline
hasSeparateHeaderLines := hasHeaderLinesWindow && headerLinesShape != tui.BorderInline
if (hasSeparateHeader || hasSeparateHeaderLines) && t.headerFirst {
switch t.layout {
case layoutDefault:
btop := w.Top() + w.Height() + previewNextSize
if hasHeaderWindow && hasHeaderLinesWindow {
btop += headerLinesHeight
}
return btop
case layoutReverse:
btop := w.Top() - inputBorderHeight - previewNextSize
if hasHeaderWindow && hasHeaderLinesWindow {
btop -= headerLinesHeight
}
return btop
case layoutReverseList:
return w.Top() + w.Height() + previewNextSize
}
}
switch t.layout {
case layoutDefault:
return w.Top() + w.Height() + headerBorderHeight + headerLinesHeight + previewNextSize
case layoutReverse:
return w.Top() - shrink + footerBorderHeight - previewNextSize
case layoutReverseList:
return w.Top() + w.Height() + headerBorderHeight + previewNextSize
}
return 0
}
if forcePreview || t.needPreviewWindow() {
var resizePreviewWindows func(previewOpts *previewOpts)
resizePreviewWindows = func(previewOpts *previewOpts) {
@@ -2627,7 +2737,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
createPreviewWindow := func(y int, x int, w int, h int) {
pwidth := w
pheight := h
shape := previewOpts.Border()
shape := previewOpts.Border(t.layout)
previewBorder := tui.MakeBorderStyle(shape, t.unicode)
t.pborder = t.tui.NewWindow(y, x, w, h, tui.WindowPreview, previewBorder, false)
pwidth -= borderColumns(shape, bw)
@@ -2648,17 +2758,12 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.pwindow.Erase()
}
}
minPreviewWidth, minPreviewHeight := t.minPreviewSize(previewOpts)
switch previewOpts.position {
case posUp, posDown:
minWindowHeight := minHeight
if t.inputless {
minWindowHeight--
}
if t.noSeparatorLine() {
minWindowHeight--
}
pheight := calculateSize(height, previewOpts.size, minWindowHeight, minPreviewHeight)
// Shared boilerplate for vertical positions (posUp/posDown/posNext):
// compute pheight, apply the threshold alternative, honor hidden,
// and update listStickToRight. Returns (pheight, true) when the
// caller should return early.
computeVerticalSize := func() (int, bool) {
pheight, minPreviewHeight := computePreviewSize(previewOpts)
if hasThreshold && pheight < previewOpts.threshold {
t.activePreviewOpts = previewOpts.alternative
if forcePreview {
@@ -2667,22 +2772,27 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
if !previewOpts.alternative.hidden {
resizePreviewWindows(previewOpts.alternative)
}
return
return 0, true
}
if forcePreview {
previewOpts.hidden = false
}
if previewOpts.hidden {
return
return 0, true
}
listStickToRight = listStickToRight && !previewOpts.Border().HasRight()
listStickToRight = listStickToRight && !previewOpts.Border(t.layout).HasRight()
if listStickToRight {
innerWidth++
width++
}
pheight = util.Constrain(pheight, minPreviewHeight, availableLines)
return util.Constrain(pheight, minPreviewHeight, availableLines), false
}
switch previewOpts.position {
case posUp, posDown:
pheight, done := computeVerticalSize()
if done {
return
}
if previewOpts.position == posUp {
innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight)
@@ -2695,15 +2805,32 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
createPreviewWindow(marginInt[0]+height-pheight, marginInt[3], width, pheight)
}
case posNext:
pheight, done := computeVerticalSize()
if done {
return
}
previewNextSize = pheight
if t.layout == layoutReverse {
// [(header)][input][preview]([header])[list]: reuse posUp's
// wborder/list math; input is pulled back up by pheight in
// its positioning. Preview sits directly below input.
innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight)
t.window = t.tui.NewWindow(
innerMarginInt[0]+pheight+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
createPreviewWindow(inputBorderTop()+inputBorderHeight, marginInt[3], width, pheight)
} else {
// [list]([header])[preview][input][(header)]: reuse posDown's
// wborder/list math; input is pushed down by pheight in its
// positioning. Preview sits directly above input.
innerBorderFn(marginInt[0], marginInt[3], width, height-pheight)
t.window = t.tui.NewWindow(
innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3], innerWidth, innerHeight-pheight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
createPreviewWindow(inputBorderTop()-pheight, marginInt[3], width, pheight)
}
case posLeft, posRight:
minListWidth := minWidth
if t.listBorderShape.HasLeft() {
minListWidth += 2
}
if t.listBorderShape.HasRight() {
minListWidth++
}
pwidth := calculateSize(width, previewOpts.size, minListWidth, minPreviewWidth)
pwidth, _ := computePreviewSize(previewOpts)
if hasThreshold && pwidth < previewOpts.threshold {
t.activePreviewOpts = previewOpts.alternative
if forcePreview {
@@ -2734,19 +2861,13 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3]+pwidth+m, innerWidth-pwidth-m, innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
// Clear characters on the margin
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1
if !hasListBorder {
for y := 0; y < innerHeight; y++ {
t.window.Move(y, -1)
t.window.Print(" ")
}
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1,border-none --footer-border --footer f --header h --header-border
if !previewOpts.Border(t.layout).HasRight() {
cleanLeft = append(cleanLeft, -2)
}
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1,border-none
if !previewOpts.Border().HasRight() {
for y := 0; y < innerHeight; y++ {
t.window.Move(y, -2)
t.window.Print(" ")
}
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1 --footer-border --footer f --header h --header-border
if !hasListBorder {
cleanLeft = append(cleanLeft, -1)
}
innerBorderFn(marginInt[0], marginInt[3]+pwidth, width-pwidth, height)
@@ -2756,7 +2877,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
// fzf --preview 'seq 500' --preview-window border-left --border
// fzf --preview 'seq 500' --preview-window border-left --border --list-border
// fzf --preview 'seq 500' --preview-window border-left --border --input-border
listStickToRight = t.borderShape.HasRight() && !previewOpts.Border().HasRight()
listStickToRight = t.borderShape.HasRight() && !previewOpts.Border(t.layout).HasRight()
if listStickToRight {
innerWidth++
width++
@@ -2845,37 +2966,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
if hasInputWindow {
var btop int
// Inline sections live inside the list frame, so they don't participate
// in --header-first repositioning; only non-inline sections do.
hasNonInlineHeader := hasHeaderWindow && t.headerBorderShape != tui.BorderInline
hasNonInlineHeaderLines := hasHeaderLinesWindow && headerLinesShape != tui.BorderInline
if (hasNonInlineHeader || hasNonInlineHeaderLines) && t.headerFirst {
switch t.layout {
case layoutDefault:
btop = w.Top() + w.Height()
// If both headers are present, the header lines are displayed with the list
if hasHeaderWindow && hasHeaderLinesWindow {
btop += headerLinesHeight
}
case layoutReverse:
btop = w.Top() - inputBorderHeight
if hasHeaderWindow && hasHeaderLinesWindow {
btop -= headerLinesHeight
}
case layoutReverseList:
btop = w.Top() + w.Height()
}
} else {
switch t.layout {
case layoutDefault:
btop = w.Top() + w.Height() + headerBorderHeight + headerLinesHeight
case layoutReverse:
btop = w.Top() - shrink + footerBorderHeight
case layoutReverseList:
btop = w.Top() + w.Height() + headerBorderHeight
}
}
btop := inputBorderTop()
shift := 0
if !t.inputBorderShape.HasLeft() && t.listBorderShape.HasLeft() {
shift += t.borderWidth + 1
@@ -2899,11 +2990,11 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
var btop int
if hasInputWindow && t.headerFirst {
if t.layout == layoutReverse {
btop = w.Top() - shrink + footerBorderHeight
btop = w.Top() - shrink + footerBorderHeight - previewNextSize
} else if t.layout == layoutReverseList {
btop = w.Top() + w.Height() + inputBorderHeight
btop = w.Top() + w.Height() + inputBorderHeight + previewNextSize
} else {
btop = w.Top() + w.Height() + inputBorderHeight + headerLinesHeight
btop = w.Top() + w.Height() + inputBorderHeight + headerLinesHeight + previewNextSize
}
} else {
if t.layout == layoutReverse {
@@ -2934,7 +3025,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
if headerFirst {
if t.layout == layoutDefault {
btop = w.Top() + w.Height() + inputBorderHeight
btop = w.Top() + w.Height() + inputBorderHeight + previewNextSize
} else if t.layout == layoutReverse {
btop = w.Top() - headerLinesHeight - inputBorderHeight
} else {
@@ -2973,6 +3064,19 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.footerWindow = createInnerWindow(t.footerBorder, t.footerBorderShape, tui.WindowFooter, 0)
}
for _, w := range []tui.Window{t.window, t.headerBorder, t.headerLinesBorder, t.footerBorder, t.inputBorder} {
if w == nil {
continue
}
for y := 0; y < w.Height(); y++ {
for _, left := range cleanLeft {
w.Move(y, left)
w.Print(" ")
}
}
}
// When the list label lands on an edge owned by an inline section, swap its bg
// so the label reads as part of that section's frame. Fg stays at list-label.
listLabel, listLabelLen := t.listLabel, t.listLabelLen
@@ -2989,7 +3093,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
}
t.printLabel(t.wborder, listLabel, t.listLabelOpts, listLabelLen, t.listBorderShape, false)
t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, false)
t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(), false)
t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(t.layout), false)
t.printLabel(t.inputBorder, t.inputLabel, t.inputLabelOpts, t.inputLabelLen, t.inputBorderShape, false)
t.printLabel(t.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, false)
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, false)
@@ -3098,23 +3202,6 @@ func (t *Terminal) updatePromptOffset() ([]rune, []rune) {
return before, after
}
func (t *Terminal) promptLine() int {
if t.inputWindow != nil {
return 0
}
if t.headerFirst {
max := t.window.Height() - 1
if max <= 0 { // Extremely short terminal
return 0
}
if !t.noSeparatorLine() {
max--
}
return min(t.visibleHeaderLinesInList(), max)
}
return 0
}
func (t *Terminal) placeCursor() {
if t.inputless {
return
@@ -3130,7 +3217,7 @@ func (t *Terminal) placeCursor() {
return
}
x = min(x, t.window.Width()-1)
t.move(t.promptLine(), x, false)
t.move(0, x, false)
}
func (t *Terminal) printPrompt() {
@@ -3184,7 +3271,7 @@ func (t *Terminal) printInfoImpl() {
return
}
pos := 0
line := t.promptLine()
line := 0
maxHeight := t.window.Height()
move := func(y int, x int, clear bool) bool {
if y < 0 || y >= maxHeight {
@@ -3519,12 +3606,6 @@ func (t *Terminal) headerIndentImpl(base int, borderShape tui.BorderShape) int {
func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShape, lines1 []string, lines2 []Item) {
max := t.window.Height()
if !t.inputless && t.inputWindow == nil && window == nil && t.headerFirst {
max--
if !t.noSeparatorLine() {
max--
}
}
var state *ansiState
needReverse := false
switch t.layout {
@@ -4868,11 +4949,11 @@ func (t *Terminal) renderPreviewScrollbar(yoff int, barLength int, barStart int)
t.previewer.xw = xw
}
xshift := -1 - t.borderWidth
if !t.activePreviewOpts.Border().HasRight() {
if !t.activePreviewOpts.Border(t.layout).HasRight() {
xshift = -1
}
yshift := 1
if !t.activePreviewOpts.Border().HasTop() {
if !t.activePreviewOpts.Border(t.layout).HasTop() {
yshift = 0
}
for i := yoff; i < height; i++ {
@@ -5807,6 +5888,35 @@ func (t *Terminal) addClickFooterWord(env []string) []string {
return env
}
// startTimers spawns a goroutine per every() bind event. Forwarding ticks
// onto the unbuffered timerChan lets the ticker drop overlapping ticks
// while the main loop is busy.
func (t *Terminal) startTimers(ctx context.Context) {
for evt := range t.keymap {
switch evt.Type {
case tui.Every:
d := time.Duration(evt.Char) * time.Millisecond
evt := evt
go func() {
ticker := time.NewTicker(d)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
select {
case <-ctx.Done():
return
case t.timerChan <- evt:
}
}
}
}()
}
}
}
// Loop is called to start Terminal I/O
func (t *Terminal) Loop() error {
// prof := profile.Start(profile.ProfilePath("/tmp/"))
@@ -5820,13 +5930,13 @@ func (t *Terminal) Loop() error {
if t.activePreviewOpts.aboveOrBelow() {
if t.activePreviewOpts.size.percent {
newContentHeight := int(float64(contentHeight) * 100. / (100. - t.activePreviewOpts.size.size))
contentHeight = max(contentHeight+1+borderLines(t.activePreviewOpts.Border()), newContentHeight)
contentHeight = max(contentHeight+1+borderLines(t.activePreviewOpts.Border(t.layout)), newContentHeight)
} else {
contentHeight += int(t.activePreviewOpts.size.size) + borderLines(t.activePreviewOpts.Border())
contentHeight += int(t.activePreviewOpts.size.size) + borderLines(t.activePreviewOpts.Border(t.layout))
}
} else {
// Minimum height if preview window can appear
contentHeight = max(contentHeight, 1+borderLines(t.activePreviewOpts.Border()))
contentHeight = max(contentHeight, 1+borderLines(t.activePreviewOpts.Border(t.layout)))
}
}
return min(termHeight, contentHeight+pad)
@@ -6208,7 +6318,7 @@ func (t *Terminal) Loop() error {
case reqRedrawBorderLabel:
t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, true)
case reqRedrawPreviewLabel:
t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(), true)
t.printLabel(t.pborder, t.previewLabel, t.previewLabelOpts, t.previewLabelLen, t.activePreviewOpts.Border(t.layout), true)
case reqReinit, reqResize, reqFullRedraw, reqRedraw:
if req == reqReinit {
t.tui.Resume(t.fullscreen, true)
@@ -6314,6 +6424,7 @@ func (t *Terminal) Loop() error {
}
}
}()
t.startTimers(ctx)
previewDraggingPos := -1
barDragging := false
pbarDragging := false
@@ -6329,12 +6440,18 @@ func (t *Terminal) Loop() error {
needBarrier = false
}
// These variables are defined outside the loop to be accessible from closures
// These variables are defined outside the loop to be accessible from closures.
// In particular, async bg-transform callbacks run in a later iteration than
// the one that scheduled them, but still need to mutate state that the
// loop-end reload/event dispatch reads.
events := []util.EventType{}
changed := false
var newNth *[]Range
var newWithNth *withNthSpec
var newHeaderLines *int
var newCommand *commandSpec
var reloadSync bool
var denylist []int32
req := func(evts ...util.EventType) {
for _, event := range evts {
events = append(events, event)
@@ -6346,16 +6463,16 @@ func (t *Terminal) Loop() error {
// The main event loop
for loopIndex := int64(0); looping; loopIndex++ {
var newCommand *commandSpec
var reloadSync bool
events = []util.EventType{}
changed = false
newNth = nil
newWithNth = nil
newHeaderLines = nil
newCommand = nil
reloadSync = false
denylist = nil
beof := false
queryChanged := false
denylist := []int32{}
// Special handling of --sync. Activate the interface on the second tick.
if loopIndex == 1 && t.deferActivation() {
@@ -6373,6 +6490,7 @@ func (t *Terminal) Loop() error {
select {
case event = <-t.keyChan:
needBarrier = true
case event = <-t.timerChan:
case event = <-t.eventChan:
// Drain channel to process all queued events at once without rendering
// the intermediate states
@@ -6437,7 +6555,10 @@ func (t *Terminal) Loop() error {
previousInput := t.input
previousCx := t.cx
previousVersion := t.version
t.lastKey = event.KeyName()
if event.Type < tui.Invalid {
t.lastKey = event.KeyName()
t.lastActivity = time.Now()
}
updatePreviewWindow := func(forcePreview bool) {
t.resizeWindows(forcePreview, false)
req(reqPrompt, reqList, reqInfo, reqHeader, reqFooter)
@@ -6708,7 +6829,7 @@ func (t *Terminal) Loop() error {
changed = true
// Deselect items that are now part of the header
for idx := range t.selected {
if idx < int32(n) {
if int(idx) < n {
delete(t.selected, idx)
}
}
@@ -6852,7 +6973,8 @@ func (t *Terminal) Loop() error {
})
case actTransform, actBgTransform:
capture(false, func(body string) {
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n")); err == nil {
// Allow 'put' if the triggering key is a printable character
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n"), event.Printable()); err == nil {
// NOTE: We're not properly passing the return value here
doActions(actions)
}
@@ -7530,6 +7652,20 @@ func (t *Terminal) Loop() error {
} else if t.listBorderShape.HasRight() && t.pborder.EncloseY(my) && mx == t.wborder.Left()+t.wborder.Width()-1 {
pborderDragging = 1
}
case posNext:
if t.layout == layoutReverse {
if t.pborder.Enclose(my, mx) && my == t.pborder.Top()+t.pborder.Height()-1 {
pborderDragging = 0
} else if t.listBorderShape.HasTop() && t.pborder.EncloseX(mx) && my == t.wborder.Top() {
pborderDragging = 1
}
} else {
if t.pborder.Enclose(my, mx) && my == t.pborder.Top() {
pborderDragging = 0
} else if t.listBorderShape.HasBottom() && t.pborder.EncloseX(mx) && my == t.wborder.Top()+t.wborder.Height()-1 {
pborderDragging = 1
}
}
}
}
@@ -7553,6 +7689,27 @@ func (t *Terminal) Loop() error {
prevSize = t.pwindow.Width()
offset := mx - t.pborder.Left()
newSize = prevSize - offset
case posNext:
prevSize = t.pwindow.Height()
// In posNext, header/header-lines sections may sit
// between preview and list. When the list border is
// dragged (pborderDragging == 1), subtract that gap
// so the initial click does not jump.
headerGap := 0
if pborderDragging == 1 && t.wborder != nil {
if t.layout == layoutReverse {
headerGap = t.wborder.Top() - (t.pborder.Top() + t.pborder.Height())
} else {
headerGap = t.pborder.Top() - (t.wborder.Top() + t.wborder.Height())
}
}
if t.layout == layoutReverse {
diff := t.pborder.Height() - prevSize
newSize = my - t.pborder.Top() - diff + 1 - headerGap
} else {
offset := my - t.pborder.Top()
newSize = prevSize - offset - headerGap
}
}
newSize -= pborderDragging
if newSize < 1 {
@@ -7685,7 +7842,7 @@ func (t *Terminal) Loop() error {
if me.Down {
mxCons := util.Constrain(mx-t.promptLen, 0, len(t.input))
if !t.inputless && t.inputWindow == nil && my == t.promptLine() && mxCons >= 0 {
if !t.inputless && t.inputWindow == nil && my == 0 && mxCons >= 0 {
// Prompt
t.cx = mxCons + t.xoffset
} else if my >= min {
@@ -7819,6 +7976,11 @@ func (t *Terminal) Loop() error {
t.previewOpts = t.initialPreviewOpts
t.previewOpts.command = currentPreviewOpts.command
// Carry over toggle-driven state so toggle-preview-wrap survives
// a change-preview-window. Tokens below can still override.
t.previewOpts.wrap = currentPreviewOpts.wrap
t.previewOpts.wrapWord = currentPreviewOpts.wrapWord
// Split window options
tokens := strings.Split(a.a, "|")
if len(tokens[0]) > 0 && t.initialPreviewOpts.hidden {
+19 -18
View File
@@ -133,22 +133,22 @@ func _() {
_ = x[CtrlAltShiftDelete-122]
_ = x[CtrlAltShiftPageUp-123]
_ = x[CtrlAltShiftPageDown-124]
_ = x[Invalid-125]
_ = x[Fatal-126]
_ = x[BracketedPasteBegin-127]
_ = x[BracketedPasteEnd-128]
_ = x[Mouse-129]
_ = x[DoubleClick-130]
_ = x[LeftClick-131]
_ = x[RightClick-132]
_ = x[SLeftClick-133]
_ = x[SRightClick-134]
_ = x[ScrollUp-135]
_ = x[ScrollDown-136]
_ = x[SScrollUp-137]
_ = x[SScrollDown-138]
_ = x[PreviewScrollUp-139]
_ = x[PreviewScrollDown-140]
_ = x[Mouse-125]
_ = x[DoubleClick-126]
_ = x[LeftClick-127]
_ = x[RightClick-128]
_ = x[SLeftClick-129]
_ = x[SRightClick-130]
_ = x[ScrollUp-131]
_ = x[ScrollDown-132]
_ = x[SScrollUp-133]
_ = x[SScrollDown-134]
_ = x[PreviewScrollUp-135]
_ = x[PreviewScrollDown-136]
_ = x[Invalid-137]
_ = x[Fatal-138]
_ = x[BracketedPasteBegin-139]
_ = x[BracketedPasteEnd-140]
_ = x[Resize-141]
_ = x[Change-142]
_ = x[BackwardEOF-143]
@@ -163,11 +163,12 @@ func _() {
_ = x[ClickHeader-152]
_ = x[ClickFooter-153]
_ = x[Multi-154]
_ = x[Every-155]
}
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownInvalidFatalBracketedPasteBeginBracketedPasteEndMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMulti"
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEvery"
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1067, 1072, 1091, 1108, 1113, 1124, 1133, 1143, 1153, 1164, 1172, 1182, 1191, 1202, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325}
var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1065, 1076, 1085, 1095, 1105, 1116, 1124, 1134, 1143, 1154, 1169, 1186, 1193, 1198, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330}
func (i EventType) String() string {
if i < 0 || i >= EventType(len(_EventType_index)-1) {
+2 -1
View File
@@ -67,7 +67,8 @@ func (r *LightRenderer) stderrInternal(str string, allowNLCR bool, resetCode str
for len(bytes) > 0 {
r, sz := utf8.DecodeRune(bytes)
nlcr := r == '\n' || r == '\r'
if r >= 32 || r == '\x1b' || nlcr {
isC1 := r >= 0x80 && r <= 0x9F
if (r >= 32 && !isC1) || r == '\x1b' || nlcr {
if nlcr && !allowNLCR {
if r == '\r' {
runes = append(runes, []rune(CR+resetCode)...)
+60 -44
View File
@@ -4,6 +4,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg"
@@ -196,11 +197,6 @@ const (
CtrlAltShiftPageUp
CtrlAltShiftPageDown
Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd
Mouse
DoubleClick
LeftClick
@@ -214,7 +210,15 @@ const (
PreviewScrollUp
PreviewScrollDown
// Events
// Synthetic / non-user events. Everything from Invalid onward is
// either internally generated or a state-change notification, not
// direct user input. Use `>= Invalid` to gate activity tracking.
// BracketedPasteBegin/End sit here too: they enclose user input
// (which arrives as Rune events) and should not appear in FZF_KEY.
Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd
Resize
Change
BackwardEOF
@@ -229,6 +233,7 @@ const (
ClickHeader
ClickFooter
Multi
Every
)
func (t EventType) AsEvent() Event {
@@ -248,6 +253,12 @@ func (e Event) Comparable() Event {
return Event{e.Type, e.Char, nil}
}
// Printable returns true if the event is a printable character that can be
// inserted into the query (e.g. via the 'put' action).
func (e Event) Printable() bool {
return e.Type == Rune && unicode.IsGraphic(e.Char)
}
func (e Event) KeyName() string {
if me := e.MouseEvent; me != nil {
return me.Name()
@@ -991,51 +1002,56 @@ func init() {
undefined := ColorAttr{colUndefined, AttrUndefined}
NoColorTheme = &ColorTheme{
Colored: false,
Input: defaultColor,
Fg: defaultColor,
Bg: defaultColor,
ListFg: defaultColor,
ListBg: defaultColor,
Colored: false,
// Root colors. Everything else is left undefined so that overriding a
// root (e.g. --color bw,bg:blue) propagates to the derived colors,
// just like in the colored base themes.
Input: defaultColor,
Fg: defaultColor,
Bg: defaultColor,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
Spinner: defaultColor,
Info: defaultColor,
Pointer: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Footer: defaultColor,
BorderLabel: defaultColor,
// Derived colors. Left undefined so they inherit from a root.
ListFg: undefined,
ListBg: undefined,
AltBg: undefined,
SelectedFg: defaultColor,
SelectedBg: defaultColor,
SelectedMatch: defaultColor,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
SelectedFg: undefined,
SelectedBg: undefined,
SelectedMatch: undefined,
Current: undefined,
CurrentMatch: undefined,
Spinner: defaultColor,
Info: defaultColor,
Pointer: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Border: undefined,
BorderLabel: defaultColor,
Ghost: undefined,
Disabled: defaultColor,
PreviewFg: defaultColor,
PreviewBg: defaultColor,
Disabled: undefined,
PreviewFg: undefined,
PreviewBg: undefined,
Gutter: undefined,
AltGutter: undefined,
PreviewBorder: defaultColor,
PreviewScrollbar: defaultColor,
PreviewLabel: defaultColor,
ListLabel: defaultColor,
ListBorder: defaultColor,
Separator: defaultColor,
Scrollbar: defaultColor,
InputBg: defaultColor,
InputBorder: defaultColor,
InputLabel: defaultColor,
HeaderBg: defaultColor,
HeaderBorder: defaultColor,
HeaderLabel: defaultColor,
FooterBg: defaultColor,
FooterBorder: defaultColor,
FooterLabel: defaultColor,
GapLine: defaultColor,
PreviewBorder: undefined,
PreviewScrollbar: undefined,
PreviewLabel: undefined,
ListLabel: undefined,
ListBorder: undefined,
Separator: undefined,
Scrollbar: undefined,
InputBg: undefined,
InputBorder: undefined,
InputLabel: undefined,
HeaderBg: undefined,
HeaderBorder: undefined,
HeaderLabel: undefined,
FooterBg: undefined,
FooterBorder: undefined,
FooterLabel: undefined,
GapLine: undefined,
Nth: undefined,
Nomatch: undefined,
}
+66 -7
View File
@@ -78,6 +78,38 @@ class Shell
"rm -f ~/.local/share/fish/fzf_test_history; XDG_CONFIG_HOME=#{confdir} fish"
end
end
def nushell
@nushell ||=
begin
xdg_home = '/tmp/fzf-nushell-xdg'
config_dir = "#{xdg_home}/nushell"
FileUtils.rm_rf(xdg_home)
FileUtils.mkdir_p(config_dir)
# Write env.nu to set up PATH and unset FZF variables
File.open("#{config_dir}/env.nu", 'w') do |f|
f.puts "$env.PATH = ($env.PATH | split row (char esep) | prepend '#{BASE}/bin')"
UNSETS.each do |var|
f.puts "hide-env -i #{var}"
end
f.puts "$env.FZF_DEFAULT_OPTS = \"--no-scrollbar --pointer '>' --marker '>'\""
f.puts '$env.config = ($env.config | upsert history { file_format: "plaintext", max_size: 100 })'
end
# Write config.nu with minimal prompt
File.open("#{config_dir}/config.nu", 'w') do |f|
f.puts '$env.PROMPT_COMMAND = {|| "" }'
f.puts '$env.PROMPT_INDICATOR = ""'
f.puts '$env.PROMPT_COMMAND_RIGHT = {|| "" }'
f.puts '$env.config = ($env.config | upsert show_banner false)'
f.puts "source #{BASE}/shell/key-bindings.nu"
f.puts "source #{BASE}/shell/completion.nu"
end
"unset #{UNSETS.join(' ')}; env XDG_CONFIG_HOME=#{xdg_home} XDG_DATA_HOME=#{xdg_home}/../fzf-nushell-data nu --config #{config_dir}/config.nu --env-config #{config_dir}/env.nu"
end
end
end
end
@@ -85,12 +117,31 @@ class Tmux
attr_reader :win
def initialize(shell = :bash)
@shell = shell
@win = go(%W[new-window -d -P -F #I #{Shell.send(shell)}]).first
go(%W[set-window-option -t #{@win} pane-base-index 0])
return unless shell == :fish
if shell == :fish
send_keys 'function fish_prompt; end; clear', :Enter
self.until(&:empty?)
elsif shell == :nushell
# Clear history from previous tests to avoid contamination
FileUtils.rm_f('/tmp/fzf-nushell-xdg/nushell/history.txt')
# Wait for nushell to be ready by polling with a marker command.
# We use 'print "fzf-ready"' and check for a line that is exactly
# 'fzf-ready' (not the command echo which includes 'print').
retries = 0
begin
send_keys 'print "fzf-ready"', :Enter
self.until { |lines| lines.any? { |l| l.strip == 'fzf-ready' } }
rescue Minitest::Assertion
retries += 1
raise if retries > 5
send_keys 'function fish_prompt; end; clear', :Enter
self.until(&:empty?)
retry
end
send_keys 'clear', :Enter
self.until(&:empty?)
end
end
def kill
@@ -242,11 +293,19 @@ class Tmux
def prepare
tries = 0
begin
self.until(true) do |lines|
if @shell == :nushell
message = "Prepare[#{tries}]"
send_keys ' ', 'C-u', :Enter, message, :Left, :Right
sleep(0.15)
lines[-1] == message
send_keys 'C-u', 'C-l'
sleep(0.2)
send_keys ' ', 'C-u', :Enter, message
self.until { |lines| lines[-1] == message }
else
self.until(true) do |lines|
message = "Prepare[#{tries}]"
send_keys ' ', 'C-u', :Enter, message, :Left, :Right
sleep(0.15)
lines[-1] == message
end
end
rescue Minitest::Assertion
(tries += 1) < 5 ? retry : raise
+118 -2
View File
@@ -971,6 +971,24 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_includes lines[1], ' aabravo/aabravo' }
end
def test_transform_put
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:transform:echo put'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys :a
tmux.until { |lines| assert_equal '> a', lines.last }
tmux.send_keys :b
tmux.until { |lines| assert_equal '> ab', lines.last }
end
# The async callback runs in a later iteration, but 'put' must still insert
# the key that triggered the bg-transform (snapshot of the scheduling event).
def test_bg_transform_put
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:bg-transform:sleep 0.5; echo put'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys 'ab'
tmux.until { |lines| assert_equal '> ba', lines.last }
end
def test_accept_non_empty
tmux.send_keys %(seq 1000 | #{fzf('--print-query --bind enter:accept-non-empty')}), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
@@ -1387,6 +1405,85 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_includes lines, '> 1' }
end
def test_every_event
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(cat #{tempname})'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
# Trigger external state changes; the every() tick should pick them up.
writelines(['AAA>'])
tmux.until { |lines| assert_includes lines[-1], 'AAA>' }
writelines(['BBB>'])
tmux.until { |lines| assert_includes lines[-1], 'BBB>' }
end
def test_every_event_multiple_independent_timers
# Two timers with different durations should fire independently.
fast = tempname + '.fast'
slow = tempname + '.slow'
FileUtils.rm_f(fast)
FileUtils.rm_f(slow)
tmux.send_keys %(seq 100 | fzf \\
--bind 'every(0.1):execute-silent(printf . >> #{fast})' \\
--bind 'every(0.5):execute-silent(printf . >> #{slow})'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
sleep(1.2)
a = File.exist?(fast) ? File.size(fast) : 0
b = File.exist?(slow) ? File.size(slow) : 0
# Sanity: faster timer fired more times.
assert_operator a, :>, b, "fast timer should fire more (#{a} vs #{b})"
# Sanity: slow timer fired at least once.
assert_operator b, :>=, 1, "slow timer should have fired at least once (#{b})"
ensure
FileUtils.rm_f(fast)
FileUtils.rm_f(slow)
end
def test_every_event_unbind
tmux.send_keys %(seq 100 | fzf --bind 'every(0.1):transform-header(date +%S.%N)' --bind 'space:unbind(every(0.1))+change-header(STOPPED)'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
# Header should be ticking
tmux.until { |lines| assert_match(/^ \d{2}\.\d+/, lines[-3]) }
tmux.send_keys :Space
tmux.until { |lines| assert_includes lines[-3], 'STOPPED' }
sleep(0.4)
# Header must stay STOPPED after the unbind
assert_includes tmux.capture[-3], 'STOPPED'
end
def test_fzf_idle_time_env
# FZF_IDLE_TIME + FZF_IDLE_TIME_MS combined with every() implement idle-based behavior.
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-header(echo "s=$FZF_IDLE_TIME ms_ok=$((FZF_IDLE_TIME_MS / 1000 == FZF_IDLE_TIME))")'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
# Idle counter advances without any input; ms/1000 stays consistent with seconds.
tmux.until { |lines| assert_includes lines[-3], 's=1 ms_ok=1' }
tmux.until { |lines| assert_includes lines[-3], 's=2 ms_ok=1' }
# Any keystroke resets the counter
tmux.send_keys 'x'
tmux.until { |lines| assert_includes lines[-3], 's=0 ms_ok=1' }
tmux.send_keys :BSpace
# And it advances again afterwards
tmux.until { |lines| assert_includes lines[-3], 's=1 ms_ok=1' }
end
def test_every_event_rejects_invalid_arg
%w[every(0) every(-1) every(abc) every()].each do |spec|
tmux.send_keys %(seq 1 | fzf --bind '#{spec}:abort' 2>&1; echo done=$?), :Enter
tmux.until { |lines| assert(lines.any? { |l| l.include?('done=2') }) }
tmux.send_keys 'clear', :Enter
end
end
def test_fzf_key_ignores_synthetic_events
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(echo "[$FZF_KEY]> ")'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
# No user input yet: prompt should show empty FZF_KEY
tmux.until { |lines| assert_includes lines[-1], '[]>' }
tmux.send_keys 'x'
tmux.until { |lines| assert_includes lines[-1], '[x]>' }
# every() ticks shouldn't overwrite FZF_KEY
sleep(1)
assert_includes tmux.capture[-1], '[x]>'
end
def test_labels_center
tmux.send_keys 'echo x | fzf --border --border-label foobar --preview : --preview-label barfoo --bind "space:change-border-label(foobarfoo)+change-preview-label(barfoobar),enter:transform-border-label(echo foo{}foo)+transform-preview-label(echo bar{}bar)"', :Enter
tmux.until do
@@ -2074,6 +2171,24 @@ class TestCore < TestInteractive
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
end
# Regression: actions emitted by bg-transform must affect the iteration that
# processes the async result, not the (no-longer-active) iteration that
# scheduled the transform. Covers reload (newCommand) and exclude (denylist).
def test_bg_transform_action_output
tmux.send_keys %(seq 5 | #{FZF} --bind 'a:bg-transform(echo reload:seq 10 20),b:bg-transform(echo exclude)'), :Enter
tmux.until { |lines| assert_equal 5, lines.item_count }
tmux.send_keys :a
tmux.until do |lines|
assert_equal 11, lines.match_count
assert_includes lines, '> 10'
end
tmux.send_keys :b
tmux.until do |lines|
assert_equal 10, lines.match_count
assert_includes lines, '> 11'
end
end
def test_change_with_nth_search
input = [
'alpha bravo charlie',
@@ -2189,6 +2304,7 @@ class TestCore < TestInteractive
FZF_ACTION: 'start',
FZF_KEY: '',
FZF_POS: '1',
FZF_CURRENT_ITEM: '1',
FZF_QUERY: '',
FZF_POINTER: '>',
FZF_PROMPT: '> ',
@@ -2204,12 +2320,12 @@ class TestCore < TestInteractive
end
tmux.send_keys :Tab, :Tab
tmux.until do
expected.merge!(FZF_ACTION: 'toggle-down', FZF_KEY: 'tab', FZF_POS: '3', FZF_SELECT_COUNT: '2')
expected.merge!(FZF_ACTION: 'toggle-down', FZF_KEY: 'tab', FZF_POS: '3', FZF_CURRENT_ITEM: '3', FZF_SELECT_COUNT: '2')
assert_equal expected, env_vars.slice(*expected.keys)
end
tmux.send_keys '99'
tmux.until do
expected.merge!(FZF_ACTION: 'char', FZF_KEY: '9', FZF_QUERY: '99', FZF_MATCH_COUNT: '1', FZF_POS: '1')
expected.merge!(FZF_ACTION: 'char', FZF_KEY: '9', FZF_QUERY: '99', FZF_MATCH_COUNT: '1', FZF_POS: '1', FZF_CURRENT_ITEM: '99')
assert_equal expected, env_vars.slice(*expected.keys)
end
tmux.send_keys :Space
+175 -60
View File
@@ -243,6 +243,90 @@ class TestLayout < TestInteractive
tmux.until { assert_block(expected, it) }
end
def test_preview_window_next_reverse
# https://github.com/junegunn/fzf/issues/4798
tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter
expected = <<~OUTPUT
line2$ >
5/5
PREVIEW
> 1
OUTPUT
tmux.until { assert_block(expected, it) }
end
def test_preview_window_next_default
tmux.send_keys %(seq 5 | #{FZF} --preview 'echo PREVIEW' --preview-window=next:3), :Enter
expected = <<~OUTPUT
> 1
PREVIEW
5/5
>
OUTPUT
tmux.until { assert_block(expected, it) }
end
def test_preview_window_next_border_line_at_runtime
# change-preview-window to next,border-line should resolve BorderLine
# to a single horizontal separator, matching the behavior
# when next,border-line is the initial spec.
tmux.send_keys %(seq 5 | #{FZF} --preview 'echo PREVIEW' --bind 'space:change-preview-window:next:3,border-line'), :Enter
tmux.until { |lines| assert_equal 5, lines.match_count }
tmux.send_keys :Space
expected = <<~OUTPUT
> 1
PREVIEW
OUTPUT
tmux.until do |lines|
cursor = lines.index { it.start_with?('> 1') }
assert(cursor)
assert_block(expected, lines[cursor..])
end
end
def test_header_first_change_header_at_runtime
# --header-first with no initial --header content needs to grow a
# header window when change-header adds content at runtime, so the
# new header lands below the prompt (not on top of it).
tmux.send_keys %(seq 5 | #{FZF} --header-first --bind 'space:change-header:foo'), :Enter
tmux.until { |lines| assert_equal 5, lines.match_count }
tmux.send_keys :Space
expected = <<~OUTPUT
>
foo
OUTPUT
tmux.until do |lines|
prompt = lines.index { it.start_with?('>') }
assert(prompt)
assert_block(expected, lines[prompt..])
end
end
def test_preview_window_next_style_full_line
tmux.send_keys %(seq 5 | #{FZF} --reverse --preview 'echo PREVIEW' --preview-window=next:3 --header foo --footer bar --style full:line), :Enter
expected = <<~OUTPUT
>
PREVIEW
foo
> 1
OUTPUT
tmux.until { assert_block(expected, it) }
end
def test_height_range_overflow
tmux.send_keys 'seq 100 | fzf --height ~5 --info=inline --border rounded', :Enter
expected = <<~OUTPUT
@@ -1227,75 +1311,106 @@ class TestLayout < TestInteractive
def test_combinations
skip unless ENV['LONGTEST']
base = [
'--pointer=@',
'--exact',
'--query=123',
'--header="$(seq 101 103)"',
'--header-lines=3',
'--footer "$(seq 201 203)"',
'--preview "echo foobar"'
]
options = [
['--separator==', '--no-separator'],
['--info=default', '--info=inline', '--info=inline-right'],
['--no-input-border', '--input-border'],
['--no-header-border', '--header-border=none', '--header-border'],
['--no-header-lines-border', '--header-lines-border'],
['--no-footer-border', '--footer-border'],
['--no-list-border', '--list-border'],
['--preview-window=right', '--preview-window=up', '--preview-window=down', '--preview-window=left'],
['--header-first', '--no-header-first'],
['--layout=default', '--layout=reverse', '--layout=reverse-list']
]
# Combination of all options
combinations = options[0].product(*options.drop(1))
combinations.each_with_index do |combination, index|
opts = base + combination
command = %(seq 1001 2000 | #{FZF} #{opts.join(' ')})
puts "# #{index + 1}/#{combinations.length}\n#{command}"
tmux.send_keys command, :Enter
tmux.until do |lines|
layout = combination.find { it.start_with?('--layout=') }.split('=').last
header_first = combination.include?('--header-first')
begin
base = [
'--pointer=@',
'--exact',
'--query=123',
'--header="$(seq 101 103)"',
'--header-lines=3',
'--footer "$(seq 201 203)"',
'--preview "echo foobar"'
]
options = [
['--separator==', '--no-separator'],
['--info=default', '--info=inline', '--info=inline-right'],
['--no-input-border', '--input-border'],
['--no-header-border', '--header-border=none', '--header-border'],
['--no-header-lines-border', '--header-lines-border'],
['--no-footer-border', '--footer-border'],
['--no-list-border', '--list-border'],
['--preview-window=right', '--preview-window=up', '--preview-window=down', '--preview-window=left', '--preview-window=next'],
['--header-first', '--no-header-first'],
['--layout=default', '--layout=reverse', '--layout=reverse-list']
]
# Combination of all options
combinations = options[0].product(*options.drop(1))
# Input
input = lines.index { it.include?('> 123') }
assert(input)
# Run workers in parallel, each with its own pre-created tmux window.
# Tmux setup/teardown is serialized in the main thread to avoid racing
# `tmux new-window` and `tmux kill-window` calls on the tmux server.
workers = 10
tmuxes = Array.new(workers) { Tmux.new }
failures = []
mutex = Mutex.new
queue = Queue.new
index = 0
threads = tmuxes.map do |local_tmux|
Thread.new do
command = nil
loop do
combination = queue.pop or break
# Info
info = lines.index { it.include?('11/997') }
assert(info)
opts = base + combination
command = %(seq 1001 2000 | #{FZF} #{opts.join(' ')})
mutex.synchronize do
print("\r#{index += 1}/#{combinations.length}")
end
local_tmux.send_keys command, :Enter
local_tmux.until do |lines|
layout = combination.find { it.start_with?('--layout=') }.split('=').last
header_first = combination.include?('--header-first')
assert(layout == 'reverse' ? input <= info : input >= info)
# Input
input = lines.index { it.include?('> 123') }
assert(input)
# List
item1 = lines.index { it.include?('1230') }
item2 = lines.index { it.include?('1231') }
assert_equal(item1, layout == 'default' ? item2 + 1 : item2 - 1)
# Info
info = lines.index { it.include?('11/997') }
assert(info)
# Preview
assert(lines.any? { it.include?('foobar') })
assert(layout == 'reverse' ? input <= info : input >= info)
# Header
header1 = lines.index { it.include?('101') }
header2 = lines.index { it.include?('102') }
assert_equal(header2, header1 + 1)
assert((layout == 'reverse') == header_first ? input > header1 : input < header1)
# List
item1 = lines.index { it.include?('1230') }
item2 = lines.index { it.include?('1231') }
assert_equal(item1, layout == 'default' ? item2 + 1 : item2 - 1)
# Footer
footer1 = lines.index { it.include?('201') }
footer2 = lines.index { it.include?('202') }
assert_equal(footer2, footer1 + 1)
assert(layout == 'reverse' ? footer1 > item2 : footer1 < item2)
# Preview
assert(lines.any? { it.include?('foobar') })
# Header lines
hline1 = lines.index { it.include?('1001') }
hline2 = lines.index { it.include?('1002') }
assert_equal(hline1, layout == 'default' ? hline2 + 1 : hline2 - 1)
assert(layout == 'reverse' ? hline1 > header1 : hline1 < header1)
# Header
header1 = lines.index { it.include?('101') }
header2 = lines.index { it.include?('102') }
assert_equal(header2, header1 + 1)
assert((layout == 'reverse') == header_first ? input > header1 : input < header1)
# Footer
footer1 = lines.index { it.include?('201') }
footer2 = lines.index { it.include?('202') }
assert_equal(footer2, footer1 + 1)
assert(layout == 'reverse' ? footer1 > item2 : footer1 < item2)
# Header lines
hline1 = lines.index { it.include?('1001') }
hline2 = lines.index { it.include?('1002') }
assert_equal(hline1, layout == 'default' ? hline2 + 1 : hline2 - 1)
assert(layout == 'reverse' ? hline1 > header1 : hline1 < header1)
end
local_tmux.send_keys :Enter
end
rescue StandardError, Minitest::Assertion => e
mutex.synchronize { failures << [command, e] }
end
end
tmux.send_keys :Enter
combinations.each { queue << it }
queue.close
threads.each(&:join)
raise failures.inspect unless failures.empty?
ensure
# Reverse so any tmux window renumbering does not leave stale indices behind.
tmuxes&.reverse_each(&:kill)
end
end
+43
View File
@@ -383,6 +383,49 @@ class TestPreview < TestInteractive
end
end
def test_change_preview_window_preserves_wrap_toggle
# https://github.com/junegunn/fzf/issues/4791
tmux.send_keys "#{FZF} --preview 'for i in $(seq $FZF_PREVIEW_COLUMNS); do echo -n .; done; echo -n .; echo wrapped; echo 2nd line' " \
"--preview-window 'right,nowrap,border-rounded' " \
'--bind ctrl-w:toggle-preview-wrap ' \
'--bind ctrl-r:change-preview-window:border-bold', :Enter
sleep(2)
# Initial: nowrap, rounded border. The long line is truncated; "wrapped" is hidden.
tmux.until do |lines|
assert_includes lines[2], '2nd line'
assert(lines.any? { it.include?('╭') })
end
# Toggle wrap on.
tmux.send_keys 'C-w'
tmux.until do |lines|
assert_includes lines[2], 'wrapped'
assert_includes lines[3], '2nd line'
end
# change-preview-window swaps the border to bold; wrap state must persist.
tmux.send_keys 'C-r'
tmux.until do |lines|
assert(lines.any? { it.include?('┏') }) # border actually changed
refute(lines.any? { it.include?('╭') })
assert_includes lines[2], 'wrapped' # wrap was preserved
assert_includes lines[3], '2nd line'
end
end
def test_change_preview_window_overrides_wrap_explicitly
# When the new spec sets wrap/nowrap explicitly, it should still win.
tmux.send_keys "#{FZF} --preview 'for i in $(seq $FZF_PREVIEW_COLUMNS); do echo -n .; done; echo -n .; echo wrapped; echo 2nd line' " \
"--preview-window 'right,wrap' " \
'--bind ctrl-r:change-preview-window:nowrap', :Enter
# Initial: wrap is on.
tmux.until do |lines|
assert_includes lines[2], 'wrapped'
assert_includes lines[3], '2nd line'
end
# Explicit nowrap in the spec must override the (initially wrapped) state.
tmux.send_keys 'C-r'
tmux.until { |lines| assert_includes lines[2], '2nd line' }
end
def test_preview_follow_wrap
tmux.send_keys "seq 1 | #{FZF} --preview 'seq 1000' --preview-window right,2,follow,wrap", :Enter
tmux.until { |lines| assert_equal 1, lines.match_count }
+161
View File
@@ -100,6 +100,47 @@ module TestShell
tmux.until { |lines| assert_equal '/tmp', lines[-1] }
end
def test_alt_c_symlink
base = '/tmp/fzf-test-alt-c-symlink'
FileUtils.rm_rf(base)
FileUtils.mkdir_p("#{base}/real/subdir")
FileUtils.ln_s("#{base}/real", "#{base}/link")
tmux.prepare
tmux.send_keys "cd #{base}/link", :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'subdir'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.prepare
tmux.send_keys :pwd, :Enter
tmux.until { |lines| assert_equal "#{base}/link/subdir", lines[-1] }
ensure
FileUtils.rm_rf(base)
end
def test_alt_c_absolute_cmd
base = '/tmp/fzf-test-alt-c-absolute'
FileUtils.rm_rf(base)
FileUtils.mkdir_p(base)
set_var('FZF_ALT_C_COMMAND', "echo #{base}")
tmux.prepare
tmux.send_keys 'cd /tmp', :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.prepare
tmux.send_keys :pwd, :Enter
tmux.until { |lines| assert_equal base, lines[-1] }
ensure
FileUtils.rm_rf(base)
end
def test_ctrl_r
tmux.prepare
tmux.send_keys 'echo 1st', :Enter
@@ -1103,3 +1144,123 @@ class TestFish < TestBase
end
end
end
class TestNushell < TestBase
include TestShell
def teardown
@tmux&.kill
end
def shell
:nushell
end
def set_var(name, val)
tmux.prepare
tmux.send_keys "$env.#{name} = '#{val}'", :Enter
tmux.prepare
end
def unset_var(name)
tmux.prepare
tmux.send_keys "hide-env -i #{name}", :Enter
tmux.prepare
end
def new_shell
tmux.send_keys 'FZF_TMUX=1 nu', :Enter
tmux.prepare
end
# Override: Nushell's builtin `echo` outputs structured data, so we need
# `^echo` (external echo) for plain text output on the command line.
def test_ctrl_t_unicode
writelines(['fzf-unicode 테스트1', 'fzf-unicode 테스트2'])
set_var('FZF_CTRL_T_COMMAND', "cat #{tempname}")
tmux.prepare
tmux.send_keys '^echo ', 'C-t'
tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys 'fzf-unicode'
tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys '1'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Tab
tmux.until { |lines| assert_equal 1, lines.select_count }
tmux.send_keys :BSpace
tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys '2'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Tab
tmux.until { |lines| assert_equal 2, lines.select_count }
tmux.send_keys :Enter
tmux.until { |lines| assert_match(/\^echo .*fzf-unicode.*1.* .*fzf-unicode.*2/, lines.join) }
tmux.send_keys :Enter
tmux.until { |lines| assert_equal 'fzf-unicode 테스트1 fzf-unicode 테스트2', lines[-1] }
end
# Override: Nushell's external completer replaces the entire token,
# so we use assert_includes instead of assert_equal for the result.
# ~USERNAME expansion and backslash-escaped spaces are not applicable.
def test_file_completion
FileUtils.mkdir_p('/tmp/fzf-test')
(1..100).each { |i| FileUtils.touch("/tmp/fzf-test/#{i}") }
tmux.prepare
# Multi-selection
tmux.send_keys "cat /tmp/fzf-test/10#{trigger}", :Tab
tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys :Tab, :Tab
tmux.until { |lines| assert_equal 2, lines.select_count }
tmux.send_keys :Enter
tmux.until(true) do |lines|
assert_includes lines[-1].to_s, '/tmp/fzf-test/10'
assert_includes lines[-1].to_s, '/tmp/fzf-test/100'
end
# Single selection
tmux.prepare
tmux.send_keys "cat /tmp/fzf-test/10#{trigger}", :Tab
tmux.until { |lines| assert_equal 2, lines.match_count }
tmux.send_keys '0'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.until(true) do |lines|
assert_includes lines[-1].to_s, '/tmp/fzf-test/100'
end
# Should include hidden files
(1..100).each { |i| FileUtils.touch("/tmp/fzf-test/.hidden-#{i}") }
tmux.prepare
tmux.send_keys "cat /tmp/fzf-test/hidden#{trigger}", :Tab
tmux.until(true) do |lines|
assert_equal 100, lines.match_count
assert lines.any_include?('/tmp/fzf-test/.hidden-')
end
tmux.send_keys :Enter
ensure
FileUtils.rm_rf('/tmp/fzf-test')
end
# Nushell does not support multiline command recall the same way
# as bash/zsh/fish, so test_ctrl_r_multiline is omitted.
# Override: only test with 'foo' -- single and double quotes cause
# issues in Nushell's line editor.
def test_ctrl_r_abort
%w[foo].each do |query|
tmux.prepare
tmux.send_keys :Enter, query
tmux.until { |lines| assert lines[-1]&.start_with?(query) }
tmux.send_keys 'C-r'
tmux.until { |lines| assert_equal "> #{query}", lines[-1] }
tmux.send_keys 'C-g'
tmux.until { |lines| assert lines[-1]&.start_with?(query) }
end
end
end
+1
View File
@@ -6,6 +6,7 @@ enew = "enew"
tabe = "tabe"
Iterm = "Iterm"
ser = "ser"
Slq = "Slq"
[files]
extend-exclude = ["README.md", "*.s"]
+7
View File
@@ -114,6 +114,13 @@ if [ -d "${fish_dir}/functions" ]; then
fi
fi
if command -v nu > /dev/null; then
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
else
nushell_autoload_dir=${XDG_CONFIG_HOME:-$HOME/.config}/nushell/autoload
fi
remove "${nushell_autoload_dir}/_fzf_integration.nu"
config_dir=$(dirname "$prefix_expand")
if [[ $xdg == 1 ]] && [[ $config_dir == */fzf ]] && [[ -d $config_dir ]]; then
rmdir "$config_dir"