Compare commits

...

32 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 336cfdef02 Fix nushell contamination in install loop 2026-05-28 00:34:17 +00:00
copilot-swe-agent[bot] eee9672eb0 Initial plan 2026-05-28 00:32:09 +00:00
Junegunn Choi 845752f305 Update README
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
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
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
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
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
- `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)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
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)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
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
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
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
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
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)
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
- 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
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
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
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
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
CodeQL / Analyze (go) (push) Has been cancelled
build / build (push) Has been cancelled
Test fzf on macOS / build (push) Has been cancelled
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
36 changed files with 2017 additions and 262 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ jobs:
- name: 'Checkout Repository' - name: 'Checkout Repository'
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: 'Dependency Review' - 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" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1 uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
with: with:
ruby-version: 3.4.6 ruby-version: 3.4.6
- name: Install packages - 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 - name: Install Ruby gems
run: bundle install run: bundle install
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
go-version: "1.23" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1 uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1
with: with:
ruby-version: 3.0.0 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: on:
release: release:
types: [released] types: [released]
workflow_dispatch:
inputs:
release-tag:
description: 'Release tag to submit (e.g. v0.73.1)'
required: true
type: string
jobs: jobs:
publish: publish:
@@ -10,5 +16,6 @@ jobs:
- uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 - uses: vedantmgoyal2009/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
with: with:
identifier: junegunn.fzf identifier: junegunn.fzf
release-tag: ${{ inputs.release-tag || github.event.release.tag_name }}
installers-regex: '-windows_(armv7|arm64|amd64)\.zip$' installers-regex: '-windows_(armv7|arm64|amd64)\.zip$'
token: ${{ secrets.WINGET_TOKEN }} token: ${{ secrets.WINGET_TOKEN }}
+35
View File
@@ -1,6 +1,41 @@
CHANGELOG 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 0.72.0
------ ------
_Release highlights: https://junegunn.github.io/fzf/releases/0.72.0/_ _Release highlights: https://junegunn.github.io/fzf/releases/0.72.0/_
+14 -1
View File
@@ -117,6 +117,19 @@ generate:
build: build:
goreleaser build --clean --snapshot --skip=post-hooks 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: release:
# Make sure that the tests pass and the build works # Make sure that the tests pass and the build works
TAGS=tcell make test TAGS=tcell make test
@@ -206,4 +219,4 @@ update:
$(GO) get -u $(GO) get -u
$(GO) mod tidy $(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> <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 Whether you're selecting files, browsing command history, previewing data,
history, processes, hostnames, bookmarks, git commits, etc. It implements navigating complex datasets with fuzzy matching, or creating custom menus and
a "fuzzy" matching algorithm, so you can quickly type in patterns with omitted workflows, fzf provides the building blocks to turn shell scripts into rich
characters and still get the results you want. terminal applications.
Highlights Highlights
---------- ----------
- **Portable** -- Distributed as a single binary for easy installation - **Portable** // Distributed as a single binary for easy installation
- **Fast** -- Optimized to process millions of items instantly - **Fast** // Optimized to process millions of items in milliseconds
- **Versatile** -- Fully customizable through an event-action binding mechanism - **Programmable** // Event-driven architecture for building custom terminal interfaces and workflows
- **All-inclusive** -- Comes with integrations for Bash, Zsh, Fish, Vim, and Neovim - **Batteries-included** // Comes with integrations for Bash, Zsh, Fish, Nushell, Vim, and Neovim
Table of Contents Table of Contents
----------------- -----------------
@@ -81,6 +81,7 @@ Table of Contents
* [Supported commands (bash)](#supported-commands-bash) * [Supported commands (bash)](#supported-commands-bash)
* [Custom fuzzy completion](#custom-fuzzy-completion) * [Custom fuzzy completion](#custom-fuzzy-completion)
* [Fuzzy completion for fish](#fuzzy-completion-for-fish) * [Fuzzy completion for fish](#fuzzy-completion-for-fish)
* [Fuzzy completion for Nushell](#fuzzy-completion-for-nushell)
* [Vim plugin](#vim-plugin) * [Vim plugin](#vim-plugin)
* [Advanced topics](#advanced-topics) * [Advanced topics](#advanced-topics)
* [Customizing for different types of input](#customizing-for-different-types-of-input) * [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 # Set up fzf key bindings
fzf --fish | source 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] > [!NOTE]
> `--bash`, `--zsh`, and `--fish` options are only available in fzf 0.48.0 or > `--bash`, `--zsh`, `--fish`, and `--nushell` options are only available in
> later. If you have an older version of fzf, or want finer control, you can > 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 > source individual script files in the [/shell](/shell) directory. The
> location of the files may vary depending on the package manager you use. > location of the files may vary depending on the package manager you use.
> Please refer to the package documentation for more information. > 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)"` > * bash: `FZF_CTRL_R_COMMAND= FZF_ALT_C_COMMAND= eval "$(fzf --bash)"`
> * zsh: `FZF_CTRL_R_COMMAND= FZF_ALT_C_COMMAND= source <(fzf --zsh)` > * zsh: `FZF_CTRL_R_COMMAND= FZF_ALT_C_COMMAND= source <(fzf --zsh)`
> * fish: `fzf --fish | FZF_CTRL_R_COMMAND= FZF_ALT_C_COMMAND= source` > * 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. > 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 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 - `CTRL-T` - Paste the selected files and directories onto the command-line
- The list is generated using `--walker file,dir,follow,hidden` option - 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 - Can be disabled by setting `FZF_CTRL_T_COMMAND` to an empty string when
sourcing the script 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` - If you want to see the commands in chronological order, press `CTRL-R`
again which toggles sorting by relevance again which toggles sorting by relevance
- Press `ALT-R` to toggle "raw" mode where you can see the surrounding items - 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 of a match. In this mode, you can press `CTRL-N` and `CTRL-P` to move
between the matching items only. between the matching items only.
- Press `CTRL-/` or `ALT-/` to toggle line wrapping - Press `CTRL-/` or `ALT-/` to toggle line wrapping
- Press `SHIFT-DELETE` to delete the selected commands (bash and fish)
- Fish shell only: - Fish shell only:
- Press `SHIFT-DELETE` to delete the selected commands
- Press `ALT-ENTER` to reformat and insert 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) - Press `ALT-T` to cycle through command prefix (timestamp, date/time, none)
- Set `FZF_CTRL_R_OPTS` to pass additional options to fzf - 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 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 ### Files and directories
@@ -823,6 +835,37 @@ function _fzf_post_complete_foo
end 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 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/charlievieth/fastwalk v1.0.14
github.com/gdamore/tcell/v2 v2.9.0 github.com/gdamore/tcell/v2 v2.9.0
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.22
github.com/rivo/uniseg v0.4.7 github.com/rivo/uniseg v0.4.7
golang.org/x/sys v0.35.0 golang.org/x/sys v0.35.0
golang.org/x/term v0.34.0 golang.org/x/term v0.34.0
+2 -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/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -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-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.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.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 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+29 -3
View File
@@ -2,11 +2,11 @@
set -u set -u
version=0.72.0 version=0.73.1
auto_completion= auto_completion=
key_bindings= key_bindings=
update_config=2 update_config=2
shells="bash zsh fish" shells="bash zsh fish nushell"
prefix='~/.fzf' prefix='~/.fzf'
prefix_expand=~/.fzf prefix_expand=~/.fzf
fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish fish_dir=${XDG_CONFIG_HOME:-$HOME/.config}/fish
@@ -27,6 +27,7 @@ usage: $0 [OPTIONS]
--no-bash Do not set up bash configuration --no-bash Do not set up bash configuration
--no-zsh Do not set up zsh configuration --no-zsh Do not set up zsh configuration
--no-fish Do not set up fish configuration --no-fish Do not set up fish configuration
--no-nushell Do not set up nushell configuration
EOF EOF
} }
@@ -56,6 +57,7 @@ for opt in "$@"; do
--no-bash) shells=${shells/bash/} ;; --no-bash) shells=${shells/bash/} ;;
--no-zsh) shells=${shells/zsh/} ;; --no-zsh) shells=${shells/zsh/} ;;
--no-fish) shells=${shells/fish/} ;; --no-fish) shells=${shells/fish/} ;;
--no-nushell) shells=${shells/nushell/} ;;
*) *)
echo "unknown option: $opt" echo "unknown option: $opt"
help help
@@ -224,7 +226,9 @@ fi
[[ $* =~ "--bin" ]] && exit 0 [[ $* =~ "--bin" ]] && exit 0
for s in $shells; do 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/} shells=${shells/$s/}
fi fi
done done
@@ -248,6 +252,7 @@ fi
echo echo
for shell in $shells; do for shell in $shells; do
[[ $shell == nushell ]] && continue
fzf_completion="source \"$fzf_base/shell/completion.${shell}\"" fzf_completion="source \"$fzf_base/shell/completion.${shell}\""
fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\"" fzf_key_bindings="source \"$fzf_base/shell/key-bindings.${shell}\""
[[ $shell == fish ]] && continue [[ $shell == fish ]] && continue
@@ -368,6 +373,7 @@ fi
echo echo
for shell in $shells; do for shell in $shells; do
[[ $shell == fish ]] && continue [[ $shell == fish ]] && continue
[[ $shell == nushell ]] && continue
[ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc [ $shell = zsh ] && dest=${ZDOTDIR:-~}/.zshrc || dest=~/.bashrc
append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}" append_line $update_config "[ -f ${prefix}.${shell} ] && source ${prefix}.${shell}" "$dest" "${prefix}.${shell}"
done done
@@ -436,6 +442,25 @@ if [[ $shells =~ fish ]]; then
fi fi
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 if [ $update_config -eq 1 ]; then
echo 'Finished. Restart your shell or reload config file.' echo 'Finished. Restart your shell or reload config file.'
if [[ $shells =~ bash ]]; then if [[ $shells =~ bash ]]; then
@@ -445,6 +470,7 @@ if [ $update_config -eq 1 ]; then
fi fi
[[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh" [[ $shells =~ zsh ]] && echo " source ${ZDOTDIR:-~}/.zshrc # zsh"
[[ $shells =~ fish && $lno_func -ne 0 ]] && echo ' fish_user_key_bindings # fish' [[ $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
echo 'Use uninstall script to remove fzf.' echo 'Use uninstall script to remove fzf.'
echo 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 $fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+12 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/junegunn/fzf/src/protector" "github.com/junegunn/fzf/src/protector"
) )
var version = "0.72" var version = "0.73"
var revision = "devel" var revision = "devel"
//go:embed shell/key-bindings.bash //go:embed shell/key-bindings.bash
@@ -29,6 +29,12 @@ var zshCompletion []byte
//go:embed shell/key-bindings.fish //go:embed shell/key-bindings.fish
var fishKeyBindings []byte 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 //go:embed shell/completion.fish
var fishCompletion []byte var fishCompletion []byte
@@ -71,6 +77,11 @@ func main() {
printScript("completion.fish", fishCompletion) printScript("completion.fish", fishCompletion)
return return
} }
if options.Nushell {
printScript("key-bindings.nu", nushellKeyBindings)
printScript("completion.nu", nushellCompletion)
return
}
if options.Help { if options.Help {
fmt.Print(fzf.Usage) fmt.Print(fzf.Usage)
return 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. 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 .SH NAME
fzf\-tmux - open fzf in tmux split pane 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. 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 .SH NAME
fzf - a command-line fuzzy finder fzf - a command-line fuzzy finder
@@ -993,9 +993,14 @@ border line.
\fBdown \fBdown
\fBleft \fBleft
\fBright \fBright
\fBnext
\fRDetermines the layout of the preview window. \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 * If the argument contains \fB:hidden\fR, the preview window will be hidden by
default until \fBtoggle\-preview\fR action is triggered. 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 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 .SS OTHERS
.TP .TP
.B "\-\-no\-mouse" .B "\-\-no\-mouse"
@@ -1470,6 +1481,8 @@ fzf exports the following environment variables to its child processes.
.br .br
.BR FZF_POS " Vertical position of the cursor in the list starting from 1" .BR FZF_POS " Vertical position of the cursor in the list starting from 1"
.br .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 FZF_WRAP " The line wrapping mode (char, word) when enabled"
.br .br
.BR FZF_QUERY " Current query string" .BR FZF_QUERY " Current query string"
@@ -1500,6 +1513,10 @@ fzf exports the following environment variables to its child processes.
.br .br
.BR FZF_KEY " The name of the last key pressed" .BR FZF_KEY " The name of the last key pressed"
.br .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 FZF_PORT " Port number when \-\-listen option is used"
.br .br
.BR FZF_SOCK " Unix socket path when \-\-listen option is used" .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
.BR FZF_RAW " Only in raw mode. 1 if the current item matches, 0 otherwise" .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 .SH EXTENDED SEARCH MODE
Unless specified otherwise, fzf will start in "extended\-search mode". In this 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. if clicked on a word.
.RE .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: .SS AVAILABLE ACTIONS:
A key or an event can be bound to one or more of the following 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
}
+2 -2
View File
@@ -266,7 +266,7 @@ func charClassOf(char rune) charClass {
} }
func bonusFor(prevClass charClass, class charClass) int16 { func bonusFor(prevClass charClass, class charClass) int16 {
if class > charNonWord { if class >= charNonWord {
switch prevClass { switch prevClass {
case charWhite: case charWhite:
// Word boundary after whitespace // 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. // we fall back to the greedy algorithm.
// Also, we should not allow a very long pattern to avoid 16-bit integer // Also, we should not allow a very long pattern to avoid 16-bit integer
// overflow in the score matrix. 1000 is a safe limit. // 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) 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) scoreMatch*4+int(bonusBoundaryDelimiter)*bonusFirstCharMultiplier+int(bonusBoundaryDelimiter)*3)
assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13, assertMatch(t, fn, false, forward, "/.oh-my-zsh/cache", "zshc", 8, 13,
scoreMatch*4+bonusBoundary*bonusFirstCharMultiplier+bonusBoundary*2+scoreGapStart+int(bonusBoundaryDelimiter)) 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, assertMatch(t, fn, false, forward, "ab0123 456", "12356", 3, 10,
scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension) scoreMatch*5+bonusConsecutive*3+scoreGapStart+scoreGapExtension)
assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10, assertMatch(t, fn, false, forward, "abc123 456", "12356", 3, 10,
+5 -1
View File
@@ -4,6 +4,7 @@ package fzf
import ( import (
"fmt" "fmt"
"maps" "maps"
"math"
"os" "os"
"sync" "sync"
"time" "time"
@@ -241,6 +242,9 @@ func Run(opts *Options) (int, error) {
denylist = make(map[int32]struct{}) denylist = make(map[int32]struct{})
denyMutex.Unlock() denyMutex.Unlock()
} }
if opts.HeaderLines > math.MaxInt32 {
opts.HeaderLines = math.MaxInt32
}
headerLines := int32(opts.HeaderLines) headerLines := int32(opts.HeaderLines)
headerUpdated := false headerUpdated := false
patternBuilder := func(runes []rune) *Pattern { 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)) terminal.UpdateCount(max(0, total-int(headerLines)), !reading, value.(*string))
if headerLines > 0 && !headerUpdated { if headerLines > 0 && !headerUpdated {
terminal.UpdateHeader(GetItems(snapshot, int(headerLines))) terminal.UpdateHeader(GetItems(snapshot, int(headerLines)))
headerUpdated = int32(total) >= headerLines headerUpdated = total >= int(headerLines)
} }
if heightUnknown && !deferred { if heightUnknown && !deferred {
determine(!reading) determine(!reading)
+44 -5
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"maps" "maps"
"math"
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
@@ -159,7 +160,7 @@ Usage: fzf [options]
PREVIEW WINDOW PREVIEW WINDOW
--preview=COMMAND Command to preview highlighted line ({}) --preview=COMMAND Command to preview highlighted line ({})
--preview-window=OPT Preview window layout (default: right:50%) --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]wrap[-word]][,[no]cycle][,[no]follow][,[no]info]
[,[no]hidden][,border-STYLE] [,[no]hidden][,border-STYLE]
[,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES] [,+SCROLL[OFFSETS][/DENOM]][,~HEADER_LINES]
@@ -232,6 +233,7 @@ Usage: fzf [options]
--bash Print script to set up Bash shell integration --bash Print script to set up Bash shell integration
--zsh Print script to set up Zsh shell integration --zsh Print script to set up Zsh shell integration
--fish Print script to set up Fish shell integration --fish Print script to set up Fish shell integration
--nushell Print script to set up Nushell integration
HELP HELP
--version Display version information and exit --version Display version information and exit
@@ -331,6 +333,7 @@ const (
posLeft posLeft
posRight posRight
posCenter posCenter
posNext // adjacent to the input section, on the list side
) )
type tmuxOptions struct { type tmuxOptions struct {
@@ -390,7 +393,7 @@ func (o *previewOpts) Toggle() {
o.hidden = !o.hidden o.hidden = !o.hidden
} }
func (o *previewOpts) Border() tui.BorderShape { func (o *previewOpts) Border(layout layoutType) tui.BorderShape {
shape := o.border shape := o.border
if shape == tui.BorderLine { if shape == tui.BorderLine {
switch o.position { switch o.position {
@@ -402,6 +405,12 @@ func (o *previewOpts) Border() tui.BorderShape {
shape = tui.BorderRight shape = tui.BorderRight
case posRight: case posRight:
shape = tui.BorderLeft shape = tui.BorderLeft
case posNext:
if layout == layoutReverse {
shape = tui.BorderBottom
} else {
shape = tui.BorderTop
}
} }
} }
return shape return shape
@@ -511,7 +520,7 @@ func parseLabelPosition(opts *labelOpts, arg string) error {
} }
func (a previewOpts) aboveOrBelow() bool { 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 type previewOptsCompare int
@@ -578,6 +587,7 @@ type Options struct {
Bash bool Bash bool
Zsh bool Zsh bool
Fish bool Fish bool
Nushell bool
Man bool Man bool
Fuzzy bool Fuzzy bool
FuzzyAlgo algo.Algo FuzzyAlgo algo.Algo
@@ -725,6 +735,7 @@ func defaultOptions() *Options {
Bash: false, Bash: false,
Zsh: false, Zsh: false,
Fish: false, Fish: false,
Nushell: false,
Man: false, Man: false,
Fuzzy: true, Fuzzy: true,
FuzzyAlgo: algo.FuzzyMatchV2, FuzzyAlgo: algo.FuzzyMatchV2,
@@ -1257,7 +1268,14 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
add(tui.F12) add(tui.F12)
default: default:
runes := []rune(key) 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]) r := rune(lkey[9])
evt := tui.CtrlAltKey(r) evt := tui.CtrlAltKey(r)
if r == 'h' && !util.IsWindows() { if r == 'h' && !util.IsWindows() {
@@ -1299,6 +1317,21 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
return chords, list, nil 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) { func parseScheme(str string) (string, []criterion, error) {
str = strings.ToLower(str) str = strings.ToLower(str)
switch str { switch str {
@@ -2329,6 +2362,8 @@ func parsePreviewWindowImpl(opts *previewOpts, input string) error {
opts.position = posLeft opts.position = posLeft
case "right": case "right":
opts.position = posRight opts.position = posRight
case "next":
opts.position = posNext
case "rounded", "border", "border-rounded": case "rounded", "border", "border-rounded":
opts.border = tui.BorderRounded opts.border = tui.BorderRounded
case "border-line": case "border-line":
@@ -2521,6 +2556,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
opts.Bash = false opts.Bash = false
opts.Zsh = false opts.Zsh = false
opts.Fish = false opts.Fish = false
opts.Nushell = false
opts.Help = false opts.Help = false
opts.Version = false opts.Version = false
opts.Man = false opts.Man = false
@@ -2633,6 +2669,9 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--fish": case "--fish":
clearExitingOpts() clearExitingOpts()
opts.Fish = true opts.Fish = true
case "--nushell":
clearExitingOpts()
opts.Nushell = true
case "-h", "--help": case "-h", "--help":
clearExitingOpts() clearExitingOpts()
opts.Help = true opts.Help = true
@@ -3135,7 +3174,7 @@ func parseOptions(index *int, opts *Options, allArgs []string) error {
case "--no-preview": case "--no-preview":
opts.Preview.command = "" opts.Preview.command = ""
case "--preview-window": 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 { if err != nil {
return err return err
} }
+33
View File
@@ -299,6 +299,39 @@ func TestBind(t *testing.T) {
check(tui.F1.AsEvent(), "", actAbort) 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) { func TestColorSpec(t *testing.T) {
var base *tui.ColorTheme var base *tui.ColorTheme
theme := tui.Dark256 theme := tui.Dark256
+4 -3
View File
@@ -153,7 +153,7 @@ func startHttpServer(address listenAddress, actionChannel chan []*action, getHan
func (server *httpServer) handleHttpRequest(conn net.Conn) string { func (server *httpServer) handleHttpRequest(conn net.Conn) string {
contentLength := 0 contentLength := 0
apiKey := "" apiKey := ""
body := "" var bodyBuilder strings.Builder
answer := func(code string, message string) string { answer := func(code string, message string) string {
message += "\n" message += "\n"
return code + fmt.Sprintf("Content-Length: %d%s", len(message), crlf+crlf+message) 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)] token := data[:found+len(crlf)]
return len(token), token, nil 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, data, bufio.ErrFinalToken
} }
return 0, nil, nil return 0, nil, nil
@@ -218,7 +218,7 @@ Loop:
} }
} }
case 2: // Request body case 2: // Request body
body += text bodyBuilder.WriteString(text)
} }
} }
@@ -234,6 +234,7 @@ Loop:
return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`) return answer(httpUnavailable+jsonContentType, `{"error":"timeout"}`)
} }
body := bodyBuilder.String()
if len(body) < contentLength { if len(body) < contentLength {
return bad("incomplete request") return bad("incomplete request")
} }
+286 -125
View File
@@ -436,6 +436,7 @@ type Terminal struct {
bgSemaphores map[action]chan struct{} bgSemaphores map[action]chan struct{}
keyChan chan tui.Event keyChan chan tui.Event
eventChan chan tui.Event eventChan chan tui.Event
timerChan chan tui.Event
slab *util.Slab slab *util.Slab
theme *tui.ColorTheme theme *tui.ColorTheme
tui tui.Renderer tui tui.Renderer
@@ -456,6 +457,7 @@ type Terminal struct {
proxyScript string proxyScript string
numLinesCache map[int32]numLinesCacheValue numLinesCache map[int32]numLinesCacheValue
raw bool raw bool
lastActivity time.Time
} }
type numLinesCacheValue struct { 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 // Minimum height required to render fzf excluding margin and padding
effectiveMinHeight := minHeight effectiveMinHeight := minHeight
if previewBox != nil && opts.Preview.aboveOrBelow() { if previewBox != nil && opts.Preview.aboveOrBelow() {
effectiveMinHeight += 1 + borderLines(opts.Preview.Border()) effectiveMinHeight += 1 + borderLines(opts.Preview.Border(opts.Layout))
} }
if opts.noSeparatorLine() { if opts.noSeparatorLine() {
effectiveMinHeight-- effectiveMinHeight--
@@ -1151,6 +1153,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
bgSemaphores: make(map[action]chan struct{}), bgSemaphores: make(map[action]chan struct{}),
keyChan: make(chan tui.Event), keyChan: make(chan tui.Event),
eventChan: make(chan tui.Event, 6), // start | (load + result + zero|one) | (focus) | (resize) eventChan: make(chan tui.Event, 6), // start | (load + result + zero|one) | (focus) | (resize)
timerChan: make(chan tui.Event), // unbuffered: every() ticks coalesce when main loop is busy
tui: renderer, tui: renderer,
ttyDefault: opts.TtyDefault, ttyDefault: opts.TtyDefault,
ttyin: ttyin, ttyin: ttyin,
@@ -1158,6 +1161,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
executing: util.NewAtomicBool(false), executing: util.NewAtomicBool(false),
lastAction: actStart, lastAction: actStart,
lastFocus: minItem.Index(), lastFocus: minItem.Index(),
lastActivity: time.Now(),
numLinesCache: make(map[int32]numLinesCacheValue)} numLinesCache: make(map[int32]numLinesCacheValue)}
if opts.AcceptNth != nil { if opts.AcceptNth != nil {
t.acceptNth = opts.AcceptNth(t.delimiter) 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_QUERY="+string(t.input))
env = append(env, "FZF_ACTION="+t.lastAction.Name()) env = append(env, "FZF_ACTION="+t.lastAction.Name())
env = append(env, "FZF_KEY="+t.lastKey) 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_PROMPT="+string(t.promptString))
env = append(env, "FZF_GHOST="+string(t.ghost)) env = append(env, "FZF_GHOST="+string(t.ghost))
env = append(env, "FZF_POINTER="+string(t.pointer)) 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_LINES=%d", t.areaLines))
env = append(env, fmt.Sprintf("FZF_COLUMNS=%d", t.areaColumns)) 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))) 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_LINE=%d", t.clickHeaderLine))
env = append(env, fmt.Sprintf("FZF_CLICK_HEADER_COLUMN=%d", t.clickHeaderColumn)) env = append(env, fmt.Sprintf("FZF_CLICK_HEADER_COLUMN=%d", t.clickHeaderColumn))
env = append(env, fmt.Sprintf("FZF_CLICK_FOOTER_LINE=%d", t.clickFooterLine)) 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 wrap := t.wrap
t.wrap = false t.wrap = false
t.withWindow(t.inputWindow, func() { t.withWindow(t.inputWindow, func() {
line := t.promptLine()
preTask := func(markerClass) int { preTask := func(markerClass) int {
return 1 return 1
} }
t.printHighlighted( 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 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) { func (t *Terminal) minPreviewSize(opts *previewOpts) (int, int) {
minPreviewWidth := 1 + borderColumns(opts.Border(), t.borderWidth) border := opts.Border(t.layout)
minPreviewHeight := 1 + borderLines(opts.Border()) minPreviewWidth := 1 + borderColumns(border, t.borderWidth)
minPreviewHeight := 1 + borderLines(border)
switch opts.position { switch opts.position {
case posLeft, posRight: case posLeft, posRight:
if len(t.scrollbar) > 0 && !opts.Border().HasRight() { if len(t.scrollbar) > 0 && !border.HasRight() {
// Need a column to show scrollbar // Need a column to show scrollbar
minPreviewWidth++ minPreviewWidth++
} }
@@ -2188,7 +2201,7 @@ func (t *Terminal) adjustMarginAndPadding() (int, int, [4]int, [4]int) {
if t.needPreviewWindow() { if t.needPreviewWindow() {
minPreviewWidth, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts) minPreviewWidth, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts)
switch t.activePreviewOpts.position { switch t.activePreviewOpts.position {
case posUp, posDown: case posUp, posDown, posNext:
minAreaHeight += minPreviewHeight minAreaHeight += minPreviewHeight
minAreaWidth = max(minPreviewWidth, minAreaWidth) minAreaWidth = max(minPreviewWidth, minAreaWidth)
case posLeft, posRight: case posLeft, posRight:
@@ -2213,7 +2226,7 @@ func (t *Terminal) hasHeaderWindow() bool {
if t.hasHeaderLinesWindow() { if t.hasHeaderLinesWindow() {
return len(t.header0) > 0 return len(t.header0) > 0
} }
if t.headerBorderShape.Visible() { if t.headerBorderShape.Visible() || t.headerFirst {
return len(t.header0)+t.headerLines > 0 return len(t.header0)+t.headerLines > 0
} }
return t.inputBorderShape.Visible() return t.inputBorderShape.Visible()
@@ -2251,6 +2264,9 @@ func (t *Terminal) determineHeaderLinesShape() (bool, tui.BorderShape) {
// Use header window instead // Use header window instead
if len(t.header0) == 0 { if len(t.header0) == 0 {
if t.headerFirst && shape == tui.BorderPhantom {
return true, shape
}
return false, t.headerBorderShape return false, t.headerBorderShape
} }
@@ -2443,7 +2459,56 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
hasHeaderWindow := t.hasHeaderWindow() hasHeaderWindow := t.hasHeaderWindow()
hasFooterWindow := len(t.footer) > 0 hasFooterWindow := len(t.footer) > 0
hasHeaderLinesWindow, headerLinesShape := t.determineHeaderLinesShape() 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 inputWindowHeight := 2
if t.noSeparatorLine() { if t.noSeparatorLine() {
inputWindowHeight-- inputWindowHeight--
@@ -2463,9 +2528,9 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
// FIXME: Needed? // FIXME: Needed?
if t.needPreviewWindow() { if t.needPreviewWindow() {
_, minPreviewHeight := t.minPreviewSize(t.activePreviewOpts) switch effectivePreviewOpts.position {
switch t.activePreviewOpts.position { case posUp, posDown, posNext:
case posUp, posDown: _, minPreviewHeight := t.minPreviewSize(effectivePreviewOpts)
availableLines -= minPreviewHeight availableLines -= minPreviewHeight
} }
} }
@@ -2616,6 +2681,51 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
// Set up preview window // Set up preview window
noBorder := tui.MakeBorderStyle(tui.BorderNone, t.unicode) 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() { if forcePreview || t.needPreviewWindow() {
var resizePreviewWindows func(previewOpts *previewOpts) var resizePreviewWindows func(previewOpts *previewOpts)
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) { createPreviewWindow := func(y int, x int, w int, h int) {
pwidth := w pwidth := w
pheight := h pheight := h
shape := previewOpts.Border() shape := previewOpts.Border(t.layout)
previewBorder := tui.MakeBorderStyle(shape, t.unicode) previewBorder := tui.MakeBorderStyle(shape, t.unicode)
t.pborder = t.tui.NewWindow(y, x, w, h, tui.WindowPreview, previewBorder, false) t.pborder = t.tui.NewWindow(y, x, w, h, tui.WindowPreview, previewBorder, false)
pwidth -= borderColumns(shape, bw) pwidth -= borderColumns(shape, bw)
@@ -2648,17 +2758,12 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.pwindow.Erase() t.pwindow.Erase()
} }
} }
minPreviewWidth, minPreviewHeight := t.minPreviewSize(previewOpts) // Shared boilerplate for vertical positions (posUp/posDown/posNext):
switch previewOpts.position { // compute pheight, apply the threshold alternative, honor hidden,
case posUp, posDown: // and update listStickToRight. Returns (pheight, true) when the
minWindowHeight := minHeight // caller should return early.
if t.inputless { computeVerticalSize := func() (int, bool) {
minWindowHeight-- pheight, minPreviewHeight := computePreviewSize(previewOpts)
}
if t.noSeparatorLine() {
minWindowHeight--
}
pheight := calculateSize(height, previewOpts.size, minWindowHeight, minPreviewHeight)
if hasThreshold && pheight < previewOpts.threshold { if hasThreshold && pheight < previewOpts.threshold {
t.activePreviewOpts = previewOpts.alternative t.activePreviewOpts = previewOpts.alternative
if forcePreview { if forcePreview {
@@ -2667,22 +2772,27 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
if !previewOpts.alternative.hidden { if !previewOpts.alternative.hidden {
resizePreviewWindows(previewOpts.alternative) resizePreviewWindows(previewOpts.alternative)
} }
return return 0, true
} }
if forcePreview { if forcePreview {
previewOpts.hidden = false previewOpts.hidden = false
} }
if previewOpts.hidden { if previewOpts.hidden {
return return 0, true
} }
listStickToRight = listStickToRight && !previewOpts.Border(t.layout).HasRight()
listStickToRight = listStickToRight && !previewOpts.Border().HasRight()
if listStickToRight { if listStickToRight {
innerWidth++ innerWidth++
width++ width++
} }
return util.Constrain(pheight, minPreviewHeight, availableLines), false
pheight = util.Constrain(pheight, minPreviewHeight, availableLines) }
switch previewOpts.position {
case posUp, posDown:
pheight, done := computeVerticalSize()
if done {
return
}
if previewOpts.position == posUp { if previewOpts.position == posUp {
innerBorderFn(marginInt[0]+pheight, marginInt[3], width, height-pheight) 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) 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) 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: case posLeft, posRight:
minListWidth := minWidth pwidth, _ := computePreviewSize(previewOpts)
if t.listBorderShape.HasLeft() {
minListWidth += 2
}
if t.listBorderShape.HasRight() {
minListWidth++
}
pwidth := calculateSize(width, previewOpts.size, minListWidth, minPreviewWidth)
if hasThreshold && pwidth < previewOpts.threshold { if hasThreshold && pwidth < previewOpts.threshold {
t.activePreviewOpts = previewOpts.alternative t.activePreviewOpts = previewOpts.alternative
if forcePreview { 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) innerMarginInt[0]+shift+inlineTopLines, innerMarginInt[3]+pwidth+m, innerWidth-pwidth-m, innerHeight-shrink-inlineTopLines-inlineBottomLines, tui.WindowList, noBorder, true)
// Clear characters on the margin // Clear characters on the margin
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1 // fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1,border-none --footer-border --footer f --header h --header-border
if !hasListBorder { if !previewOpts.Border(t.layout).HasRight() {
for y := 0; y < innerHeight; y++ { cleanLeft = append(cleanLeft, -2)
t.window.Move(y, -1)
t.window.Print(" ")
}
} }
// fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1,border-none // fzf --bind 'space:toggle-preview' --preview ':' --preview-window left,1 --footer-border --footer f --header h --header-border
if !previewOpts.Border().HasRight() { if !hasListBorder {
for y := 0; y < innerHeight; y++ { cleanLeft = append(cleanLeft, -1)
t.window.Move(y, -2)
t.window.Print(" ")
}
} }
innerBorderFn(marginInt[0], marginInt[3]+pwidth, width-pwidth, height) 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
// fzf --preview 'seq 500' --preview-window border-left --border --list-border // fzf --preview 'seq 500' --preview-window border-left --border --list-border
// fzf --preview 'seq 500' --preview-window border-left --border --input-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 { if listStickToRight {
innerWidth++ innerWidth++
width++ width++
@@ -2845,37 +2966,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
} }
if hasInputWindow { if hasInputWindow {
var btop int btop := inputBorderTop()
// 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
}
}
shift := 0 shift := 0
if !t.inputBorderShape.HasLeft() && t.listBorderShape.HasLeft() { if !t.inputBorderShape.HasLeft() && t.listBorderShape.HasLeft() {
shift += t.borderWidth + 1 shift += t.borderWidth + 1
@@ -2899,11 +2990,11 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
var btop int var btop int
if hasInputWindow && t.headerFirst { if hasInputWindow && t.headerFirst {
if t.layout == layoutReverse { if t.layout == layoutReverse {
btop = w.Top() - shrink + footerBorderHeight btop = w.Top() - shrink + footerBorderHeight - previewNextSize
} else if t.layout == layoutReverseList { } else if t.layout == layoutReverseList {
btop = w.Top() + w.Height() + inputBorderHeight btop = w.Top() + w.Height() + inputBorderHeight + previewNextSize
} else { } else {
btop = w.Top() + w.Height() + inputBorderHeight + headerLinesHeight btop = w.Top() + w.Height() + inputBorderHeight + headerLinesHeight + previewNextSize
} }
} else { } else {
if t.layout == layoutReverse { if t.layout == layoutReverse {
@@ -2934,7 +3025,7 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
if headerFirst { if headerFirst {
if t.layout == layoutDefault { if t.layout == layoutDefault {
btop = w.Top() + w.Height() + inputBorderHeight btop = w.Top() + w.Height() + inputBorderHeight + previewNextSize
} else if t.layout == layoutReverse { } else if t.layout == layoutReverse {
btop = w.Top() - headerLinesHeight - inputBorderHeight btop = w.Top() - headerLinesHeight - inputBorderHeight
} else { } else {
@@ -2973,6 +3064,19 @@ func (t *Terminal) resizeWindows(forcePreview bool, redrawBorder bool) {
t.footerWindow = createInnerWindow(t.footerBorder, t.footerBorderShape, tui.WindowFooter, 0) 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 // 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. // so the label reads as part of that section's frame. Fg stays at list-label.
listLabel, listLabelLen := t.listLabel, t.listLabelLen 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.wborder, listLabel, t.listLabelOpts, listLabelLen, t.listBorderShape, false)
t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, 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.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.headerBorder, t.headerLabel, t.headerLabelOpts, t.headerLabelLen, t.headerBorderShape, false)
t.printLabel(t.footerBorder, t.footerLabel, t.footerLabelOpts, t.footerLabelLen, t.footerBorderShape, 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 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() { func (t *Terminal) placeCursor() {
if t.inputless { if t.inputless {
return return
@@ -3130,7 +3217,7 @@ func (t *Terminal) placeCursor() {
return return
} }
x = min(x, t.window.Width()-1) x = min(x, t.window.Width()-1)
t.move(t.promptLine(), x, false) t.move(0, x, false)
} }
func (t *Terminal) printPrompt() { func (t *Terminal) printPrompt() {
@@ -3184,7 +3271,7 @@ func (t *Terminal) printInfoImpl() {
return return
} }
pos := 0 pos := 0
line := t.promptLine() line := 0
maxHeight := t.window.Height() maxHeight := t.window.Height()
move := func(y int, x int, clear bool) bool { move := func(y int, x int, clear bool) bool {
if y < 0 || y >= maxHeight { if y < 0 || y >= maxHeight {
@@ -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) { func (t *Terminal) printHeaderImpl(window tui.Window, borderShape tui.BorderShape, lines1 []string, lines2 []Item) {
max := t.window.Height() max := t.window.Height()
if !t.inputless && t.inputWindow == nil && window == nil && t.headerFirst {
max--
if !t.noSeparatorLine() {
max--
}
}
var state *ansiState var state *ansiState
needReverse := false needReverse := false
switch t.layout { switch t.layout {
@@ -4868,11 +4949,11 @@ func (t *Terminal) renderPreviewScrollbar(yoff int, barLength int, barStart int)
t.previewer.xw = xw t.previewer.xw = xw
} }
xshift := -1 - t.borderWidth xshift := -1 - t.borderWidth
if !t.activePreviewOpts.Border().HasRight() { if !t.activePreviewOpts.Border(t.layout).HasRight() {
xshift = -1 xshift = -1
} }
yshift := 1 yshift := 1
if !t.activePreviewOpts.Border().HasTop() { if !t.activePreviewOpts.Border(t.layout).HasTop() {
yshift = 0 yshift = 0
} }
for i := yoff; i < height; i++ { for i := yoff; i < height; i++ {
@@ -5807,6 +5888,35 @@ func (t *Terminal) addClickFooterWord(env []string) []string {
return env 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 // Loop is called to start Terminal I/O
func (t *Terminal) Loop() error { func (t *Terminal) Loop() error {
// prof := profile.Start(profile.ProfilePath("/tmp/")) // prof := profile.Start(profile.ProfilePath("/tmp/"))
@@ -5820,13 +5930,13 @@ func (t *Terminal) Loop() error {
if t.activePreviewOpts.aboveOrBelow() { if t.activePreviewOpts.aboveOrBelow() {
if t.activePreviewOpts.size.percent { if t.activePreviewOpts.size.percent {
newContentHeight := int(float64(contentHeight) * 100. / (100. - t.activePreviewOpts.size.size)) 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 { } else {
contentHeight += int(t.activePreviewOpts.size.size) + borderLines(t.activePreviewOpts.Border()) contentHeight += int(t.activePreviewOpts.size.size) + borderLines(t.activePreviewOpts.Border(t.layout))
} }
} else { } else {
// Minimum height if preview window can appear // 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) return min(termHeight, contentHeight+pad)
@@ -6208,7 +6318,7 @@ func (t *Terminal) Loop() error {
case reqRedrawBorderLabel: case reqRedrawBorderLabel:
t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, true) t.printLabel(t.border, t.borderLabel, t.borderLabelOpts, t.borderLabelLen, t.borderShape, true)
case reqRedrawPreviewLabel: 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: case reqReinit, reqResize, reqFullRedraw, reqRedraw:
if req == reqReinit { if req == reqReinit {
t.tui.Resume(t.fullscreen, true) t.tui.Resume(t.fullscreen, true)
@@ -6314,6 +6424,7 @@ func (t *Terminal) Loop() error {
} }
} }
}() }()
t.startTimers(ctx)
previewDraggingPos := -1 previewDraggingPos := -1
barDragging := false barDragging := false
pbarDragging := false pbarDragging := false
@@ -6329,12 +6440,18 @@ func (t *Terminal) Loop() error {
needBarrier = false 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{} events := []util.EventType{}
changed := false changed := false
var newNth *[]Range var newNth *[]Range
var newWithNth *withNthSpec var newWithNth *withNthSpec
var newHeaderLines *int var newHeaderLines *int
var newCommand *commandSpec
var reloadSync bool
var denylist []int32
req := func(evts ...util.EventType) { req := func(evts ...util.EventType) {
for _, event := range evts { for _, event := range evts {
events = append(events, event) events = append(events, event)
@@ -6346,16 +6463,16 @@ func (t *Terminal) Loop() error {
// The main event loop // The main event loop
for loopIndex := int64(0); looping; loopIndex++ { for loopIndex := int64(0); looping; loopIndex++ {
var newCommand *commandSpec
var reloadSync bool
events = []util.EventType{} events = []util.EventType{}
changed = false changed = false
newNth = nil newNth = nil
newWithNth = nil newWithNth = nil
newHeaderLines = nil newHeaderLines = nil
newCommand = nil
reloadSync = false
denylist = nil
beof := false beof := false
queryChanged := false queryChanged := false
denylist := []int32{}
// Special handling of --sync. Activate the interface on the second tick. // Special handling of --sync. Activate the interface on the second tick.
if loopIndex == 1 && t.deferActivation() { if loopIndex == 1 && t.deferActivation() {
@@ -6373,6 +6490,7 @@ func (t *Terminal) Loop() error {
select { select {
case event = <-t.keyChan: case event = <-t.keyChan:
needBarrier = true needBarrier = true
case event = <-t.timerChan:
case event = <-t.eventChan: case event = <-t.eventChan:
// Drain channel to process all queued events at once without rendering // Drain channel to process all queued events at once without rendering
// the intermediate states // the intermediate states
@@ -6437,7 +6555,10 @@ func (t *Terminal) Loop() error {
previousInput := t.input previousInput := t.input
previousCx := t.cx previousCx := t.cx
previousVersion := t.version previousVersion := t.version
t.lastKey = event.KeyName() if event.Type < tui.Invalid {
t.lastKey = event.KeyName()
t.lastActivity = time.Now()
}
updatePreviewWindow := func(forcePreview bool) { updatePreviewWindow := func(forcePreview bool) {
t.resizeWindows(forcePreview, false) t.resizeWindows(forcePreview, false)
req(reqPrompt, reqList, reqInfo, reqHeader, reqFooter) req(reqPrompt, reqList, reqInfo, reqHeader, reqFooter)
@@ -6708,7 +6829,7 @@ func (t *Terminal) Loop() error {
changed = true changed = true
// Deselect items that are now part of the header // Deselect items that are now part of the header
for idx := range t.selected { for idx := range t.selected {
if idx < int32(n) { if int(idx) < n {
delete(t.selected, idx) delete(t.selected, idx)
} }
} }
@@ -7530,6 +7651,20 @@ func (t *Terminal) Loop() error {
} else if t.listBorderShape.HasRight() && t.pborder.EncloseY(my) && mx == t.wborder.Left()+t.wborder.Width()-1 { } else if t.listBorderShape.HasRight() && t.pborder.EncloseY(my) && mx == t.wborder.Left()+t.wborder.Width()-1 {
pborderDragging = 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 +7688,27 @@ func (t *Terminal) Loop() error {
prevSize = t.pwindow.Width() prevSize = t.pwindow.Width()
offset := mx - t.pborder.Left() offset := mx - t.pborder.Left()
newSize = prevSize - offset 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 newSize -= pborderDragging
if newSize < 1 { if newSize < 1 {
@@ -7685,7 +7841,7 @@ func (t *Terminal) Loop() error {
if me.Down { if me.Down {
mxCons := util.Constrain(mx-t.promptLen, 0, len(t.input)) 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 // Prompt
t.cx = mxCons + t.xoffset t.cx = mxCons + t.xoffset
} else if my >= min { } else if my >= min {
@@ -7819,6 +7975,11 @@ func (t *Terminal) Loop() error {
t.previewOpts = t.initialPreviewOpts t.previewOpts = t.initialPreviewOpts
t.previewOpts.command = currentPreviewOpts.command 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 // Split window options
tokens := strings.Split(a.a, "|") tokens := strings.Split(a.a, "|")
if len(tokens[0]) > 0 && t.initialPreviewOpts.hidden { if len(tokens[0]) > 0 && t.initialPreviewOpts.hidden {
+19 -18
View File
@@ -133,22 +133,22 @@ func _() {
_ = x[CtrlAltShiftDelete-122] _ = x[CtrlAltShiftDelete-122]
_ = x[CtrlAltShiftPageUp-123] _ = x[CtrlAltShiftPageUp-123]
_ = x[CtrlAltShiftPageDown-124] _ = x[CtrlAltShiftPageDown-124]
_ = x[Invalid-125] _ = x[Mouse-125]
_ = x[Fatal-126] _ = x[DoubleClick-126]
_ = x[BracketedPasteBegin-127] _ = x[LeftClick-127]
_ = x[BracketedPasteEnd-128] _ = x[RightClick-128]
_ = x[Mouse-129] _ = x[SLeftClick-129]
_ = x[DoubleClick-130] _ = x[SRightClick-130]
_ = x[LeftClick-131] _ = x[ScrollUp-131]
_ = x[RightClick-132] _ = x[ScrollDown-132]
_ = x[SLeftClick-133] _ = x[SScrollUp-133]
_ = x[SRightClick-134] _ = x[SScrollDown-134]
_ = x[ScrollUp-135] _ = x[PreviewScrollUp-135]
_ = x[ScrollDown-136] _ = x[PreviewScrollDown-136]
_ = x[SScrollUp-137] _ = x[Invalid-137]
_ = x[SScrollDown-138] _ = x[Fatal-138]
_ = x[PreviewScrollUp-139] _ = x[BracketedPasteBegin-139]
_ = x[PreviewScrollDown-140] _ = x[BracketedPasteEnd-140]
_ = x[Resize-141] _ = x[Resize-141]
_ = x[Change-142] _ = x[Change-142]
_ = x[BackwardEOF-143] _ = x[BackwardEOF-143]
@@ -163,11 +163,12 @@ func _() {
_ = x[ClickHeader-152] _ = x[ClickHeader-152]
_ = x[ClickFooter-153] _ = x[ClickFooter-153]
_ = x[Multi-154] _ = 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 { func (i EventType) String() string {
if i < 0 || i >= EventType(len(_EventType_index)-1) { 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 { for len(bytes) > 0 {
r, sz := utf8.DecodeRune(bytes) r, sz := utf8.DecodeRune(bytes)
nlcr := r == '\n' || r == '\r' 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 nlcr && !allowNLCR {
if r == '\r' { if r == '\r' {
runes = append(runes, []rune(CR+resetCode)...) runes = append(runes, []rune(CR+resetCode)...)
+10 -6
View File
@@ -196,11 +196,6 @@ const (
CtrlAltShiftPageUp CtrlAltShiftPageUp
CtrlAltShiftPageDown CtrlAltShiftPageDown
Invalid
Fatal
BracketedPasteBegin
BracketedPasteEnd
Mouse Mouse
DoubleClick DoubleClick
LeftClick LeftClick
@@ -214,7 +209,15 @@ const (
PreviewScrollUp PreviewScrollUp
PreviewScrollDown 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 Resize
Change Change
BackwardEOF BackwardEOF
@@ -229,6 +232,7 @@ const (
ClickHeader ClickHeader
ClickFooter ClickFooter
Multi Multi
Every
) )
func (t EventType) AsEvent() Event { func (t EventType) AsEvent() Event {
+66 -8
View File
@@ -78,6 +78,38 @@ class Shell
"rm -f ~/.local/share/fish/fzf_test_history; XDG_CONFIG_HOME=#{confdir} fish" "rm -f ~/.local/share/fish/fzf_test_history; XDG_CONFIG_HOME=#{confdir} fish"
end end
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
end end
@@ -85,12 +117,30 @@ class Tmux
attr_reader :win attr_reader :win
def initialize(shell = :bash) def initialize(shell = :bash)
@shell = shell
@win = go(%W[new-window -d -P -F #I #{Shell.send(shell)}]).first @win = go(%W[new-window -d -P -F #I #{Shell.send(shell)}]).first
go(%W[set-window-option -t #{@win} pane-base-index 0]) 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
send_keys 'function fish_prompt; end; clear', :Enter self.until(&:empty?)
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
retry
end
send_keys 'clear', :Enter
self.until(&:empty?)
end
end end
def kill def kill
@@ -242,11 +292,19 @@ class Tmux
def prepare def prepare
tries = 0 tries = 0
begin begin
self.until(true) do |lines| if @shell == :nushell
message = "Prepare[#{tries}]" message = "Prepare[#{tries}]"
send_keys ' ', 'C-u', :Enter, message, :Left, :Right send_keys 'C-u', 'C-l'
sleep(0.15) sleep 0.2
lines[-1] == message 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 end
rescue Minitest::Assertion rescue Minitest::Assertion
(tries += 1) < 5 ? retry : raise (tries += 1) < 5 ? retry : raise
+100 -2
View File
@@ -1387,6 +1387,85 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_includes lines, '> 1' } tmux.until { |lines| assert_includes lines, '> 1' }
end 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 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.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 tmux.until do
@@ -2074,6 +2153,24 @@ class TestCore < TestInteractive
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') } tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
end 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 def test_change_with_nth_search
input = [ input = [
'alpha bravo charlie', 'alpha bravo charlie',
@@ -2189,6 +2286,7 @@ class TestCore < TestInteractive
FZF_ACTION: 'start', FZF_ACTION: 'start',
FZF_KEY: '', FZF_KEY: '',
FZF_POS: '1', FZF_POS: '1',
FZF_CURRENT_ITEM: '1',
FZF_QUERY: '', FZF_QUERY: '',
FZF_POINTER: '>', FZF_POINTER: '>',
FZF_PROMPT: '> ', FZF_PROMPT: '> ',
@@ -2204,12 +2302,12 @@ class TestCore < TestInteractive
end end
tmux.send_keys :Tab, :Tab tmux.send_keys :Tab, :Tab
tmux.until do 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) assert_equal expected, env_vars.slice(*expected.keys)
end end
tmux.send_keys '99' tmux.send_keys '99'
tmux.until do 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) assert_equal expected, env_vars.slice(*expected.keys)
end end
tmux.send_keys :Space tmux.send_keys :Space
+175 -60
View File
@@ -243,6 +243,90 @@ class TestLayout < TestInteractive
tmux.until { assert_block(expected, it) } tmux.until { assert_block(expected, it) }
end 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 def test_height_range_overflow
tmux.send_keys 'seq 100 | fzf --height ~5 --info=inline --border rounded', :Enter tmux.send_keys 'seq 100 | fzf --height ~5 --info=inline --border rounded', :Enter
expected = <<~OUTPUT expected = <<~OUTPUT
@@ -1227,75 +1311,106 @@ class TestLayout < TestInteractive
def test_combinations def test_combinations
skip unless ENV['LONGTEST'] skip unless ENV['LONGTEST']
base = [ begin
'--pointer=@', base = [
'--exact', '--pointer=@',
'--query=123', '--exact',
'--header="$(seq 101 103)"', '--query=123',
'--header-lines=3', '--header="$(seq 101 103)"',
'--footer "$(seq 201 203)"', '--header-lines=3',
'--preview "echo foobar"' '--footer "$(seq 201 203)"',
] '--preview "echo foobar"'
options = [ ]
['--separator==', '--no-separator'], options = [
['--info=default', '--info=inline', '--info=inline-right'], ['--separator==', '--no-separator'],
['--no-input-border', '--input-border'], ['--info=default', '--info=inline', '--info=inline-right'],
['--no-header-border', '--header-border=none', '--header-border'], ['--no-input-border', '--input-border'],
['--no-header-lines-border', '--header-lines-border'], ['--no-header-border', '--header-border=none', '--header-border'],
['--no-footer-border', '--footer-border'], ['--no-header-lines-border', '--header-lines-border'],
['--no-list-border', '--list-border'], ['--no-footer-border', '--footer-border'],
['--preview-window=right', '--preview-window=up', '--preview-window=down', '--preview-window=left'], ['--no-list-border', '--list-border'],
['--header-first', '--no-header-first'], ['--preview-window=right', '--preview-window=up', '--preview-window=down', '--preview-window=left', '--preview-window=next'],
['--layout=default', '--layout=reverse', '--layout=reverse-list'] ['--header-first', '--no-header-first'],
] ['--layout=default', '--layout=reverse', '--layout=reverse-list']
# Combination of all options ]
combinations = options[0].product(*options.drop(1)) # Combination of all options
combinations.each_with_index do |combination, index| combinations = options[0].product(*options.drop(1))
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')
# Input # Run workers in parallel, each with its own pre-created tmux window.
input = lines.index { it.include?('> 123') } # Tmux setup/teardown is serialized in the main thread to avoid racing
assert(input) # `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 opts = base + combination
info = lines.index { it.include?('11/997') } command = %(seq 1001 2000 | #{FZF} #{opts.join(' ')})
assert(info) 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 # Info
item1 = lines.index { it.include?('1230') } info = lines.index { it.include?('11/997') }
item2 = lines.index { it.include?('1231') } assert(info)
assert_equal(item1, layout == 'default' ? item2 + 1 : item2 - 1)
# Preview assert(layout == 'reverse' ? input <= info : input >= info)
assert(lines.any? { it.include?('foobar') })
# Header # List
header1 = lines.index { it.include?('101') } item1 = lines.index { it.include?('1230') }
header2 = lines.index { it.include?('102') } item2 = lines.index { it.include?('1231') }
assert_equal(header2, header1 + 1) assert_equal(item1, layout == 'default' ? item2 + 1 : item2 - 1)
assert((layout == 'reverse') == header_first ? input > header1 : input < header1)
# Footer # Preview
footer1 = lines.index { it.include?('201') } assert(lines.any? { it.include?('foobar') })
footer2 = lines.index { it.include?('202') }
assert_equal(footer2, footer1 + 1)
assert(layout == 'reverse' ? footer1 > item2 : footer1 < item2)
# Header lines # Header
hline1 = lines.index { it.include?('1001') } header1 = lines.index { it.include?('101') }
hline2 = lines.index { it.include?('1002') } header2 = lines.index { it.include?('102') }
assert_equal(hline1, layout == 'default' ? hline2 + 1 : hline2 - 1) assert_equal(header2, header1 + 1)
assert(layout == 'reverse' ? hline1 > header1 : hline1 < header1) 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 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
end end
+43
View File
@@ -383,6 +383,49 @@ class TestPreview < TestInteractive
end end
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 def test_preview_follow_wrap
tmux.send_keys "seq 1 | #{FZF} --preview 'seq 1000' --preview-window right,2,follow,wrap", :Enter 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 } tmux.until { |lines| assert_equal 1, lines.match_count }
+120
View File
@@ -1103,3 +1103,123 @@ class TestFish < TestBase
end end
end 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" tabe = "tabe"
Iterm = "Iterm" Iterm = "Iterm"
ser = "ser" ser = "ser"
Slq = "Slq"
[files] [files]
extend-exclude = ["README.md", "*.s"] extend-exclude = ["README.md", "*.s"]
+7
View File
@@ -114,6 +114,13 @@ if [ -d "${fish_dir}/functions" ]; then
fi fi
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") config_dir=$(dirname "$prefix_expand")
if [[ $xdg == 1 ]] && [[ $config_dir == */fzf ]] && [[ -d $config_dir ]]; then if [[ $xdg == 1 ]] && [[ $config_dir == */fzf ]] && [[ -d $config_dir ]]; then
rmdir "$config_dir" rmdir "$config_dir"