Compare commits

...

73 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Evaluated dynamically so change-header/toggle-header update it
- Inline header is drawn inside list border and not adjacent to input;
  decide from section actually next to it
- Separator is kept when a preview window can be at 'next' position,
  right next to the input section
2026-07-18 16:43:23 +09:00
Junegunn Choi e877b6f846 Update CHANGELOG 2026-07-17 14:44:20 +09:00
Junegunn Choi b9cbe3a6b8 Publish .deb package for each release
Close #4859
2026-07-17 14:44:20 +09:00
Junegunn Choi 7e134f0a78 Update CHANGELOG 2026-07-17 10:47:56 +09:00
Junegunn Choi 3c0de84dab Skip redundant SGR sequences in light renderer
Track the last emitted SGR sequence and skip no-op transitions.
Reduces rendering output by 10-23% depending on the scenario.
2026-07-17 10:34:42 +09:00
Junegunn Choi 374b01242d Wrap frames in synchronized update mode (2026)
Reduce flickering during rendering on supported terminals.
2026-07-16 23:20:29 +09:00
Junegunn Choi c0860259fa Use DECSC/DECRC instead of CSI s/u
Honored by more terminals, e.g. fixes cursor restoration with
--height --no-clear inside nvim terminal.
2026-07-16 23:00:39 +09:00
Junegunn Choi 1462f1358d Comment cleanup 2026-07-15 18:30:17 +09:00
Junegunn Choi 8e340dabbc Use CHA for absolute horizontal cursor movement
Related:
- #4858
- https://github.com/zellij-org/zellij/issues/5370
2026-07-15 18:23:08 +09:00
Junegunn Choi 4dcedc01df Comment why floating pane requires TMUX_PANE
Blocking run-shell suspends client key processing until the command
exits, so a floating pane deadlocks; popup input bypasses the suspended
path. Fall back to display-popup when TMUX_PANE is not set.
2026-07-15 17:45:08 +09:00
Simon Désaulniers 24832e97ef nushell: fix 0.114.0 nushell str downcase deprecated error
We switch to `str lowercase` and add a note for the Nushell<0.114.0 user
that should display at runtime.

Close #4857
2026-07-10 13:49:47 +09:00
Junegunn Choi 6765f464a6 0.74.0 2026-07-06 22:21:21 +09:00
Junegunn Choi c110aec3c4 Update CHANGELOG 2026-07-06 21:06:27 +09:00
Junegunn Choi e54f11c64e Default to native border for Zellij floating pane (#4854)
The native border is the handle for moving and resizing the pane with
the mouse, so use it by default, consistent with tmux. fzf draws its
own border only when a border style is explicitly specified with
--border. Extract the shared native-border decision into a helper.
2026-07-06 21:04:35 +09:00
Junegunn Choi f7392a8b63 Pre-create become file with O_EXCL
Prevents another user on a shared TMPDIR from planting a file or a
symbolic link at the predictable path while fzf is running, like the
exit status file of the floating pane. Reject an empty become command.
2026-07-05 22:00:55 +09:00
Junegunn Choi eee92b1b2a Clear border text of the floating pane when no label is given 2026-07-05 20:02:36 +09:00
Junegunn Choi 77e6394f50 Set --border-label as the title of the tmux floating pane (#4853)
The pane sets the options itself before running fzf, so that they are
in place no matter how quickly the command exits, targeted at
$TMUX_PANE; the default target would resolve to the active pane of
the session's current window.

- pane-border-format is set to '#{pane_title}' so that the label is
  displayed on the border when pane-border-status is enabled;
  pane-border-status itself is a window option in released tmux
  versions and is left alone
- When a border style is explicitly specified with --border, a popup
  is used instead of a floating pane so that the fzf-drawn border is
  the only border shown; give 'border-native' to force a floating pane
- 'none' and 'line' are treated as no border; fzf draws no box for
  either, so the label is displayed on the native border
- Remove 'border-fzf' which is now redundant; it was never released
- The title is escaped for select-pane -T which expands format
  expressions; a lone ';' is escaped as tmux would treat it as a
  command separator
- The label is skipped when ANSI stripping leaves an empty string
- --border-label-pos is ignored
- Fix remain-on-exit set on the original pane instead of the floating
  pane
2026-07-05 19:35:34 +09:00
Junegunn Choi 1e31e5dfbe Use floating pane instead of popup on tmux 3.7 or above (#4850) (#4852)
Unlike a popup, a floating pane is not modal; you can switch to other
panes and windows while fzf is running, move and resize the pane with
the mouse, zoom it to fullscreen, and use copy-mode in it.

- Floating pane always has a native border, so 'border-native' is
  implied; give new 'border-fzf' option to fall back to a popup where
  fzf draws its own border
- Popup is also used on tmux versions below 3.7, or when the window is
  too small to fit a floating pane
- new-pane does not block until the command finishes and does not
  propagate the exit status; block on a wait-for channel signaled by
  the pane and pass the exit status through a temporary file
- Watchdog process signals the channel when the pane is closed
  abnormally (e.g. kill-pane)
- Kill the pane when the proxy process is interrupted, like a popup
  dying with its client
- Unzoom the window before creating the floating pane; doing so over a
  zoomed window crashes the tmux server on 3.7b, and newer versions of
  tmux unzoom the window anyway
- Floating pane size excludes the border and the position is that of
  the content area; treat the requested size as the total footprint
  including the border for consistency with popups
- Close the pane on exit even when remain-on-exit is on
- Pre-create the exit status file with O_EXCL to prevent tampering on
  a shared TMPDIR
2026-07-05 14:15:11 +09:00
Junegunn Choi a1fb01462d Copy input snapshot on bracketed paste begin
[]rune(t.input) is a same-type conversion that aliases t.input;
in-place query edits during the paste corrupt the snapshot and skew
the paste-end comparison.
2026-07-05 11:22:02 +09:00
Junegunn Choi 978ded5cd0 Pass bracketed paste actions through wait/track block
A pasted character firing a wait-arming binding swallowed the
subsequent bracketed-paste-end, leaving t.pasting set forever and
suppressing queryChanged for all later input. Let paste begin/end
through the block so pasting state is maintained and the search for
the edited query is dispatched.
2026-07-05 11:22:02 +09:00
Junegunn Choi 743930a38d Run bg-transform callbacks while wait/track-blocked
Background transform results are unrelated to the block; dropping them
would silently lose completed work. Applies both the dedicated
bg-transform-* setters and actions parsed from generic bg-transform
output (inBgCallback); a 'wait' in such output joins the ongoing wait.
Searches initiated by a bg result are not waited for.
2026-07-05 11:22:02 +09:00
Junegunn Choi 8ec443ba40 Add 'wait' action to block until search completion (#4825)
Blocks execution of subsequent actions until the current search
completes, so motion actions operate on the complete result set.

  # Move to the best match only after the search is done
  fzf --bind 'start:change-query(foo)+wait+best'

- Blocks when a search is in progress, will be triggered by preceding
  actions, or the query was edited in the same binding; no-op otherwise
- Initial input load counts as a search in progress, so 'start:wait'
  blocks until the input is fully loaded and searched
- User input ignored while blocked, including --expect keys; keys bound
  to abort/cancel cancel the wait and discard pending actions instead
- Actions deferred by the wait survive across nested lists (trigger
  chords) and cancel/re-arm chains
- Debounced feedback after 200ms: dimmed prompt, hidden cursor, and
  (..) on the info line
2026-07-05 11:22:02 +09:00
Jason Han 9e2856559d Add mouse wheel tests 2026-06-28 22:15:03 +09:00
Jason Han 9e4f8e7b7a Fix horizontal mouse wheel being treated as vertical scroll
Close #4848
2026-06-28 22:15:03 +09:00
Junegunn Choi f2e451596c Skip FZF_CURRENT_ITEM export for items larger than 64 KB
A huge item can overflow ARG_MAX and break exec with E2BIG, failing
preview and other child commands. (#4806)
2026-06-23 20:50:12 +09:00
dependabot[bot] 109057877b Bump actions/checkout from 5 to 7 (#4844)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-23 13:36:49 +09:00
dependabot[bot] 0fd47770fc Bump ruby/setup-ruby from 1.308.0 to 1.314.0 (#4843)
Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.308.0 to 1.314.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/97ecb7b512899eb71ab1bf2310a624c6f1589ac6...9eb537ca036ebaed86729dcb9309076e4c5c3b74)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-23 13:36:36 +09:00
bitraid 3c9965a61a fish: Make CTRL-R work on empty/invalid $fish_color{normal,comment} (#4831)
Fix #4767
Fix #4830
2026-06-14 17:52:45 +03:00
Junegunn Choi 3951df8537 Bind alt-left/alt-right to backward-word/forward-word by default
Fix #4833
2026-06-14 21:55:48 +09:00
Junegunn Choi 5dd698b869 Add result-final event
- Fires like result, but only after the input stream closes
- Use for one-shot per-query actions that would otherwise re-fire on
  every intermediate snapshot during loading

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

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

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

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

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

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

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

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

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

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

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

Thanks to @matheus-pacifico for the report.

Close #4813
2026-05-28 23:02:34 +09:00
Copilot 5ef8dea36e Prevent nushell source contamination in install shell loop (#4812) 2026-05-28 10:26:43 +09:00
Junegunn Choi 845752f305 Update README 2026-05-25 22:28:11 +09:00
Junegunn Choi 9a61a1457d Bump action versions for Node.js 24 support 2026-05-25 21:11:25 +09:00
Junegunn Choi dfcacb443d Allow manual dispatch of Winget workflow 2026-05-25 21:09:25 +09:00
Junegunn Choi 5412f39b84 Use PAT in release workflow
Releases created with the default GITHUB_TOKEN do not trigger other
workflows (anti-recursion). Winget workflow therefore did not fire
on v0.73.1. Switch to RELEASE_PAT (registered in the `release`
environment) so the release is authored by the user.
2026-05-25 21:08:48 +09:00
Junegunn Choi 07c5cd4185 Fix typo in CHANGELOG 2026-05-25 14:39:05 +09:00
51 changed files with 2792 additions and 236 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
+1 -1
View File
@@ -9,6 +9,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 'Checkout Repository' - name: 'Checkout Repository'
uses: actions/checkout@v5 uses: actions/checkout@v7
- name: 'Dependency Review' - name: 'Dependency Review'
uses: actions/dependency-review-action@v5 uses: actions/dependency-review-action@v5
+1 -1
View File
@@ -12,6 +12,6 @@ jobs:
label: label:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@v6 - uses: actions/labeler@v7
with: with:
configuration-path: .github/labeler.yml configuration-path: .github/labeler.yml
+3 -3
View File
@@ -18,17 +18,17 @@ jobs:
build: build:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: "1.23" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1 uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with: with:
ruby-version: 3.4.6 ruby-version: 3.4.6
+3 -3
View File
@@ -15,17 +15,17 @@ jobs:
build: build:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: "1.23" go-version: "1.23"
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@97ecb7b512899eb71ab1bf2310a624c6f1589ac6 # v1 uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with: with:
ruby-version: 3.0.0 ruby-version: 3.0.0
+4 -4
View File
@@ -19,11 +19,11 @@ jobs:
runs-on: macos-latest runs-on: macos-latest
environment: release environment: release
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-go@v5 - uses: actions/setup-go@v7
with: with:
go-version: stable go-version: stable
@@ -60,7 +60,7 @@ jobs:
| tee tmp/release-note | tee tmp/release-note
- name: Run goreleaser - name: Run goreleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v7
with: with:
version: latest version: latest
args: >- args: >-
@@ -68,7 +68,7 @@ jobs:
&& 'release --clean --release-notes tmp/release-note' && 'release --clean --release-notes tmp/release-note'
|| 'release --snapshot --clean --skip=publish' }} || 'release --snapshot --clean --skip=publish' }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
+1 -1
View File
@@ -6,5 +6,5 @@ jobs:
name: Spell Check with Typos name: Spell Check with Typos
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v7
- uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4 - uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4
+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 }}
+19
View File
@@ -96,6 +96,25 @@ archives:
files: files:
- non-existent* - non-existent*
nfpms:
- package_name: fzf
file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
vendor: junegunn
homepage: https://github.com/junegunn/fzf
maintainer: Junegunn Choi <junegunn.c@gmail.com>
description: Command-line fuzzy finder
license: MIT
section: utils
formats:
- deb
ids:
- fzf
contents:
- src: man/man1/fzf.1
dst: /usr/share/man/man1/fzf.1
- src: LICENSE
dst: /usr/share/doc/fzf/copyright
release: release:
github: github:
owner: junegunn owner: junegunn
+92 -1
View File
@@ -1,10 +1,101 @@
CHANGELOG CHANGELOG
========= =========
0.74.2
------
- Performance optimizations for short queries
- Short queries scan the largest candidate sets, and the first keystroke scans the whole input
- Single-character queries are up to 2.4x faster
- Two-character queries are up to 1.4x faster
- Faster sorting of search results, skipping redundant radix passes
- `change-border-label` and `transform-border-label` now work on the native border of a tmux or Zellij floating pane
- Fixed Kitty graphics sequences from a preview command being taken by tmux as pane title requests
- Fixed an image at the top of the preview being torn by `--preview-window ~N`
- Fixed nondeterministic match highlight positions
- Fixed signal and resize handlers persisting after `Run()` returns when fzf is used as a library
- fzf now detects terminal resize on Windows in `--height` mode (#4790) (@Cyrus580529)
- fish: fixed history command being affected by user initialization scripts, and improved timestamp colors in CTRL-R (#4862) (@bitraid)
0.74.1
------
- The default separator on the info line is no longer shown when the input section is already visually separated from the list section by a border line
```sh
# No separator shown below the header border
fzf --style full --input-border none --header foo
# Separator shown; no border separates the input section from the list section
fzf --style full --input-border none --header foo --no-header-border
# No separator below the border of the preview window at 'next' position
fzf --preview : --preview-window next
# Conversely, separator is now shown when the input border does not draw
# a line facing the list section
fzf --input-border bottom
```
- Rendering improvements
- Each frame is now wrapped in synchronized update mode (mode 2026) to reduce flickering on supported terminals
- Reduced rendering output by 10-23% by skipping redundant SGR sequences
- Fixed ghost characters and misplaced colors inside Zellij by using CHA instead of CR + CUF for horizontal cursor movement (#4858, zellij-org/zellij#5370)
- Fixed cursor restoration on exit with `--height --no-clear` inside Neovim terminal by using DECSC/DECRC instead of `CSI s`/`CSI u`
- nushell: fixed deprecation error of `str downcase` on nushell 0.114.0 or above (#4857) (@sim590)
- Each release now includes `.deb` packages for easy installation on Debian-based distros (#4859)
0.74.0
------
_Release highlights: https://junegunn.github.io/fzf/releases/0.74.0/_
- On tmux 3.7 or above, `--popup` starts fzf in a floating pane instead of a popup (#4850)
- Unlike a popup, a floating pane is not modal; you can switch to other panes and windows while fzf is running, move and resize the pane with the mouse, zoom it to fullscreen, and use copy-mode in it
- A floating pane always has a native border, which is what makes the pane movable and resizable, so `border-native` is implied
- A popup is used instead when a border style is explicitly specified with `--border`, so that the fzf-drawn border is the only border shown (`none` and `line` are treated as no border)
```sh
fzf --popup --border
```
- `--border-label` is set as the title of the floating pane, and is displayed on the border if `pane-border-status` is enabled in tmux
```sh
fzf --popup --border-label ' fzf '
```
- On Zellij, `--popup` uses the native border by default, consistent with tmux, so that the pane can be moved and resized with the mouse; fzf draws its own border when a border style is explicitly specified with `--border`
- `--border-label` is set as the name of the pane, displayed on the native border
- Added `result-final` event, a variant of `result` that is not triggered while the input stream is still open (#4835)
- Use it for one-shot, per-query actions that would otherwise re-fire on every intermediate snapshot during loading
```sh
# 'result' fires per intermediate snapshot (header keeps updating during load);
# 'result-final' fires once after the stream closes (footer shows the final count)
(seq 100; sleep 1; seq 100) | fzf --query 1 \
--bind 'result:transform-header(echo result: $FZF_MATCH_COUNT),result-final:transform-footer(echo final: $FZF_MATCH_COUNT)'
```
- Added `wait` action to block subsequent actions until search completes (#4825)
- Useful for chaining query-changing actions with motion actions to ensure operations on complete results
```sh
# Wait for search to complete before moving to the best match
fzf --bind 'start:change-query(foo)+wait+best'
```
- The initial loading of the input is also considered a search in progress, so `start:wait` can be used to wait until the input is fully loaded
```sh
# Move to the last item after the input is fully loaded
(seq 1000; sleep 1; seq 1001 2000) | fzf --bind 'start:wait+last'
```
- Bound `alt-left` to `backward-word` and `alt-right` to `forward-word` by default (#4833)
- Bug fixes and improvements
- Skip `$FZF_CURRENT_ITEM` export when the item is larger than 64 KB; a huge item can overflow `ARG_MAX` and break preview and other child commands with `E2BIG` (#4806)
- `transform` and `bg-transform` now allow a bare `put` action in the output to insert the key that triggered the action
```sh
# Insert the typed key ('a') into the query
fzf --bind 'a:transform:echo put'
```
- `ALT-C` in zsh no longer resolves symbolic links when changing the directory, consistent with the `cd` builtin (#4816) (@silverneko)
- Fixed horizontal mouse wheel events being treated as vertical scrolling (#4848) (@jason5122)
- Fixed `bw` theme not inheriting overridden colors
- fish: `CTRL-R` now works when `$fish_color_normal` or `$fish_color_comment` is empty or invalid (#4831) (@bitraid)
- Fixed empty-shell detection in the install script (#4813)
- Fixed the install script writing nushell source lines into the config files of other shells (#4812)
0.73.1 0.73.1
------ ------
- Bug fixes - 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 (#2395) - 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) - 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 0.73.0
+11 -1
View File
@@ -1,5 +1,15 @@
FROM rubylang/ruby:3.4.1-noble FROM rubylang/ruby:3.4.1-noble
RUN apt-get update -y && apt install -y git make golang zsh fish tmux RUN apt-get update && apt-get install -y git make golang zsh fish tmux
# https://www.nushell.sh/book/installation.html
RUN <<EOF
set -ex
apt-get install -y wget gnupg
wget -qO- https://apt.fury.io/nushell/gpg.key | gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | tee /etc/apt/sources.list.d/fury-nushell.list
apt-get update
apt-get install -y nushell
EOF
RUN gem install --no-document -v 5.22.3 minitest RUN gem install --no-document -v 5.22.3 minitest
RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc RUN echo '. /usr/share/bash-completion/completions/git' >> ~/.bashrc
RUN echo '. ~/.bashrc' >> ~/.bash_profile RUN echo '. ~/.bashrc' >> ~/.bash_profile
+1 -1
View File
@@ -2,7 +2,7 @@ GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
ast (2.4.2) ast (2.4.2)
json (2.9.1) json (2.19.9)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
minitest (5.25.4) minitest (5.25.4)
parallel (1.26.3) parallel (1.26.3)
+10
View File
@@ -97,6 +97,16 @@ test: $(SOURCES)
itest: itest:
ruby test/runner.rb ruby test/runner.rb
# Actively fuzz the matcher fast paths against the general algorithm.
# Go fuzzes one target at a time, so iterate. Override duration with
# FUZZTIME (e.g. make fuzz FUZZTIME=5m).
FUZZTIME ?= 30s
fuzz:
@for t in FuzzFuzzyMatchV2Single FuzzFuzzyMatchV2Two; do \
echo "== $$t =="; \
$(GO) test -run '^$$' -fuzz "^$$t$$" -fuzztime $(FUZZTIME) ./src/algo || exit 1; \
done
bench: bench:
cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem cd src && SHELL=/bin/sh GOOS= $(GO) test -v -tags "$(TAGS)" -run=Bench -bench=. -benchmem
+9 -9
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, Nushell, Vim, and Neovim - **Batteries-included** // Comes with integrations for Bash, Zsh, Fish, Nushell, Vim, and Neovim
Table of Contents Table of Contents
----------------- -----------------
+1 -1
View File
@@ -18,7 +18,7 @@ triggered by a tag push.
2. Verify file consistency, sign the tag, and push the tag. 2. Verify file consistency, sign the tag, and push the tag.
```sh ```sh
make tag VERSION=0.73.1 make tag VERSION=0.74.1
``` ```
`make tag` runs `prerelease` first (checks that the version `make tag` runs `prerelease` first (checks that the version
+1 -1
View File
@@ -4,7 +4,7 @@ require (
github.com/charlievieth/fastwalk v1.0.14 github.com/charlievieth/fastwalk v1.0.14
github.com/gdamore/tcell/v2 v2.9.0 github.com/gdamore/tcell/v2 v2.9.0
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741
github.com/mattn/go-isatty v0.0.22 github.com/mattn/go-isatty v0.0.24
github.com/rivo/uniseg v0.4.7 github.com/rivo/uniseg v0.4.7
golang.org/x/sys v0.35.0 golang.org/x/sys v0.35.0
golang.org/x/term v0.34.0 golang.org/x/term v0.34.0
+2 -2
View File
@@ -8,8 +8,8 @@ github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMD
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+5 -5
View File
@@ -2,7 +2,7 @@
set -u set -u
version=0.73.1 version=0.74.1
auto_completion= auto_completion=
key_bindings= key_bindings=
update_config=2 update_config=2
@@ -227,13 +227,13 @@ fi
for s in $shells; do for s in $shells; do
bin=$s bin=$s
[[ "$s" = nushell ]] && bin=nu [[ $s == nushell ]] && bin=nu
if ! command -v "$bin" > /dev/null; then if ! command -v "$bin" > /dev/null; then
shells=${shells/$s/} shells=${shells/$s/}
fi fi
done done
if [[ ${#shells} -lt 3 ]]; then if [[ -z ${shells// /} ]]; then
echo "No shell configuration to be updated." echo "No shell configuration to be updated."
exit 0 exit 0
fi fi
@@ -252,10 +252,10 @@ 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
[[ $shell == nushell ]] && continue
src=${prefix_expand}.${shell} src=${prefix_expand}.${shell}
echo -n "Generate $src ... " echo -n "Generate $src ... "
@@ -442,7 +442,7 @@ if [[ $shells =~ fish ]]; then
fi fi
fi fi
if [[ "$shells" =~ nushell ]]; then if [[ $shells =~ nushell ]]; then
if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then if [[ $key_bindings -eq 1 || $auto_completion -eq 1 ]]; then
echo "Setting up Nushell integration ..." echo "Setting up Nushell integration ..."
nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first') nushell_autoload_dir=$(nu -c '$nu.user-autoload-dirs | first')
+1 -1
View File
@@ -1,4 +1,4 @@
$version="0.73.1" $version="0.74.1"
$fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition $fzf_base=Split-Path -Parent $MyInvocation.MyCommand.Definition
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/junegunn/fzf/src/protector" "github.com/junegunn/fzf/src/protector"
) )
var version = "0.73" var version = "0.74"
var revision = "devel" var revision = "devel"
//go:embed shell/key-bindings.bash //go:embed shell/key-bindings.bash
+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 "May 2026" "fzf 0.73.1" "fzf\-tmux - open fzf in tmux split pane" .TH fzf\-tmux 1 "Jul 2026" "fzf 0.74.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
+88 -6
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 "May 2026" "fzf 0.73.1" "fzf - a command-line fuzzy finder" .TH fzf 1 "Jul 2026" "fzf 0.74.1" "fzf - a command-line fuzzy finder"
.SH NAME .SH NAME
fzf - a command-line fuzzy finder fzf - a command-line fuzzy finder
@@ -226,7 +226,7 @@ Enable processing of ANSI color codes
Synchronous search for multi-staged filtering. If specified, fzf will launch Synchronous search for multi-staged filtering. If specified, fzf will launch
the finder only after the input stream is complete and the initial filtering the finder only after the input stream is complete and the initial filtering
and the associated actions (bound to any of \fBstart\fR, \fBload\fR, and the associated actions (bound to any of \fBstart\fR, \fBload\fR,
\fBresult\fR, or \fBfocus\fR) are complete. \fBresult\fR, \fBresult\-final\fR, or \fBfocus\fR) are complete.
.RS .RS
e.g. \fB# Avoid rendering both fzf instances at the same time e.g. \fB# Avoid rendering both fzf instances at the same time
@@ -418,10 +418,29 @@ section (default: \fB10+\fR).
Ignored when \fB\-\-height\fR is not specified or set as an absolute value. Ignored when \fB\-\-height\fR is not specified or set as an absolute value.
.TP .TP
.BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]" .BI "\-\-popup" "[=[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native]]"
Start fzf in a tmux popup or in a Zellij floating pane (default Start fzf in a tmux or Zellij floating pane (default \fBcenter,50%\fR).
\fBcenter,50%\fR). Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you Requires tmux 3.3+ or Zellij 0.44+. This option is ignored if you
are not running fzf inside tmux or Zellij. \fB\-\-tmux\fR is an alias for this option. are not running fzf inside tmux or Zellij. \fB\-\-tmux\fR is an alias for this option.
On tmux 3.7 or above and on Zellij, the floating pane is not modal; you can
switch to other panes and windows while fzf is running, and move and resize
the pane with the mouse. The native border of the pane is the handle for
moving and resizing it, so it is used by default and \fBborder\-native\fR is
implied. \fB\-\-border\-label\fR is displayed on the native border, and
\fBchange\-border\-label\fR and \fBtransform\-border\-label\fR update it
(\fB\-\-border\-label\-pos\fR is ignored). On tmux, fzf holds the label in
the \fB@fzf\-border\-label\fR option of the pane and sets its
\fBpane\-border\-format\fR to read it back, so the label is displayed if
\fBpane\-border\-status\fR is enabled in tmux. The title of the pane is left
alone. On Zellij, the label is the name of the pane.
fzf draws its own border instead when a border style is explicitly specified
with \fB\-\-border\fR, so that it is the only border shown. \fBnone\fR and
\fBline\fR are treated as no border. Give \fBborder\-native\fR to keep the
native border nonetheless. On tmux, the fzf\-drawn border is shown in a modal
popup, since the native border of a tmux floating pane cannot be removed;
this is also the case on tmux versions below 3.7.
e.g. e.g.
\fB# Popup in the center with 70% width and height \fB# Popup in the center with 70% width and height
fzf \-\-popup 70% fzf \-\-popup 70%
@@ -808,6 +827,10 @@ A synonym for \fB\-\-info=hidden\fB
The given string will be repeated to form the horizontal separator on the info The given string will be repeated to form the horizontal separator on the info
line (default: '─' or '\-' depending on \fB\-\-no\-unicode\fR). line (default: '─' or '\-' depending on \fB\-\-no\-unicode\fR).
Unless explicitly specified, the separator is not displayed if the input
section is already visually separated from the list section by a border line
(e.g. \fB\-\-input\-border\fR or \fB\-\-header\-border\fR).
ANSI color codes are supported. ANSI color codes are supported.
.TP .TP
@@ -1534,6 +1557,9 @@ fzf exports the following environment variables to its child processes.
.PP .PP
.B FZF_CURRENT_ITEM .B FZF_CURRENT_ITEM
is omitted when the item contains a NUL byte, because exec(2) cannot pass it. is omitted when the item contains a NUL byte, because exec(2) cannot pass it.
It is also omitted when the item is larger than 64 KB, so that a huge item
cannot overflow the environment size limit and break preview and other child
commands.
.SH EXTENDED SEARCH MODE .SH EXTENDED SEARCH MODE
@@ -1855,6 +1881,20 @@ e.g.
# * Note that you can't use 'change' event in this case because the second position may not be available # * Note that you can't use 'change' event in this case because the second position may not be available
fzf \-\-sync \-\-bind 'result:transform:[[ \-z {q} ]] && echo "pos(2)"'\fR fzf \-\-sync \-\-bind 'result:transform:[[ \-z {q} ]] && echo "pos(2)"'\fR
.RE .RE
\fIresult\-final\fR
.RS
Same as \fIresult\fR, but suppressed while the input stream is still open. Use
this when you want a one-shot action per query instead of one per intermediate
snapshot during loading.
e.g.
\fB# 'result' fires per intermediate snapshot (header keeps updating during load);
# 'result-final' fires once after the stream closes (footer shows the final count)
(seq 100; sleep 1; seq 100) | fzf \-\-query 1 \\
\-\-bind 'result:transform\-header(echo result: $FZF_MATCH_COUNT),result\-final:transform\-footer(echo final: $FZF_MATCH_COUNT)'\fR
.RE
\fIchange\fR \fIchange\fR
.RS .RS
Triggered whenever the query string is changed Triggered whenever the query string is changed
@@ -1998,7 +2038,7 @@ A key or an event can be bound to one or more of the following actions.
\fBbackward\-kill\-subword\fR \fBbackward\-kill\-subword\fR
\fBbackward\-kill\-word\fR \fIalt\-bs\fR \fBbackward\-kill\-word\fR \fIalt\-bs\fR
\fBbackward\-subword\fR \fBbackward\-subword\fR
\fBbackward\-word\fR \fIalt\-b shift\-left\fR \fBbackward\-word\fR \fIalt\-b shift\-left alt\-left\fR
\fBbecome(...)\fR (replace fzf process with the specified command; see below for the details) \fBbecome(...)\fR (replace fzf process with the specified command; see below for the details)
\fBbeginning\-of\-line\fR \fIctrl\-a home\fR \fBbeginning\-of\-line\fR \fIctrl\-a home\fR
\fBbell\fR (ring the terminal bell) \fBbell\fR (ring the terminal bell)
@@ -2045,7 +2085,7 @@ A key or an event can be bound to one or more of the following actions.
\fBfirst\fR (move to the first match; same as \fBpos(1)\fR) \fBfirst\fR (move to the first match; same as \fBpos(1)\fR)
\fBforward\-char\fR \fIctrl\-f right\fR \fBforward\-char\fR \fIctrl\-f right\fR
\fBforward\-subword\fR \fBforward\-subword\fR
\fBforward\-word\fR \fIalt\-f shift\-right\fR \fBforward\-word\fR \fIalt\-f shift\-right alt\-right\fR
\fBignore\fR \fBignore\fR
\fBjump\fR (EasyMotion-like 2-keystroke movement) \fBjump\fR (EasyMotion-like 2-keystroke movement)
\fBkill\-line\fR \fBkill\-line\fR
@@ -2132,6 +2172,7 @@ A key or an event can be bound to one or more of the following actions.
\fBunix\-line\-discard\fR \fIctrl\-u\fR \fBunix\-line\-discard\fR \fIctrl\-u\fR
\fBunix\-word\-rubout\fR \fIctrl\-w\fR \fBunix\-word\-rubout\fR \fIctrl\-w\fR
\fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled) \fBuntrack\-current\fR (stop tracking the current item; no-op if global tracking is enabled)
\fBwait\fR (block action execution until search completes)
\fBup\fR \fIctrl\-k up\fR \fBup\fR \fIctrl\-k up\fR
\fBup\-match\fR \fIctrl\-p\fR \fIalt\-up\fR (move to the match above the cursor) \fBup\-match\fR \fIctrl\-p\fR \fIalt\-up\fR (move to the match above the cursor)
\fBup\-selected\fR (move to the selected item above the cursor) \fBup\-selected\fR (move to the selected item above the cursor)
@@ -2284,6 +2325,47 @@ chain multiple transform actions where later ones depend on earlier results,
prefer using the \fBbg\fR variant. To cancel currently running background prefer using the \fBbg\fR variant. To cancel currently running background
transform processes, use \fBbg\-cancel\fR action. transform processes, use \fBbg\-cancel\fR action.
.SS WAITING FOR SEARCH COMPLETION
The \fBwait\fR action blocks the execution of subsequent actions until the
current search completes. This is useful when chaining query\-changing actions
with motion actions like \fBbest\fR or \fBfirst\fR, ensuring that the motion
action operates on the complete search results rather than stale data.
e.g.
\fBfzf \-\-bind 'start:change\-query(foo)+wait+best'\fR
In this example, \fBchange\-query(foo)\fR starts an asynchronous search for
the new query, \fBwait\fR blocks until the search completes, and \fBbest\fR
then moves the cursor to the best match in the complete result set.
The initial loading of the input is also considered a search in progress, so
\fBstart:wait\fR can be used to block until the input is fully loaded and
searched.
While waiting, user input is ignored, except for keys bound to \fBabort\fR or
\fBcancel\fR (\fIctrl\-c\fR, \fIctrl\-g\fR, \fIctrl\-q\fR, and \fIesc\fR by
default), which cancel the wait and discard the pending actions instead of
performing their usual role. The remaining actions of such a binding still run,
so a binding like \fBesc:cancel+first\fR is possible.
Asynchronous \fBbg\-transform\-*\fR actions are not affected; their results are
applied as soon as they arrive, even while waiting. For the same reason,
\fBwait\fR does not pair with them: in
\fBbg\-transform\-query(...)+wait\fR, the background command completes only
after \fBwait\fR has already been evaluated, so the search its result
eventually triggers is not waited for. Use the synchronous
\fBtransform\-query(...)\fR variant instead when chaining with \fBwait\fR.
If the search takes long enough, fzf indicates that it is waiting by dimming the
input, hiding the cursor, and showing \fB(..)\fR on the info line. This visual
feedback is debounced so that quick searches do not cause flickering.
Note that when searches are triggered in rapid succession (e.g. via
\fB\-\-listen\fR), \fBwait\fR may unblock on the completion of an earlier
search. Also, if the input source never completes, \fBwait\fR will block until
cancelled.
.SS PREVIEW BINDING .SS PREVIEW BINDING
With \fBpreview(...)\fR action, you can specify multiple different preview With \fBpreview(...)\fR action, you can specify multiple different preview
+2 -1
View File
@@ -116,7 +116,8 @@ def __fzf_list_hosts [] {
( (
# Process ssh config files # Process ssh config files
$ssh_configs | append $ssh_configs_d | append $ssh_config_global $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') } # NOTE: str lowercase is new from Nushell 0.114.0. Please upgrade to Nushell >=0.114.0 if you are seeing `extra positional argument` error regarding `str lowercase` in this block.
| where {|it| ($it | str lowercase | str starts-with 'host') or ($it | str lowercase | str starts-with 'hostname') }
| parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword | parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword
| default { hosts: null } # Handle lines that don't match regex | default { hosts: null } # Handle lines that don't match regex
| get hosts | get hosts
+9 -12
View File
@@ -127,14 +127,21 @@ function fzf_key_bindings
set -l -- total_lines (count $command_line) set -l -- total_lines (count $command_line)
set -l -- fzf_query (string escape -- $command_line[$current_line]) set -l -- fzf_query (string escape -- $command_line[$current_line])
set -lx -- FZF_DEFAULT_COMMAND "builtin history -z --show-time=(set_color $fish_color_comment 2>/dev/null; or set_color normal)\"%F %a %T%t%s%t\"(set_color normal)"
# Enable syntax highlighting colors on fish v4.3.3 and newer
if string match -qr -- '^\\d\\d+|^4\\.[4-9]|^4\\.3\\.[3-9]' $version
set -a -- FZF_DEFAULT_COMMAND "--color=always"
end
set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \ set -lx -- FZF_DEFAULT_OPTS (__fzf_defaults '' \
'--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \ '--with-nth=2.. --nth=2..,.. --scheme=history --multi --no-multi-line' \
'--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \ '--no-wrap --wrap-sign="\t\t\t↳ " --preview-wrap-sign="↳ " --freeze-left=1' \
'--bind="alt-enter:become(set -g fzf_temp {+sf3..}; string join0 -- (string split0 -- <$fzf_temp | fish_indent -i); unlink $fzf_temp &>/dev/null)"' \ '--bind="alt-enter:become(set -g fzf_temp {+sf3..}; string join0 -- (string split0 -- <$fzf_temp | fish_indent -i); unlink $fzf_temp &>/dev/null)"' \
'--bind="alt-t:change-with-nth(1,3..|3..|2..)"' \ '--bind="alt-t:change-with-nth(1,3..|3..|2..)"' \
'--bind="shift-delete:execute-silent(eval builtin history delete -Ce -- (string escape -n -- (string split0 -- <{+sf3..})))+reload(eval $FZF_DEFAULT_COMMAND)"' \ "--bind='shift-delete:execute-silent(eval builtin history delete -Ce -- (string escape -n -- (string split0 -- <{+sf3..})))+reload($FZF_DEFAULT_COMMAND)'" \
"--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \ "--bind=ctrl-r:toggle-sort,alt-r:toggle-raw --highlight-line $FZF_CTRL_R_OPTS" \
'--accept-nth=3.. --delimiter="\t" --tabstop=4 --read0 --print0 --with-shell='(status fish-path)\\ -c) '--accept-nth=3.. --delimiter="\t" --tabstop=4 --ansi --read0 --print0 --with-shell='(status fish-path)\\ -c)
# Add dynamic preview options if preview command isn't already set by user # Add dynamic preview options if preview command isn't already set by user
if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS" if string match -qvr -- '--preview[= ]' "$FZF_DEFAULT_OPTS"
@@ -147,16 +154,6 @@ function fzf_key_bindings
set -lx FZF_DEFAULT_OPTS_FILE set -lx FZF_DEFAULT_OPTS_FILE
set -lx -- FZF_DEFAULT_COMMAND 'builtin history -z'
# Enable syntax highlighting colors on fish v4.3.3 and newer
if string match -qr -- '^\\d\\d+|^4\\.[4-9]|^4\\.3\\.[3-9]' $version
set -a -- FZF_DEFAULT_OPTS '--ansi'
set -a -- FZF_DEFAULT_COMMAND '--color=always --show-time=(set_color $fish_color_comment)"%F %a %T%t%s%t"(set_color $fish_color_normal)'
else
set -a -- FZF_DEFAULT_COMMAND '--show-time="%F %a %T%t%s%t"'
end
# Merge history from other sessions before searching # Merge history from other sessions before searching
test -z "$fish_private_mode"; and builtin history merge test -z "$fish_private_mode"; and builtin history merge
+8 -2
View File
@@ -110,8 +110,14 @@ fzf-cd-widget() {
zle redisplay zle redisplay
return 0 return 0
fi fi
# Use subshell expansion to get the absolute PWD of the target dir.
# This allows the recorded shell history to be reused even from a different
# working directory.
# If failed, fallback to the unexpanded path to surface the error to the user.
# NOTE: Don't use the `:a` modifier as it resolves symlinks like `pwd -P`.
dir=$(builtin cd -q >/dev/null -- "${dir}" && echo "${PWD}" || echo "${dir}")
zle push-line # Clear buffer. Auto-restored on next prompt. zle push-line # Clear buffer. Auto-restored on next prompt.
BUFFER="builtin cd -- ${(q)dir:a}" BUFFER="builtin cd -- ${(q)dir}"
zle accept-line zle accept-line
local ret=$? local ret=$?
unset dir # ensure this doesn't end up appearing in prompt expansion unset dir # ensure this doesn't end up appearing in prompt expansion
@@ -133,11 +139,11 @@ fzf-history-widget() {
# as the associative 'history' array, which maps event numbers to full history # as the associative 'history' array, which maps event numbers to full history
# lines, are set. Also, make sure Perl is installed for multi-line output. # lines, are set. Also, make sure Perl is installed for multi-line output.
if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then if zmodload -F zsh/parameter p:{commands,history} 2>/dev/null && (( ${+commands[perl]} )); then
extracted_with_perl=1
selected="$(printf '%s\t%s\000' "${(kv)history[@]}" | selected="$(printf '%s\t%s\000' "${(kv)history[@]}" |
perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' | perl -0 -ne 'if (!$seen{(/^\s*[0-9]+\**\t(.*)/s, $1)}++) { s/\n/\n\t/g; print; }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \ FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER} --read0") \
FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))" FZF_DEFAULT_OPTS_FILE='' $(__fzfcmd))"
extracted_with_perl=1
else else
selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' | selected="$(fc -rl 1 | __fzf_exec_awk '{ cmd=$0; sub(/^[ \t]*[0-9]+\**[ \t]+/, "", cmd); if (!seen[cmd]++) print $0 }' |
FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \ FZF_DEFAULT_OPTS=$(__fzf_defaults "" "-n2..,.. --scheme=history --bind=ctrl-r:toggle-sort,alt-r:toggle-raw --wrap-sign '\t↳ ' --highlight-line --multi ${FZF_CTRL_R_OPTS-} --query=${(qqq)LBUFFER}") \
+3 -2
View File
@@ -186,11 +186,12 @@ func _() {
_ = x[actExclude-175] _ = x[actExclude-175]
_ = x[actExcludeMulti-176] _ = x[actExcludeMulti-176]
_ = x[actAsync-177] _ = x[actAsync-177]
_ = x[actWait-178]
} }
const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsync" const _actionType_name = "actIgnoreactStartactClickactInvalidactBracketedPasteBeginactBracketedPasteEndactCharactMouseactBeginningOfLineactAbortactAcceptactAcceptNonEmptyactAcceptOrPrintQueryactBackwardCharactBackwardDeleteCharactBackwardDeleteCharEofactBackwardWordactBackwardSubWordactCancelactChangeBorderLabelactChangeGhostactChangeHeaderactChangeHeaderLinesactChangeFooteractChangeHeaderLabelactChangeFooterLabelactChangeInputLabelactChangeListLabelactChangeMultiactChangeNthactChangeWithNthactChangePointeractChangePreviewactChangePreviewLabelactChangePreviewWindowactChangePromptactChangeQueryactClearScreenactClearQueryactClearSelectionactCloseactDeleteCharactDeleteCharEofactEndOfLineactFatalactForwardCharactForwardWordactForwardSubWordactKillLineactKillWordactKillSubWordactUnixLineDiscardactUnixWordRuboutactYankactBackwardKillWordactBackwardKillSubWordactSelectAllactDeselectAllactToggleactToggleSearchactToggleAllactToggleDownactToggleUpactToggleInactToggleOutactToggleTrackactToggleTrackCurrentactToggleHeaderactToggleWrapactToggleWrapWordactToggleMultiLineactToggleHscrollactToggleRawactEnableRawactDisableRawactTrackCurrentactToggleInputactHideInputactShowInputactUntrackCurrentactDownactDownMatchactUpactUpMatchactPageUpactPageDownactPositionactHalfPageUpactHalfPageDownactOffsetUpactOffsetDownactOffsetMiddleactJumpactJumpAcceptactPrintQueryactRefreshPreviewactReplaceQueryactToggleSortactShowPreviewactHidePreviewactTogglePreviewactTogglePreviewWrapactTogglePreviewWrapWordactTransformactTransformBorderLabelactTransformGhostactTransformHeaderactTransformHeaderLinesactTransformFooteractTransformHeaderLabelactTransformFooterLabelactTransformInputLabelactTransformListLabelactTransformNthactTransformWithNthactTransformPointeractTransformPreviewLabelactTransformPromptactTransformQueryactTransformSearchactTriggeractBgTransformactBgTransformBorderLabelactBgTransformGhostactBgTransformHeaderactBgTransformHeaderLinesactBgTransformFooteractBgTransformHeaderLabelactBgTransformFooterLabelactBgTransformInputLabelactBgTransformListLabelactBgTransformNthactBgTransformWithNthactBgTransformPointeractBgTransformPreviewLabelactBgTransformPromptactBgTransformQueryactBgTransformSearchactBgCancelactSearchactPreviewactPreviewTopactPreviewBottomactPreviewUpactPreviewDownactPreviewPageUpactPreviewPageDownactPreviewHalfPageUpactPreviewHalfPageDownactPrevHistoryactPrevSelectedactPrintactPutactNextHistoryactNextSelectedactExecuteactExecuteSilentactExecuteMultiactSigStopactBestactFirstactLastactReloadactReloadSyncactDisableSearchactEnableSearchactSelectactDeselectactUnbindactRebindactToggleBindactBecomeactShowHeaderactHideHeaderactBellactExcludeactExcludeMultiactAsyncactWait"
var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 336, 351, 371, 391, 410, 428, 442, 454, 470, 486, 502, 523, 545, 560, 574, 588, 601, 618, 626, 639, 655, 667, 675, 689, 703, 720, 731, 742, 756, 774, 791, 798, 817, 839, 851, 865, 874, 889, 901, 914, 925, 936, 948, 962, 983, 998, 1011, 1028, 1046, 1062, 1074, 1086, 1099, 1114, 1128, 1140, 1152, 1169, 1176, 1188, 1193, 1203, 1212, 1223, 1234, 1247, 1262, 1273, 1286, 1301, 1308, 1321, 1334, 1351, 1366, 1379, 1393, 1407, 1423, 1443, 1467, 1479, 1502, 1519, 1537, 1560, 1578, 1601, 1624, 1646, 1667, 1682, 1701, 1720, 1744, 1762, 1779, 1797, 1807, 1821, 1846, 1865, 1885, 1910, 1930, 1955, 1980, 2004, 2027, 2044, 2065, 2086, 2112, 2132, 2151, 2171, 2182, 2191, 2201, 2214, 2230, 2242, 2256, 2272, 2290, 2310, 2332, 2346, 2361, 2369, 2375, 2389, 2404, 2414, 2430, 2445, 2455, 2462, 2470, 2477, 2486, 2499, 2515, 2530, 2539, 2550, 2559, 2568, 2581, 2590, 2603, 2616, 2623, 2633, 2648, 2656} var _actionType_index = [...]uint16{0, 9, 17, 25, 35, 57, 77, 84, 92, 110, 118, 127, 144, 165, 180, 201, 225, 240, 258, 267, 287, 301, 316, 336, 351, 371, 391, 410, 428, 442, 454, 470, 486, 502, 523, 545, 560, 574, 588, 601, 618, 626, 639, 655, 667, 675, 689, 703, 720, 731, 742, 756, 774, 791, 798, 817, 839, 851, 865, 874, 889, 901, 914, 925, 936, 948, 962, 983, 998, 1011, 1028, 1046, 1062, 1074, 1086, 1099, 1114, 1128, 1140, 1152, 1169, 1176, 1188, 1193, 1203, 1212, 1223, 1234, 1247, 1262, 1273, 1286, 1301, 1308, 1321, 1334, 1351, 1366, 1379, 1393, 1407, 1423, 1443, 1467, 1479, 1502, 1519, 1537, 1560, 1578, 1601, 1624, 1646, 1667, 1682, 1701, 1720, 1744, 1762, 1779, 1797, 1807, 1821, 1846, 1865, 1885, 1910, 1930, 1955, 1980, 2004, 2027, 2044, 2065, 2086, 2112, 2132, 2151, 2171, 2182, 2191, 2201, 2214, 2230, 2242, 2256, 2272, 2290, 2310, 2332, 2346, 2361, 2369, 2375, 2389, 2404, 2414, 2430, 2445, 2455, 2462, 2470, 2477, 2486, 2499, 2515, 2530, 2539, 2550, 2559, 2568, 2581, 2590, 2603, 2616, 2623, 2633, 2648, 2656, 2663}
func (i actionType) String() string { func (i actionType) String() string {
if i < 0 || i >= actionType(len(_actionType_index)-1) { if i < 0 || i >= actionType(len(_actionType_index)-1) {
+229 -1
View File
@@ -425,6 +425,217 @@ func debugV2(T []rune, pattern []rune, F []int32, lastIdx int, H []int16, C []in
} }
} }
// fuzzyMatchV2Single is a fast path for a single-character ASCII pattern on
// ASCII input. Same scoring and tiebreaks as Phase 2 of FuzzyMatchV2, but
// jumps between occurrences instead of scanning every character, and
// allocates no arrays.
func fuzzyMatchV2Single(caseSensitive bool, forward bool, input *util.Chars, b byte, withPos bool) (Result, *[]int) {
byteArray := input.Bytes()
maxScore, maxScorePos := int16(0), -1
for idx := 0; idx < len(byteArray); {
idx = trySkip(input, caseSensitive, b, idx)
if idx < 0 {
break
}
class := asciiCharClasses[byteArray[idx]]
prevClass := initialCharClass
if idx > 0 {
prevClass = asciiCharClasses[byteArray[idx-1]]
}
bonus := bonusMatrix[prevClass][class]
score := scoreMatch + bonus*bonusFirstCharMultiplier
if forward && score > maxScore || !forward && score >= maxScore {
maxScore, maxScorePos = score, idx
if forward && bonus >= bonusBoundary {
break
}
}
idx++
}
if maxScorePos < 0 {
return Result{-1, -1, 0}, nil
}
result := Result{maxScorePos, maxScorePos + 1, int(maxScore)}
if !withPos {
return result, nil
}
pos := []int{maxScorePos}
return result, &pos
}
// Test hooks: force the general path instead of a fast path, so the two can
// be compared for equivalence.
var (
disableSingle bool
disableTwo bool
)
// fuzzyMatchV2Two is a fused fast path for a two-character ASCII pattern on
// ASCII input. It replicates Phase 2 (row 0) and Phase 3 (row 1) of
// FuzzyMatchV2 in a single pass, carrying the row-0 diagonal/left values and
// the row-1 left value as scalars instead of materializing score arrays.
// When withPos is set, the two DP rows are stored so the backtrace can
// recover the matched character positions, exactly as the general Phase 4.
func fuzzyMatchV2Two(caseSensitive bool, forward bool, input *util.Chars, pchar0 byte, pchar1 byte, minIdx int, maxIdx int, withPos bool, slab *util.Slab) (Result, *[]int) {
sl := input.Bytes()
N := maxIdx - minIdx
// Row storage, only needed for the backtrace
var H0, C0, H1, C1 []int16
if withPos {
o := 0
o, H0 = alloc16(o, slab, N)
o, C0 = alloc16(o, slab, N)
o, H1 = alloc16(o, slab, N)
_, C1 = alloc16(o, slab, N)
}
maxScore, maxScorePos := int16(0), 0
prevClass := initialCharClass
// Subsequence tracking (equivalent to F[0], F[1] in Phase 2). The scope
// from asciiFuzzyIndex ends exactly at the last pchar1, so row 1's upper
// bound (Phase 3 lastIdx) is the final loop position; no separate var.
f0, f1 := -1, -1
// Row 0 running state at the previous position
var h0Prev, c0Prev, bPrev int16
inGap0 := false
// Row 1 running state
var h1Prev int16
inGap1 := false
for off := range N {
pos := minIdx + off
b := sl[pos]
class := asciiCharClasses[b]
lb := b
if !caseSensitive && b >= 'A' && b <= 'Z' {
lb = b + 32
}
bonus := bonusMatrix[prevClass][class]
prevClass = class
// Subsequence advance: pchar0 then pchar1
if f0 < 0 {
if lb == pchar0 {
f0 = off
}
} else if lb == pchar1 && f1 < 0 {
f1 = off
}
// Row 0 (pchar0)
var h0Cur, c0Cur int16
if lb == pchar0 {
h0Cur = scoreMatch + bonus*bonusFirstCharMultiplier
c0Cur = 1
inGap0 = false
} else {
if inGap0 {
h0Cur = max(h0Prev+scoreGapExtension, 0)
} else {
h0Cur = max(h0Prev+scoreGapStart, 0)
}
c0Cur = 0
inGap0 = true
}
if withPos {
H0[off], C0[off] = h0Cur, c0Cur
}
// Row 1 (pchar1), only within [f1, lastIdx]
if f1 >= 0 && off >= f1 {
var s1, s2, consecutive int16
hleft := h1Prev
if off == f1 {
hleft = 0
}
if inGap1 {
s2 = hleft + scoreGapExtension
} else {
s2 = hleft + scoreGapStart
}
if lb == pchar1 {
s1 = h0Prev + scoreMatch
bb := bonus
consecutive = c0Prev + 1
if consecutive > 1 {
fb := bPrev
if bb >= bonusBoundary && bb > fb {
consecutive = 1
} else {
bb = max(bb, bonusConsecutive, fb)
}
}
if s1+bb < s2 {
s1 += bonus
consecutive = 0
} else {
s1 += bb
}
}
inGap1 = s1 < s2
score := max(s1, s2, 0)
if forward && score > maxScore || !forward && score >= maxScore {
maxScore, maxScorePos = score, off
}
h1Prev = score
if withPos {
H1[off], C1[off] = score, consecutive
}
}
h0Prev, c0Prev, bPrev = h0Cur, c0Cur, bonus
}
if f1 < 0 {
return Result{-1, -1, 0}, nil
}
if !withPos {
return Result{minIdx + f0, minIdx + maxScorePos + 1, int(maxScore)}, nil
}
// Phase 4 backtrace, specialized to two rows. Mirrors the general loop:
// record a cell when it dominates its diagonal and left neighbors, then
// step up a row; otherwise step left. preferMatch breaks score ties and
// must not read row 1 left of f1 (unwritten, possibly stale slab data).
pos := posArray(true, 2)
i := 1
j := maxScorePos
preferMatch := true
for {
var s, s1, s2, cCur int16
if i == 1 {
s, cCur = H1[j], C1[j]
if j >= f1 {
s1 = H0[j-1]
}
if j > f1 {
s2 = H1[j-1]
}
} else {
s, cCur = H0[j], C0[j]
if j > f0 {
s2 = H0[j-1]
}
}
row := i
if s > s1 && (s > s2 || s == s2 && preferMatch) {
*pos = append(*pos, j+minIdx)
if i == 0 {
break
}
i--
}
preferMatch = cCur > 1 ||
row == 0 && j < N-1 && j+1 >= f1 && C1[j+1] > 0
j--
}
return Result{minIdx + j, minIdx + maxScorePos + 1, int(maxScore)}, pos
}
func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) { func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.Chars, pattern []rune, withPos bool, slab *util.Slab) (Result, *[]int) {
// Assume that pattern is given in lowercase if case-insensitive. // Assume that pattern is given in lowercase if case-insensitive.
// First check if there's a match and calculate bonus for each position. // First check if there's a match and calculate bonus for each position.
@@ -447,6 +658,12 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab) return FuzzyMatchV1(caseSensitive, normalize, forward, input, pattern, withPos, slab)
} }
// Single-character ASCII pattern needs neither the prefilter nor the
// score matrix
if !disableSingle && M == 1 && input.IsBytes() && pattern[0] < utf8.RuneSelf {
return fuzzyMatchV2Single(caseSensitive, forward, input, byte(pattern[0]), withPos)
}
// Phase 1. Optimized search for ASCII string // Phase 1. Optimized search for ASCII string
minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive) minIdx, maxIdx := asciiFuzzyIndex(input, pattern, caseSensitive)
if minIdx < 0 { if minIdx < 0 {
@@ -455,6 +672,13 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
// fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString()) // fmt.Println(N, maxIdx, idx, maxIdx-idx, input.ToString())
N = maxIdx - minIdx N = maxIdx - minIdx
// Two-character ASCII pattern: rows 0 and 1 collapse to scalar running
// state, so the general score arrays are unnecessary
if !disableTwo && M == 2 && input.IsBytes() &&
pattern[0] < utf8.RuneSelf && pattern[1] < utf8.RuneSelf {
return fuzzyMatchV2Two(caseSensitive, forward, input, byte(pattern[0]), byte(pattern[1]), minIdx, maxIdx, withPos, slab)
}
// Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages // Reuse pre-allocated integer slice to avoid unnecessary sweeping of garbages
offset16 := 0 offset16 := 0
offset32 := 0 offset32 := 0
@@ -630,6 +854,7 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
s2 = H[I+j0-1] s2 = H[I+j0-1]
} }
row := i
if s > s1 && (s > s2 || s == s2 && preferMatch) { if s > s1 && (s > s2 || s == s2 && preferMatch) {
*pos = append(*pos, j+minIdx) *pos = append(*pos, j+minIdx)
if i == 0 { if i == 0 {
@@ -637,7 +862,10 @@ func FuzzyMatchV2(caseSensitive bool, normalize bool, forward bool, input *util.
} }
i-- i--
} }
preferMatch = C[I+j0] > 1 || I+width+j0+1 < len(C) && C[I+width+j0+1] > 0 // Row below is only written from column F[row+1]; don't read
// stale slab data left of it
preferMatch = C[I+j0] > 1 ||
row+1 < M && j < lastIdx && int32(j+1) >= F[row+1] && C[I+width+j0+1] > 0
j-- j--
} }
} }
+95
View File
@@ -218,3 +218,98 @@ func TestLongStringWithNormalize(t *testing.T) {
unicodeString := string(bytes) + " Minímal example" unicodeString := string(bytes) + " Minímal example"
assertMatch2(t, FuzzyMatchV1, false, true, false, unicodeString, "minim", 30001, 30006, 140) assertMatch2(t, FuzzyMatchV1, false, true, false, unicodeString, "minim", 30001, 30006, 140)
} }
func TestResultPositionsWithReusedSlab(t *testing.T) {
// Backtrace positions in equal-score ties must not depend on data
// a previous match left in the slab
pattern := []rune("co/")
target := util.ToChars([]byte("core_color/view/server.txt"))
_, freshPos := FuzzyMatchV2(false, false, true, &target, pattern, true, util.MakeSlab(100*1024, 2048))
slab := util.MakeSlab(100*1024, 2048)
dirty := util.ToChars([]byte("completion/keybinding/client/handler/writer_index.txt"))
FuzzyMatchV2(false, false, true, &dirty, pattern, true, slab)
_, reusedPos := FuzzyMatchV2(false, false, true, &target, pattern, true, slab)
if len(*freshPos) != len(*reusedPos) {
t.Fatalf("position count mismatch: %v vs %v", *freshPos, *reusedPos)
}
for i := range *freshPos {
if (*freshPos)[i] != (*reusedPos)[i] {
t.Errorf("positions differ with reused slab: %v vs %v", *freshPos, *reusedPos)
break
}
}
}
// TestFuzzyMatchV2TwoEquivalence verifies that the two-character fast path
// produces the same Result and positions as the general algorithm across
// case sensitivity, direction, and withPos, using a reused slab to surface
// any stale-data reads in the backtrace.
func TestFuzzyMatchV2TwoEquivalence(t *testing.T) {
words := []string{"src", "main", "core", "config", "parser", "render", "server",
"client", "index", "handler", "util", "list", "cache", "reader"}
exts := []string{".go", ".rb", ".py", ".md", ".c", ".txt"}
// Deterministic corpus (LCG), plus adversarial short/repeated items
corpus := []util.Chars{}
seed := uint32(12345)
next := func(n int) int { seed = seed*1664525 + 1013904223; return int(seed>>8) % n }
for i := 0; i < 4000; i++ {
depth := 2 + next(4)
s := ""
for d := 0; d < depth; d++ {
if d > 0 {
s += "/"
}
s += words[next(len(words))]
if next(5) == 0 {
s += "_" + words[next(len(words))]
}
}
s += exts[next(len(exts))]
corpus = append(corpus, util.ToChars([]byte(s)))
}
for _, s := range []string{"", "a", "ab", "aa", "aXb", "a/b", "//", "..", "abcabc",
"AaBb", "x.y.z", "a_b_c", "CoreCore", " co"} {
corpus = append(corpus, util.ToChars([]byte(s)))
}
pats := []string{"co", "ab", "aa", "//", "..", "sr", "a/", "_c", "oo", "Ab",
"z.", "b.", "1a", "ll", "re", "er", "Co"}
slab := util.MakeSlab(100*1024, 2048)
for _, cs := range []bool{false, true} {
for _, fwd := range []bool{true, false} {
for _, wp := range []bool{false, true} {
for _, p := range pats {
pattern := []rune(p)
for j := range corpus {
disableTwo = true
rg, pg := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
disableTwo = false
rt, pt := FuzzyMatchV2(cs, false, fwd, &corpus[j], pattern, wp, slab)
if rg != rt {
t.Fatalf("Result cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
cs, fwd, wp, p, corpus[j].ToString(), rg, rt)
}
if (pg == nil) != (pt == nil) || (pg != nil && !equalInts(*pg, *pt)) {
t.Fatalf("Pos cs=%v fwd=%v wp=%v pat=%q item=%q: general %v vs two %v",
cs, fwd, wp, p, corpus[j].ToString(), pg, pt)
}
}
}
}
}
}
}
func equalInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+157
View File
@@ -0,0 +1,157 @@
package algo
// Equivalence tests for the single- and two-character fast paths against the
// general FuzzyMatchV2 algorithm, which serves as the oracle.
//
// Two complementary strategies:
// - Exhaustive: every string up to a fixed length over an alphabet that
// covers all ASCII character classes, so every local scoring branch is
// exercised (not merely sampled).
// - Fuzz: coverage-guided, arbitrary length, to reach cases the bounded
// exhaustive sweep cannot (e.g. long gaps where a high score decays).
import (
"testing"
"github.com/junegunn/fzf/src/util"
)
// One char of each ASCII class that affects scoring: lower, upper, delimiter,
// number, non-word, whitespace.
var equivAlphabet = []byte{'a', 'B', '/', '1', '_', ' '}
func samePos(a, b *[]int) bool {
if (a == nil) != (b == nil) {
return false
}
if a == nil {
return true
}
return equalInts(*a, *b)
}
// compareFastPath runs a single input/pattern/params pair through both the
// fast path and the general algorithm and fails on any difference.
// It toggles the package-global disable* hook, so tests that call it must
// not run in parallel.
func compareFastPath(t *testing.T, chars *util.Chars, pattern []rune, cs, norm, fwd, wp bool, disable *bool, slab *util.Slab) {
t.Helper()
*disable = true
rg, pg := FuzzyMatchV2(cs, norm, fwd, chars, pattern, wp, slab)
*disable = false
rt, pt := FuzzyMatchV2(cs, norm, fwd, chars, pattern, wp, slab)
if rg != rt || !samePos(pg, pt) {
t.Fatalf("mismatch item=%q pat=%q cs=%v norm=%v fwd=%v wp=%v: general %v %v vs fast %v %v",
chars.ToString(), string(pattern), cs, norm, fwd, wp, rg, pg, rt, pt)
}
}
// lowerPattern lowercases the pattern for case-insensitive search, matching
// the Algo contract (the pattern is pre-lowercased by BuildPattern).
func lowerPattern(p []rune, cs bool) []rune {
if cs {
return p
}
out := make([]rune, len(p))
for i, c := range p {
if c >= 'A' && c <= 'Z' {
c += 32
}
out[i] = c
}
return out
}
func runExhaustive(t *testing.T, patLen, maxLen int, disable *bool) {
// All patterns of length patLen over the alphabet
var pats [][]rune
var genPat func(cur []rune)
genPat = func(cur []rune) {
if len(cur) == patLen {
pats = append(pats, append([]rune(nil), cur...))
return
}
for _, c := range equivAlphabet {
genPat(append(cur, rune(c)))
}
}
genPat(nil)
slab := util.MakeSlab(100*1024, 2048)
buf := make([]byte, 0, maxLen)
var rec func(depth int)
rec = func(depth int) {
chars := util.ToChars(append([]byte(nil), buf...))
// normalize is not varied: normalizeRune is the identity below 0xC0,
// and the general algorithm skips normalization on its ASCII branch,
// so it cannot change the result for the ASCII-only fast path. The
// fuzz test exercises normalize=true as a guard against that changing.
for _, cs := range []bool{false, true} {
for _, fwd := range []bool{true, false} {
for _, wp := range []bool{false, true} {
for _, p := range pats {
compareFastPath(t, &chars, lowerPattern(p, cs), cs, false, fwd, wp, disable, slab)
}
}
}
}
if depth == maxLen {
return
}
for _, c := range equivAlphabet {
buf = append(buf, c)
rec(depth + 1)
buf = buf[:len(buf)-1]
}
}
rec(0)
}
func TestFuzzyMatchV2SingleExhaustive(t *testing.T) {
runExhaustive(t, 1, 6, &disableSingle)
}
func TestFuzzyMatchV2TwoExhaustive(t *testing.T) {
runExhaustive(t, 2, 6, &disableTwo)
}
func fuzzFastPath(f *testing.F, patLen int, disable *bool) {
inputs := []string{"core_color/view/server.txt", "a b",
"XyZ/123_abc.def", "aaaaaaaaaaaaaaaaaaaaaaaa"}
// Seed patterns of the right length; wrong-length seeds are rejected below
// and would waste the corpus.
seedPats := map[int][]string{1: {"c", "a", "/"}, 2: {"co", "ab", "z1", "aa"}}[patLen]
for _, in := range inputs {
for _, p := range seedPats {
f.Add(in, p)
}
}
slab := util.MakeSlab(200*1024, 4096)
f.Fuzz(func(t *testing.T, input, pat string) {
r := []rune(pat)
if len(r) != patLen {
return
}
for _, c := range r {
if c >= 128 {
return
}
}
chars := util.ToChars([]byte(input))
if !chars.IsBytes() {
return
}
for _, cs := range []bool{false, true} {
for _, norm := range []bool{false, true} {
for _, fwd := range []bool{true, false} {
for _, wp := range []bool{false, true} {
compareFastPath(t, &chars, lowerPattern(r, cs), cs, norm, fwd, wp, disable, slab)
}
}
}
}
})
}
func FuzzFuzzyMatchV2Single(f *testing.F) { fuzzFastPath(f, 1, &disableSingle) }
func FuzzFuzzyMatchV2Two(f *testing.F) { fuzzFastPath(f, 2, &disableTwo) }
+13 -8
View File
@@ -10,7 +10,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/tui"
@@ -76,7 +75,7 @@ Usage: fzf [options]
--min-height=HEIGHT[+] Minimum height when --height is given as a percentage. --min-height=HEIGHT[+] Minimum height when --height is given as a percentage.
Add '+' to automatically increase the value Add '+' to automatically increase the value
according to the other layout options (default: 10+). according to the other layout options (default: 10+).
--popup[=OPTS] Start fzf in a popup window (requires tmux 3.3+ or Zellij 0.44+) --popup[=OPTS] Start fzf in a floating pane (requires tmux 3.3+ or Zellij 0.44+)
[center|top|bottom|left|right][,SIZE[%]][,SIZE[%]] [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]]
[,border-native] (default: center,50%) [,border-native] (default: center,50%)
--tmux[=OPTS] Alias for --popup --tmux[=OPTS] Alias for --popup
@@ -428,7 +427,7 @@ func parseTmuxOptions(arg string, index int) (*tmuxOptions, error) {
var err error var err error
opts := defaultTmuxOptions(index) opts := defaultTmuxOptions(index)
tokens := splitRegexp.Split(arg, -1) tokens := splitRegexp.Split(arg, -1)
errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%][,border-native]])") errorToReturn := errors.New("invalid popup option: " + arg + " (expected: [center|top|bottom|left|right][,SIZE[%]][,SIZE[%]][,border-native])")
if len(tokens) == 0 || len(tokens) > 4 { if len(tokens) == 0 || len(tokens) > 4 {
return nil, errorToReturn return nil, errorToReturn
} }
@@ -1064,6 +1063,8 @@ func parseKeyChords(str string, message string) (map[tui.Event]string, []tui.Eve
add(tui.Focus) add(tui.Focus)
case "result": case "result":
add(tui.Result) add(tui.Result)
case "result-final":
add(tui.ResultFinal)
case "resize": case "resize":
add(tui.Resize) add(tui.Resize)
case "one": case "one":
@@ -1734,10 +1735,10 @@ Loop:
return masked return masked
} }
func parseSingleActionList(str string) ([]*action, error) { func parseSingleActionList(str string, putAllowed bool) ([]*action, error) {
// We prepend a colon to satisfy argActionRegexp and remove it later // We prepend a colon to satisfy argActionRegexp and remove it later
masked := maskActionContents(":" + str)[1:] masked := maskActionContents(":" + str)[1:]
return parseActionList(masked, str, []*action{}, false) return parseActionList(masked, str, []*action{}, putAllowed)
} }
func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) { func parseActionList(masked string, original string, prevActions []*action, putAllowed bool) ([]*action, error) {
@@ -1957,6 +1958,8 @@ func parseActionList(masked string, original string, prevActions []*action, putA
} else { } else {
return nil, errors.New("unable to put non-printable character") return nil, errors.New("unable to put non-printable character")
} }
case "wait":
appendAction(actWait)
case "bell": case "bell":
appendAction(actBell) appendAction(actBell)
case "exclude": case "exclude":
@@ -2043,8 +2046,7 @@ func parseKeymap(keymap map[tui.Event][]*action, str string) error {
} }
key = firstKey(keys) key = firstKey(keys)
} }
putAllowed := key.Type == tui.Rune && unicode.IsGraphic(key.Char) keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], key.Printable())
keymap[key], err = parseActionList(pair[1], origPairStr[len(pair[0])+1:], keymap[key], putAllowed)
if err != nil { if err != nil {
return err return err
} }
@@ -3694,7 +3696,10 @@ func (opts *Options) noSeparatorLine() bool {
if opts.Inputless { if opts.Inputless {
return true return true
} }
sep := opts.Separator == nil && !opts.InputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0 // NOTE: This does not know that the default separator can be suppressed at
// runtime (see Terminal.separatorLength), so the minimum heights derived
// from it can be one line taller than strictly necessary.
sep := opts.Separator == nil && !inputBorderFacesList(opts.Layout, opts.InputBorderShape) || opts.Separator != nil && len(*opts.Separator) > 0
return noSeparatorLine(opts.InfoStyle, sep) return noSeparatorLine(opts.InfoStyle, sep)
} }
+2 -2
View File
@@ -572,7 +572,7 @@ func TestValidateSign(t *testing.T) {
} }
func TestParseSingleActionList(t *testing.T) { func TestParseSingleActionList(t *testing.T) {
actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down") actions, _ := parseSingleActionList("Execute@foo+bar,baz@+up+up+reload:down+down", false)
if len(actions) != 4 { if len(actions) != 4 {
t.Errorf("Invalid number of actions parsed:%d", len(actions)) t.Errorf("Invalid number of actions parsed:%d", len(actions))
} }
@@ -588,7 +588,7 @@ func TestParseSingleActionList(t *testing.T) {
} }
func TestParseSingleActionListError(t *testing.T) { func TestParseSingleActionListError(t *testing.T) {
_, err := parseSingleActionList("change-query(foobar)baz") _, err := parseSingleActionList("change-query(foobar)baz", false)
if err == nil { if err == nil {
t.Errorf("Failed to detect error") t.Errorf("Failed to detect error")
} }
+82 -3
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
"github.com/junegunn/fzf/src/tui" "github.com/junegunn/fzf/src/tui"
@@ -19,10 +20,75 @@ import (
const becomeSuffix = ".become" const becomeSuffix = ".become"
// Withheld from the environment replay below, so an outer value cannot
// override what the floating pane set
const internalEnvPrefix = "__FZF_INTERNAL_"
func escapeSingleQuote(str string) string { func escapeSingleQuote(str string) string {
return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'" return "'" + strings.ReplaceAll(str, "'", "'\\''") + "'"
} }
// Read and remove, so preview and execute commands do not inherit it
func takeEnv(name string) string {
value := os.Getenv(name)
os.Unsetenv(name)
return value
}
// Applies label updates off the event loop, which would block on the fork
type labelUpdater struct {
set func(string)
mu sync.Mutex
pending *string
running bool
}
// A queue of one: a burst collapses to the newest label. A goroutine per
// update could apply them out of order
func (u *labelUpdater) update(label string) {
u.mu.Lock()
defer u.mu.Unlock()
u.pending = &label
if u.running {
return
}
u.running = true
go u.run()
}
// Ends once caught up, so nothing needs cancelling when Run returns
func (u *labelUpdater) run() {
for {
u.mu.Lock()
label := u.pending
if label == nil {
u.running = false
u.mu.Unlock()
return
}
u.pending = nil
u.mu.Unlock()
u.set(*label)
}
}
// Updates the label on the native border, or nil when it is not fzf's to update
func nativeLabelSetter() func(string) {
// Read both, so neither is left behind
tmuxPane, zellijPane := takeEnv(tmuxBorderLabelEnv), takeEnv(zellijBorderLabelEnv)
var set func(string)
switch {
case tmuxPane != "":
set = func(label string) { setTmuxBorderLabel(tmuxPane, label) }
case zellijPane != "":
set = func(label string) { setZellijBorderLabel(zellijPane, label) }
default:
return nil
}
updater := &labelUpdater{set: set}
return updater.update
}
func popupArgStr(args []string, opts *Options) (string, string) { func popupArgStr(args []string, opts *Options) (string, string) {
fzf, rest := args[0], args[1:] fzf, rest := args[0], args[1:]
args = []string{"--bind=ctrl-z:ignore"} args = []string{"--bind=ctrl-z:ignore"}
@@ -125,6 +191,9 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) validIdentifier := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
for _, pairStr := range os.Environ() { for _, pairStr := range os.Environ() {
pair := strings.SplitN(pairStr, "=", 2) pair := strings.SplitN(pairStr, "=", 2)
if strings.HasPrefix(pair[0], internalEnvPrefix) {
continue
}
if validIdentifier.MatchString(pair[0]) { if validIdentifier.MatchString(pair[0]) {
exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1]))) exports = append(exports, fmt.Sprintf("export %s=%s", pair[0], escapeSingleQuote(pair[1])))
} else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") { } else if strings.HasPrefix(pair[0], "BASH_FUNC_") && strings.HasSuffix(pair[0], "%%") {
@@ -138,6 +207,17 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
temp := WriteTemporaryFile(append(exports, command), "\n") temp := WriteTemporaryFile(append(exports, command), "\n")
defer os.Remove(temp) defer os.Remove(temp)
// Pre-create the become file so that another user on a shared TMPDIR
// cannot plant a file or a symbolic link at the predictable path while
// fzf is running
becomeFile := temp + becomeSuffix
becomeF, err := os.OpenFile(becomeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return ExitError, err
}
becomeF.Close()
defer os.Remove(becomeFile)
cmd, err := cmdBuilder(temp, needBash) cmd, err := cmdBuilder(temp, needBash)
if err != nil { if err != nil {
return ExitError, err return ExitError, err
@@ -155,16 +235,15 @@ func runProxy(commandPrefix string, cmdBuilder func(temp string, needBash bool)
if exitError, ok := err.(*exec.ExitError); ok { if exitError, ok := err.(*exec.ExitError); ok {
code := exitError.ExitCode() code := exitError.ExitCode()
if code == ExitBecome { if code == ExitBecome {
becomeFile := temp + becomeSuffix
data, err := os.ReadFile(becomeFile) data, err := os.ReadFile(becomeFile)
os.Remove(becomeFile) os.Remove(becomeFile)
if err != nil { if err != nil {
return ExitError, err return ExitError, err
} }
elems := strings.Split(string(data), "\x00") if len(data) == 0 {
if len(elems) < 1 {
return ExitError, errors.New("invalid become command") return ExitError, errors.New("invalid become command")
} }
elems := strings.Split(string(data), "\x00")
command := elems[0] command := elems[0]
env := []string{} env := []string{}
if len(elems) > 1 { if len(elems) > 1 {
+65
View File
@@ -0,0 +1,65 @@
package fzf
import (
"strconv"
"sync"
"testing"
"time"
)
func TestLabelUpdater(t *testing.T) {
var mu sync.Mutex
applied := []int{}
// The goroutine can still be running after the test function returns, so
// it records the label instead of reporting it, and -1 stands in for one
// that could not be parsed
updater := &labelUpdater{set: func(label string) {
n, err := strconv.Atoi(label)
if err != nil {
n = -1
}
mu.Lock()
applied = append(applied, n)
mu.Unlock()
// Long enough for the burst to pile up behind the first update
time.Sleep(time.Millisecond)
}}
const last = 99
for i := 0; i <= last; i++ {
updater.update(strconv.Itoa(i))
}
// However many updates are dropped, the final one is always applied
deadline := time.Now().Add(5 * time.Second)
for {
mu.Lock()
done := len(applied) > 0 && applied[len(applied)-1] == last
mu.Unlock()
if done {
break
}
if time.Now().After(deadline) {
t.Fatalf("last update was not applied: %v", applied)
}
time.Sleep(time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
// Updates are never applied out of order, so the border cannot be left
// showing a label older than the last one requested
for i, n := range applied {
if i > 0 && n <= applied[i-1] {
t.Errorf("applied out of order: %v", applied)
break
}
}
// A burst collapses instead of running one command per update. The
// updates are queued in a tight loop while each one takes a millisecond
// to apply, so all of them landing would mean no coalescing at all
if len(applied) >= last+1 {
t.Errorf("expected updates to be coalesced, applied all %d", len(applied))
}
t.Logf("applied %d of %d updates", len(applied), last+1)
}
+16 -1
View File
@@ -198,7 +198,10 @@ func (result *Result) colorOffsets(matchOffsets []Offset, nthOffsets []Offset, t
start := 0 start := 0
ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair { ansiToColorPair := func(ansi ansiOffset, base tui.ColorPair) tui.ColorPair {
if !theme.Colored { if !theme.Colored {
return tui.NewColorPair(-1, -1, ansi.color.attr).MergeAttr(base) // Ignore ANSI colors but keep the attributes. Retain the base
// colors (e.g. an overridden input-bg or list-bg) instead of
// resetting to the terminal default.
return tui.NewColorPair(base.Fg(), base.Bg(), ansi.color.attr).MergeAttr(base)
} }
// fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular // fd --color always | fzf --ansi --delimiter / --nth -1 --color fg:dim:strip,nth:regular
if base.ShouldStripColors() { if base.ShouldStripColors() {
@@ -366,9 +369,21 @@ func radixSortResults(a []Result, tac bool, scratch []Result) []Result {
src, dst := a, buf src, dst := a, buf
scattered := 0 scattered := 0
// OR of all keys: a byte position that is zero here is zero in every key,
// so its pass is a no-op and can be skipped without a histogram scan.
// With the common two-criteria setup the low 32 bits are always zero.
var keyOR uint64
for i := range src {
keyOR |= sortKey(&src[i])
}
for pass := range 8 { for pass := range 8 {
shift := uint(pass) * 8 shift := uint(pass) * 8
if byte(keyOR>>shift) == 0 {
continue
}
var count [256]int var count [256]int
for i := range src { for i := range src {
count[byte(sortKey(&src[i])>>shift)]++ count[byte(sortKey(&src[i])>>shift)]++
+1 -1
View File
@@ -240,7 +240,7 @@ Loop:
} }
body = body[:contentLength] body = body[:contentLength]
actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n")) actions, err := parseSingleActionList(strings.Trim(string(body), "\r\n"), false)
if err != nil { if err != nil {
return bad(err.Error()) return bad(err.Error())
} }
+479 -55
View File
@@ -57,8 +57,11 @@ var offsetComponentRegex *regexp.Regexp
var offsetTrimCharsRegex *regexp.Regexp var offsetTrimCharsRegex *regexp.Regexp
var passThroughBeginRegex *regexp.Regexp var passThroughBeginRegex *regexp.Regexp
var passThroughEndTmuxRegex *regexp.Regexp var passThroughEndTmuxRegex *regexp.Regexp
var sixelBeginRegex *regexp.Regexp
var ttyin *os.File var ttyin *os.File
var inTmux = len(os.Getenv("TMUX")) > 0
const clearCode string = "\x1b[2J" const clearCode string = "\x1b[2J"
// Number of maximum focus events to process synchronously // Number of maximum focus events to process synchronously
@@ -68,6 +71,10 @@ const maxFocusEvents = 10000
// After this duration, users can press CTRL-C to terminate the command. // After this duration, users can press CTRL-C to terminate the command.
const blockDuration = 1 * time.Second const blockDuration = 1 * time.Second
// Skip exporting FZF_CURRENT_ITEM when the item is larger than this, so a huge
// item cannot overflow ARG_MAX and break exec for preview and other commands.
const maxCurrentItemEnvSize = 64 * 1024
func init() { func init() {
placeholder = regexp.MustCompile(`\\?(?:{[+*sfr]*[0-9,-.]*}|{q(?::s?[0-9,-.]+)?}|{fzf:(?:query|action|prompt)}|{[+*]?f?nf?})`) placeholder = regexp.MustCompile(`\\?(?:{[+*sfr]*[0-9,-.]*}|{q(?::s?[0-9,-.]+)?}|{fzf:(?:query|action|prompt)}|{[+*]?f?nf?})`)
whiteSuffix = regexp.MustCompile(`\s*$`) whiteSuffix = regexp.MustCompile(`\s*$`)
@@ -88,6 +95,7 @@ func init() {
*/ */
passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`) passThroughBeginRegex = regexp.MustCompile(`\x1bPtmux;\x1b\x1b|\x1b(_G|P[0-9;]*q)|\x1b]1337;`)
passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`) passThroughEndTmuxRegex = regexp.MustCompile(`[^\x1b]\x1b\\`)
sixelBeginRegex = regexp.MustCompile(`^\x1bP[0-9;]*q`)
} }
type jumpMode int type jumpMode int
@@ -140,6 +148,13 @@ type quitSignal struct {
err error err error
} }
type waitState struct {
blocked bool
blockedAt time.Time
pending []*action
searching bool // a search is in progress or the input is still loading
}
type previewer struct { type previewer struct {
version int64 version int64
lines []string lines []string
@@ -260,6 +275,7 @@ type Terminal struct {
ghost string ghost string
separator labelPrinter separator labelPrinter
separatorLen int separatorLen int
separatorAuto bool
spinner []string spinner []string
promptString string promptString string
prompt func() prompt func()
@@ -321,6 +337,7 @@ type Terminal struct {
trackBlocked bool trackBlocked bool
trackSync bool trackSync bool
trackKeyCache map[int32]bool trackKeyCache map[int32]bool
wait waitState
pendingSelections map[string]selectedItem pendingSelections map[string]selectedItem
targetIndex int32 targetIndex int32
delimiter Delimiter delimiter Delimiter
@@ -455,6 +472,7 @@ type Terminal struct {
clickFooterLine int clickFooterLine int
clickFooterColumn int clickFooterColumn int
proxyScript string proxyScript string
setNativeLabel func(string)
numLinesCache map[int32]numLinesCacheValue numLinesCache map[int32]numLinesCacheValue
raw bool raw bool
lastActivity time.Time lastActivity time.Time
@@ -720,6 +738,7 @@ const (
actExclude actExclude
actExcludeMulti actExcludeMulti
actAsync actAsync
actWait
) )
func (a actionType) Name() string { func (a actionType) Name() string {
@@ -871,8 +890,10 @@ func defaultKeymap() map[tui.Event][]*action {
addEvent(tui.AltKey('b'), actBackwardWord) addEvent(tui.AltKey('b'), actBackwardWord)
add(tui.ShiftLeft, actBackwardWord) add(tui.ShiftLeft, actBackwardWord)
add(tui.AltLeft, actBackwardWord)
addEvent(tui.AltKey('f'), actForwardWord) addEvent(tui.AltKey('f'), actForwardWord)
add(tui.ShiftRight, actForwardWord) add(tui.ShiftRight, actForwardWord)
add(tui.AltRight, actForwardWord)
addEvent(tui.AltKey('d'), actKillWord) addEvent(tui.AltKey('d'), actKillWord)
add(tui.AltBackspace, actBackwardKillWord) add(tui.AltBackspace, actBackwardKillWord)
@@ -1124,6 +1145,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
printer: opts.Printer, printer: opts.Printer,
printsep: opts.PrintSep, printsep: opts.PrintSep,
proxyScript: opts.ProxyScript, proxyScript: opts.ProxyScript,
setNativeLabel: nativeLabelSetter(),
merger: em, merger: em,
passMerger: em, passMerger: em,
resultMerger: em, resultMerger: em,
@@ -1152,7 +1174,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
bgSemaphore: make(chan struct{}, maxBgProcesses), bgSemaphore: make(chan struct{}, maxBgProcesses),
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, 7), // start | (load + result + result-final + zero|one) | (focus) | (resize)
timerChan: make(chan tui.Event), // unbuffered: every() ticks coalesce when main loop is busy timerChan: make(chan tui.Event), // unbuffered: every() ticks coalesce when main loop is busy
tui: renderer, tui: renderer,
ttyDefault: opts.TtyDefault, ttyDefault: opts.TtyDefault,
@@ -1162,7 +1184,10 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
lastAction: actStart, lastAction: actStart,
lastFocus: minItem.Index(), lastFocus: minItem.Index(),
lastActivity: time.Now(), lastActivity: time.Now(),
numLinesCache: make(map[int32]numLinesCacheValue)} numLinesCache: make(map[int32]numLinesCacheValue),
// The initial load counts as a search in progress ('start:wait').
// Set before the reader starts so the first final result clears it.
wait: waitState{searching: true}}
if opts.AcceptNth != nil { if opts.AcceptNth != nil {
t.acceptNth = opts.AcceptNth(t.delimiter) t.acceptNth = opts.AcceptNth(t.delimiter)
} }
@@ -1230,13 +1255,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
} }
// Determine input border shape // Determine input border shape
if t.inputBorderShape == tui.BorderLine { t.inputBorderShape = resolveInputBorderShape(t.layout, t.inputBorderShape)
if t.layout == layoutReverse {
t.inputBorderShape = tui.BorderBottom
} else {
t.inputBorderShape = tui.BorderTop
}
}
// Inline borders are embedded between the list's top and bottom horizontals. // Inline borders are embedded between the list's top and bottom horizontals.
// Shapes missing either one (none/phantom/line/single-sided) fall back to a plain // Shapes missing either one (none/phantom/line/single-sided) fall back to a plain
@@ -1281,14 +1300,16 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
} }
} }
// Disable separator by default if input border is set // The default separator can be suppressed at runtime depending on the
if opts.Separator == nil && !t.inputBorderShape.Visible() || opts.Separator != nil && len(*opts.Separator) > 0 { // window layout (see separatorLength)
bar := "─" t.separatorAuto = opts.Separator == nil
if opts.Separator != nil { bar := "─"
bar = *opts.Separator if opts.Separator != nil {
} else if !t.unicode { bar = *opts.Separator
bar = "-" } else if !t.unicode {
} bar = "-"
}
if len(bar) > 0 {
t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true) t.separator, t.separatorLen = t.ansiLabelPrinter(bar, &tui.ColSeparator, true)
} }
@@ -1345,6 +1366,9 @@ func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor
} }
_, t.hasStartActions = t.keymap[tui.Start.AsEvent()] _, t.hasStartActions = t.keymap[tui.Start.AsEvent()]
_, t.hasResultActions = t.keymap[tui.Result.AsEvent()] _, t.hasResultActions = t.keymap[tui.Result.AsEvent()]
if _, prs := t.keymap[tui.ResultFinal.AsEvent()]; prs {
t.hasResultActions = true
}
_, t.hasFocusActions = t.keymap[tui.Focus.AsEvent()] _, t.hasFocusActions = t.keymap[tui.Focus.AsEvent()]
_, t.hasLoadActions = t.keymap[tui.Load.AsEvent()] _, t.hasLoadActions = t.keymap[tui.Load.AsEvent()]
@@ -1439,8 +1463,10 @@ func (t *Terminal) environImpl(forPreview bool) []string {
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 { if item := t.currentItem(); item != nil {
// Skip if the value contains a NUL byte; exec(2) would reject the env. // Skip if the value contains a NUL byte (exec(2) would reject the env)
if s := item.AsString(t.ansi); !strings.ContainsRune(s, 0) { // or is too large (a huge item can overflow ARG_MAX and break exec
// entirely for preview and other child commands).
if s := item.AsString(t.ansi); !strings.ContainsRune(s, 0) && len(s) <= maxCurrentItemEnvSize {
env = append(env, "FZF_CURRENT_ITEM="+s) env = append(env, "FZF_CURRENT_ITEM="+s)
} }
} }
@@ -1672,7 +1698,129 @@ func (t *Terminal) parsePrompt(prompt string) (func(), int) {
} }
func (t *Terminal) noSeparatorLine() bool { func (t *Terminal) noSeparatorLine() bool {
return t.inputless || noSeparatorLine(t.infoStyle, t.separatorLen > 0) return t.inputless || noSeparatorLine(t.infoStyle, t.separatorLength() > 0)
}
// Effective length of the horizontal separator. The default separator is
// suppressed when the input section is already visually separated from the
// list section by a border line.
func (t *Terminal) separatorLength() int {
if t.separatorAuto && t.separatedByBorder() {
return 0
}
return t.separatorLen
}
// BorderLine for the input border resolves to a single line on the side
// facing the list section
func resolveInputBorderShape(layout layoutType, shape tui.BorderShape) tui.BorderShape {
if shape != tui.BorderLine {
return shape
}
if layout == layoutReverse {
return tui.BorderBottom
}
return tui.BorderTop
}
// Whether the input border draws a line on the side facing the list section
func inputBorderFacesList(layout layoutType, shape tui.BorderShape) bool {
shape = resolveInputBorderShape(layout, shape)
if layout == layoutReverse {
return shape.HasBottom()
}
return shape.HasTop()
}
// Whether a border line is already drawn between the info line and the
// section next to it on the list side, making the default horizontal
// separator redundant
func (t *Terminal) separatedByBorder() bool {
// The input border itself draws a line on the side facing the list section
if inputBorderFacesList(t.layout, t.inputBorderShape) {
return true
}
// A preview window at 'next' position sits right next to the input
// section, in front of the sections handled below
anyNext := false
for po := &t.previewOpts; po != nil; po = po.alternative {
if po.position == posNext {
anyNext = true
break
}
}
if anyNext && t.hasPreviewer() {
// The separator is hidden only when every spec in the threshold chain
// displays a preview window at 'next' position with a border line
// facing the input section. We cannot single out the active spec;
// which one is active depends on the terminal size, and the
// resolution (computePreviewSize in resizeWindows) itself consults
// noSeparatorLine. When not every spec qualifies, err on the side of
// showing the separator.
if len(t.previewOpts.command) == 0 {
// No preview command, so no preview window is normally shown, but
// the preview(...) action can still display one at any time
return false
}
// NOTE: resizeWindows can clear 'hidden' mid-layout when the
// preview(...) action forces the window, so the frame being laid out
// can briefly disagree with this decision until the next relayout
for po := &t.previewOpts; po != nil; po = po.alternative {
if po.position != posNext || po.hidden || po.size.size == 0 || !t.borderFacingInput(po.Border(t.layout)) {
return false
}
}
return true
}
hasHeaderLinesWindow, headerLinesShape := t.determineHeaderLinesShape()
hasHeaderWindow := t.hasHeaderWindow()
// Mirror of hasInputWindow in resizeWindows; a 'next' preview position
// gives the input section its own window even when no preview window is
// shown. A 'next' spec here implies there is no previewer (the block
// above returns otherwise), and without a previewer the position is
// resolved from the first spec alone, so a 'next' in a threshold
// alternative does not count.
hasInputWindow := t.previewOpts.position == posNext || t.inputBorderShape.Visible() || hasHeaderWindow || hasHeaderLinesWindow
if !hasInputWindow {
// The input section is embedded in the list window and no border
// separates the two
return false
}
// The input section has its own window. Determine which section it is
// facing, and check if the border of that section draws a line toward it.
// NOTE: hasHeaderWindow can be true with no header content when the input
// border is visible (see Terminal.hasHeaderWindow). An empty header window
// takes no space, so it cannot be the facing section.
hasHeaderSection := hasHeaderWindow && t.visibleHeaderLines() > 0
if hasHeaderSection && !t.headerFirst && t.headerBorderShape != tui.BorderInline {
// Header section is facing the input section. An inline header is
// excluded; it is drawn inside the list border and is covered by the
// branches below.
return t.borderFacingInput(t.headerBorderShape)
}
// Header lines section is facing the input section, except in
// reverse-list layout where it is on the other side of the list section,
// or with --header-first and no header section where it is moved past
// the input section
if hasHeaderLinesWindow && t.layout != layoutReverseList && (hasHeaderWindow || !t.headerFirst) {
return t.borderFacingInput(headerLinesShape)
}
// The input section sits right next to the list section
return t.borderFacingInput(t.listBorderShape)
}
// Whether the border of the section next to the input section on the list
// side draws a horizontal line facing the info line
func (t *Terminal) borderFacingInput(shape tui.BorderShape) bool {
if shape == tui.BorderInline {
// Embedded between the horizontal lines of the list border
return true
}
if t.layout == layoutReverse {
return shape.HasTop()
}
return shape.HasBottom()
} }
func getScrollbar(perLine int, total int, height int, offset int) (int, int) { func getScrollbar(perLine int, total int, height int, offset int) (int, int) {
@@ -1871,6 +2019,21 @@ func (t *Terminal) UpdateProgress(progress float32) {
func (t *Terminal) UpdateList(result MatchResult) { func (t *Terminal) UpdateList(result MatchResult) {
merger := result.merger merger := result.merger
t.mutex.Lock() t.mutex.Lock()
waitWasBlocked := t.wait.blocked
wakeUp := false
if result.final() {
t.wait.searching = false
// If waiting, unblock so main loop can execute pending actions.
// Note: any final result unblocks the wait, not just the one for the
// search that armed it. Back-to-back searches (--listen, bg-transform
// callbacks, reload+wait) can therefore unblock early and run the
// pending actions on the previous result set. Accepted; a per-search
// generation token through the matcher isn't worth the complexity.
if t.wait.blocked {
t.unblockWait()
wakeUp = len(t.wait.pending) > 0
}
}
prevIndex := minItem.Index() prevIndex := minItem.Index()
newRevision := merger.Revision() newRevision := merger.Revision()
if t.revision.compatible(newRevision) && t.track != trackDisabled { if t.revision.compatible(newRevision) && t.track != trackDisabled {
@@ -2022,13 +2185,33 @@ func (t *Terminal) UpdateList(result MatchResult) {
} }
} }
if t.hasResultActions { if t.hasResultActions {
t.pendingReqList = true result := tui.Result.AsEvent()
t.eventChan <- tui.Result.AsEvent() if _, prs := t.keymap[result]; prs {
t.pendingReqList = true
t.eventChan <- result
}
if !t.reading {
resultFinal := tui.ResultFinal.AsEvent()
if _, prs := t.keymap[resultFinal]; prs {
t.pendingReqList = true
t.eventChan <- resultFinal
}
}
} }
updateList := !t.trackBlocked && !t.pendingReqList updateList := !t.trackBlocked && !t.pendingReqList
updatePrompt := trackWasBlocked && !t.trackBlocked updatePrompt := (trackWasBlocked && !t.trackBlocked) || (waitWasBlocked && !t.wait.blocked)
t.mutex.Unlock() t.mutex.Unlock()
// Wake up the main loop to execute pending actions after wait unblocks.
// Send from a goroutine; UpdateList runs inside the event box callback,
// and an inline send on a full channel would deadlock with the main loop
// blocking on eventBox.Set while trying to drain the channel.
if wakeUp {
go func() {
t.serverInputChan <- []*action{{t: actIgnore}}
}()
}
t.reqBox.Set(reqInfo, nil) t.reqBox.Set(reqInfo, nil)
if updateList { if updateList {
t.reqBox.Set(reqList, nil) t.reqBox.Set(reqList, nil)
@@ -3244,7 +3427,7 @@ func (t *Terminal) printPrompt() {
color := tui.ColInput color := tui.ColInput
if t.paused { if t.paused {
color = tui.ColDisabled color = tui.ColDisabled
} else if t.trackBlocked { } else if t.trackBlocked || t.waitFeedback() {
color = color.WithAttr(tui.Dim) color = color.WithAttr(tui.Dim)
} }
w.CPrint(color, string(before)) w.CPrint(color, string(before))
@@ -3272,6 +3455,7 @@ func (t *Terminal) printInfoImpl() {
} }
pos := 0 pos := 0
line := 0 line := 0
separatorLen := t.separatorLength()
maxHeight := t.window.Height() maxHeight := t.window.Height()
move := func(y int, x int, clear bool) bool { move := func(y int, x int, clear bool) bool {
if y < 0 || y >= maxHeight { if y < 0 || y >= maxHeight {
@@ -3299,7 +3483,7 @@ func (t *Terminal) printInfoImpl() {
str = string(trimmed) str = string(trimmed)
width = maxWidth width = maxWidth
} }
move(line, pos, t.separatorLen == 0) move(line, pos, separatorLen == 0)
if t.reading { if t.reading {
t.window.CPrint(tui.ColSpinner, str) t.window.CPrint(tui.ColSpinner, str)
} else { } else {
@@ -3308,7 +3492,7 @@ func (t *Terminal) printInfoImpl() {
pos += width pos += width
} }
printSeparator := func(fillLength int, pad bool) { printSeparator := func(fillLength int, pad bool) {
if t.separatorLen > 0 { if separatorLen > 0 {
t.separator(t.window, fillLength) t.separator(t.window, fillLength)
t.window.Print(" ") t.window.Print(" ")
} else if pad { } else if pad {
@@ -3317,7 +3501,7 @@ func (t *Terminal) printInfoImpl() {
} }
if t.infoStyle == infoHidden { if t.infoStyle == infoHidden {
if t.separatorLen > 0 { if separatorLen > 0 {
if !move(line+1, 0, false) { if !move(line+1, 0, false) {
return return
} }
@@ -3336,9 +3520,6 @@ func (t *Terminal) printInfoImpl() {
output += fmt.Sprintf(" (%d/%d)", len(t.selected), t.multi) output += fmt.Sprintf(" (%d/%d)", len(t.selected), t.multi)
} }
} }
if t.progress > 0 && t.progress < 100 {
output += fmt.Sprintf(" (%d%%)", t.progress)
}
if t.toggleSort { if t.toggleSort {
if t.sort { if t.sort {
output += " +S" output += " +S"
@@ -3359,6 +3540,14 @@ func (t *Terminal) printInfoImpl() {
output += " +t" output += " +t"
} }
} }
if t.waitFeedback() {
output += " (..)"
}
// Keep the search progress at the end so the other indicators don't shift
// as it appears and disappears.
if t.progress > 0 && t.progress < 100 {
output += fmt.Sprintf(" (%d%%)", t.progress)
}
if t.failed != nil && t.count == 0 { if t.failed != nil && t.count == 0 {
output = fmt.Sprintf("[Command failed: %s]", *t.failed) output = fmt.Sprintf("[Command failed: %s]", *t.failed)
} }
@@ -3375,7 +3564,7 @@ func (t *Terminal) printInfoImpl() {
} }
switch t.infoStyle { switch t.infoStyle {
case infoDefault: case infoDefault:
if !move(line+1, 0, t.separatorLen == 0) { if !move(line+1, 0, separatorLen == 0) {
return return
} }
printSpinner() printSpinner()
@@ -3459,7 +3648,7 @@ func (t *Terminal) printInfoImpl() {
} }
if t.infoStyle == infoInlineRight { if t.infoStyle == infoInlineRight {
if t.separatorLen > 0 { if separatorLen > 0 {
if !move(line+1, 0, false) { if !move(line+1, 0, false) {
return return
} }
@@ -4490,15 +4679,23 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
height := t.pwindow.Height() height := t.pwindow.Height()
body := t.previewer.lines body := t.previewer.lines
headerLines := t.activePreviewOpts.headerLines headerLines := t.activePreviewOpts.headerLines
lineNo := -t.previewer.offset + headerLines
// Scrollbar is sized from the body alone, split off or not
scrollLines := len(body)
// Do not enable preview header lines if it's value is too large // Do not enable preview header lines if it's value is too large
if headerLines > 0 && headerLines < min(len(body), height) { if headerLines > 0 && headerLines < min(len(body), height) {
scrollLines -= headerLines
header := t.previewer.lines[0:headerLines] header := t.previewer.lines[0:headerLines]
body = t.previewer.lines[headerLines:] // A separate header pass would resume the body inside an image, which
// Always redraw header // takes up more rows than the line it arrives on
t.renderPreviewText(height, header, 0, false) if !containsImage(header) {
t.pwindow.MoveAndClear(t.pwindow.Y(), 0) // Always redraw header
t.renderPreviewText(height, header, 0, false)
t.pwindow.MoveAndClear(t.pwindow.Y(), 0)
body = t.previewer.lines[headerLines:]
}
} }
t.renderPreviewText(height, body, -t.previewer.offset+headerLines, unchanged) t.renderPreviewText(height, body, lineNo, unchanged)
if !unchanged { if !unchanged {
t.pwindow.FinishFill() t.pwindow.FinishFill()
@@ -4509,7 +4706,7 @@ func (t *Terminal) renderPreviewArea(unchanged bool) {
} }
effectiveHeight := height - headerLines effectiveHeight := height - headerLines
barLength, barStart := getScrollbar(1, len(body), effectiveHeight, min(len(body)-effectiveHeight, t.previewer.offset-headerLines)) barLength, barStart := getScrollbar(1, scrollLines, effectiveHeight, min(scrollLines-effectiveHeight, t.previewer.offset-headerLines))
t.renderPreviewScrollbar(headerLines, barLength, barStart) t.renderPreviewScrollbar(headerLines, barLength, barStart)
} }
@@ -4573,6 +4770,66 @@ func findPassThrough(line string) []int {
return []int{loc[0], loc[1] + pos + 2} return []int{loc[0], loc[1] + pos + 2}
} }
// tmux takes a bare APC as a request to set the pane title, so a Kitty
// graphics command never reaches the terminal and clobbers the title on the
// way. 'kitten icat --clear' emits one unwrapped. Sixel is left alone.
// https://github.com/junegunn/fzf/issues/4870
func wrapPassThrough(passThrough string, tmux bool) string {
if !tmux || !strings.HasPrefix(passThrough, "\x1b_G") {
return passThrough
}
// Only the sequence is passed through, not the trailing CR
suffix := ""
if strings.HasSuffix(passThrough, "\r") {
passThrough, suffix = passThrough[:len(passThrough)-1], "\r"
}
return "\x1bPtmux;" + strings.ReplaceAll(passThrough, "\x1b", "\x1b\x1b") + "\x1b\\" + suffix
}
// Whether the sequence draws an image. Kitty commands that only transmit or
// delete do not
func isImagePassThrough(passThrough string) bool {
// Unwrap the tmux passthrough sequence, in which every ESC is doubled
if after, ok := strings.CutPrefix(passThrough, "\x1bPtmux;"); ok {
passThrough = strings.ReplaceAll(after, "\x1b\x1b", "\x1b")
}
if after, ok := strings.CutPrefix(passThrough, "\x1b_G"); ok {
// Control data ends at the payload delimiter or at the terminator
keys := after
if index := strings.IndexAny(keys, ";\x1b"); index >= 0 {
keys = keys[:index]
}
for _, key := range strings.Split(keys, ",") {
// Transmit and display, or put an image already transmitted
if key == "a=T" || key == "a=p" {
return true
}
}
return false
}
if after, ok := strings.CutPrefix(passThrough, "\x1b]1337;"); ok {
return strings.HasPrefix(after, "File=") || strings.HasPrefix(after, "MultipartFile=")
}
return sixelBeginRegex.MatchString(passThrough)
}
// Whether any line carries an image
func containsImage(lines []string) bool {
for _, line := range lines {
for {
loc := findPassThrough(line)
if loc == nil {
break
}
if isImagePassThrough(line[loc[0]:loc[1]]) {
return true
}
line = line[loc[1]:]
}
}
return false
}
func extractPassThroughs(line string) ([]string, string) { func extractPassThroughs(line string) ([]string, string) {
passThroughs := []string{} passThroughs := []string{}
transformed := "" transformed := ""
@@ -4813,7 +5070,7 @@ Loop:
} else { } else {
t.pwindow.Move(y, x) t.pwindow.Move(y, x)
} }
t.tui.PassThrough(passThrough) t.tui.PassThrough(wrapPassThrough(passThrough, inTmux))
if requiredLines > 0 { if requiredLines > 0 {
if y+requiredLines == height { if y+requiredLines == height {
@@ -5765,12 +6022,63 @@ func (t *Terminal) unblockTrack() {
t.trackBlocked = false t.trackBlocked = false
t.trackKey = "" t.trackKey = ""
t.trackKeyCache = nil t.trackKeyCache = nil
if !t.inputless { // Keep the cursor hidden if the wait feedback is still showing it
if !t.inputless && !t.waitFeedback() {
t.tui.ShowCursor() t.tui.ShowCursor()
} }
} }
} }
// The wait state machine. Invariant: arming captures every action after
// 'wait' at any nesting level into wait.pending; while blocked, actions are
// dropped unless they are results of work started before the block
// (bg-transform callbacks, bracketed paste bookkeeping); abort/cancel
// discards everything.
// blockWait blocks action execution and defers the given actions until the
// current search completes (see UpdateList)
func (t *Terminal) blockWait(pending []*action) {
t.wait.blocked = true
t.wait.blockedAt = time.Now()
// Clone so that later joins don't append into the backing array of the
// bound action list
t.wait.pending = slices.Clone(pending)
// Show the waiting feedback only if the search takes long enough,
// so that quick searches don't cause flickering
go func() {
timer := time.NewTimer(progressMinDuration)
<-timer.C
t.mutex.Lock()
blocked := t.wait.blocked
t.mutex.Unlock()
if blocked {
t.reqBox.Set(reqPrompt, nil)
t.reqBox.Set(reqInfo, nil)
}
}()
}
// unblockWait lifts the block, leaving the pending actions to the caller:
// UpdateList keeps them for the main loop to replay, cancelWait discards them
func (t *Terminal) unblockWait() {
t.wait.blocked = false
// Restore the cursor unless it's still hidden for another reason
if !t.inputless && !t.trackBlocked {
t.tui.ShowCursor()
}
}
// cancelWait unblocks and discards the pending actions (user abort)
func (t *Terminal) cancelWait() {
t.unblockWait()
t.wait.pending = nil
}
// Debounce visual feedback so quick searches don't cause flashing
func (t *Terminal) waitFeedback() bool {
return t.wait.blocked && time.Since(t.wait.blockedAt) > progressMinDuration
}
func (t *Terminal) addClickHeaderWord(env []string) []string { func (t *Terminal) addClickHeaderWord(env []string) []string {
/* /*
* echo $'HL1\nHL2' | fzf --header-lines 3 --header $'H1\nH2' --header-lines-border --bind 'click-header:preview:env | grep FZF_CLICK' * echo $'HL1\nHL2' | fzf --header-lines 3 --header $'H1\nH2' --header-lines-border --bind 'click-header:preview:env | grep FZF_CLICK'
@@ -5953,6 +6261,7 @@ func (t *Terminal) Loop() error {
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
signal.Stop(intChan)
return return
case s := <-intChan: case s := <-intChan:
// Don't quit by SIGINT while executing because it should be for the executing command and not for fzf itself // Don't quit by SIGINT while executing because it should be for the executing command and not for fzf itself
@@ -5965,7 +6274,7 @@ func (t *Terminal) Loop() error {
if !t.tui.ShouldEmitResizeEvent() { if !t.tui.ShouldEmitResizeEvent() {
resizeChan := make(chan os.Signal, 1) resizeChan := make(chan os.Signal, 1)
notifyOnResize(resizeChan) // Non-portable notifyOnResize(ctx, resizeChan) // Non-portable
go func() { go func() {
for { for {
select { select {
@@ -6396,6 +6705,10 @@ func (t *Terminal) Loop() error {
t.printFooter() t.printFooter()
} }
} }
// Hide the cursor while the debounced waiting feedback is shown
if !t.inputless && !t.trackBlocked && t.waitFeedback() {
t.tui.HideCursor()
}
t.flush() t.flush()
t.mutex.Unlock() t.mutex.Unlock()
t.uiMutex.Unlock() t.uiMutex.Unlock()
@@ -6452,6 +6765,10 @@ func (t *Terminal) Loop() error {
var newCommand *commandSpec var newCommand *commandSpec
var reloadSync bool var reloadSync bool
var denylist []int32 var denylist []int32
// True while running bg-transform callbacks. Declared outside the loop
// because callbacks invoke the doActions closure of the iteration that
// scheduled them, not the one executing actAsync.
inBgCallback := false
req := func(evts ...util.EventType) { req := func(evts ...util.EventType) {
for _, event := range evts { for _, event := range evts {
events = append(events, event) events = append(events, event)
@@ -6543,12 +6860,15 @@ func (t *Terminal) Loop() error {
} }
t.mutex.Lock() t.mutex.Lock()
for key, ret := range t.expect { // Ignore --expect keys while wait-blocked like the rest of the input
if keyMatch(key, event) { if !t.wait.blocked {
t.pressed = ret for key, ret := range t.expect {
t.mutex.Unlock() if keyMatch(key, event) {
t.reqBox.Set(reqClose, nil) t.pressed = ret
return nil t.mutex.Unlock()
t.reqBox.Set(reqClose, nil)
return nil
}
} }
} }
triggering := map[tui.Event]struct{}{} triggering := map[tui.Event]struct{}{}
@@ -6598,12 +6918,41 @@ func (t *Terminal) Loop() error {
var doAction func(*action) bool var doAction func(*action) bool
doActions := func(actions []*action) bool { doActions := func(actions []*action) bool {
// Snapshot to detect query edits in this batch that trigger a
// search at loop end without setting 'changed'. String copy:
// edits mutate t.input in place.
queryBefore := string(t.input)
for iter := 0; iter <= maxFocusEvents; iter++ { for iter := 0; iter <= maxFocusEvents; iter++ {
currentIndex := t.currentIndex() currentIndex := t.currentIndex()
for _, action := range actions { for i, action := range actions {
if action.t == actWait {
// Already waiting. Actions parsed from a bg-transform
// result join the current wait; user input can't reset
// the blocked state
if t.wait.blocked {
if inBgCallback {
t.wait.pending = append(t.wait.pending, actions[i+1:]...)
}
return true
}
// Block if search is in progress or will be triggered
if changed || newCommand != nil || t.wait.searching || queryBefore != string(t.input) {
t.blockWait(actions[i+1:])
return true
}
// No search, wait is a no-op; continue to next action
continue
}
blockedBefore := t.wait.blocked
if !doAction(action) { if !doAction(action) {
return false return false
} }
// If this action armed the wait through a nested list
// (e.g. via trigger), defer the rest of this list too
if !blockedBefore && t.wait.blocked {
t.wait.pending = append(t.wait.pending, actions[i+1:]...)
return true
}
// A terminal action performed. We should stop processing more. // A terminal action performed. We should stop processing more.
if !looping { if !looping {
break break
@@ -6649,8 +6998,22 @@ func (t *Terminal) Loop() error {
callback(a.a) callback(a.a)
} }
} }
// Actions that run even while wait/track-blocked: bg-transform
// callbacks and their parsed actions (results of processes
// started before the block), and bracketed paste bookkeeping (a
// swallowed paste-end would leave t.pasting set forever).
passthrough := inBgCallback || a.t == actAsync ||
a.t == actBracketedPasteBegin || a.t == actBracketedPasteEnd
// When wait-blocked, only allow abort/cancel
if t.wait.blocked && !passthrough {
if a.t == actAbort || a.t == actCancel {
t.cancelWait()
req(reqPrompt, reqInfo)
}
return true
}
// When track-blocked, only allow abort/cancel and track-disabling actions // When track-blocked, only allow abort/cancel and track-disabling actions
if t.trackBlocked && a.t != actToggleTrack && a.t != actToggleTrackCurrent && a.t != actUntrackCurrent { if t.trackBlocked && !passthrough && a.t != actToggleTrack && a.t != actToggleTrackCurrent && a.t != actUntrackCurrent {
if a.t == actAbort || a.t == actCancel { if a.t == actAbort || a.t == actCancel {
t.unblockTrack() t.unblockTrack()
req(reqPrompt, reqInfo) req(reqPrompt, reqInfo)
@@ -6661,11 +7024,13 @@ func (t *Terminal) Loop() error {
switch a.t { switch a.t {
case actIgnore, actStart, actClick: case actIgnore, actStart, actClick:
case actAsync: case actAsync:
inBgCallback = true
for _, callback := range callbacks { for _, callback := range callbacks {
if t.bgVersion == callback.version { if t.bgVersion == callback.version {
callback.callback() callback.callback()
} }
} }
inBgCallback = false
case actBecome: case actBecome:
valid, list := t.buildPlusList(a.a, false) valid, list := t.buildPlusList(a.a, false)
if valid { if valid {
@@ -6715,7 +7080,9 @@ func (t *Terminal) Loop() error {
t.mutex.Unlock() t.mutex.Unlock()
return false return false
case actBracketedPasteBegin: case actBracketedPasteBegin:
current := []rune(t.input) // Clone: []rune(t.input) would alias t.input, and in-place
// query edits during the paste would corrupt the snapshot
current := slices.Clone(t.input)
t.pasting = &current t.pasting = &current
case actBracketedPasteEnd: case actBracketedPasteEnd:
if t.pasting != nil { if t.pasting != nil {
@@ -6961,6 +7328,10 @@ func (t *Terminal) Loop() error {
if t.border != nil { if t.border != nil {
t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false) t.borderLabel, t.borderLabelLen = t.ansiLabelPrinter(label, &tui.ColBorderLabel, false)
req(reqRedrawBorderLabel) req(reqRedrawBorderLabel)
} else if t.setNativeLabel != nil {
// fzf draws no border of its own; the label is on the
// native border of the floating pane
t.setNativeLabel(label)
} }
}) })
case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel: case actChangePreviewLabel, actTransformPreviewLabel, actBgTransformPreviewLabel:
@@ -6973,7 +7344,8 @@ func (t *Terminal) Loop() error {
}) })
case actTransform, actBgTransform: case actTransform, actBgTransform:
capture(false, func(body string) { capture(false, func(body string) {
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n")); err == nil { // Allow 'put' if the triggering key is a printable character
if actions, err := parseSingleActionList(strings.Trim(body, "\r\n"), event.Printable()); err == nil {
// NOTE: We're not properly passing the return value here // NOTE: We're not properly passing the return value here
doActions(actions) doActions(actions)
} }
@@ -7532,7 +7904,8 @@ func (t *Terminal) Loop() error {
req(reqPrompt) req(reqPrompt)
case actTrigger: case actTrigger:
if _, chords, err := parseKeyChords(a.a, ""); err == nil { if _, chords, err := parseKeyChords(a.a, ""); err == nil {
for _, chord := range chords { blockedBefore := t.wait.blocked
for ci, chord := range chords {
if _, prs := triggering[chord]; prs { if _, prs := triggering[chord]; prs {
// Avoid recursive triggering // Avoid recursive triggering
continue continue
@@ -7542,6 +7915,19 @@ func (t *Terminal) Loop() error {
doActions(acts) doActions(acts)
delete(triggering, chord) delete(triggering, chord)
} }
// If this chord armed the wait, defer the remaining chords
if !blockedBefore && t.wait.blocked {
for _, rest := range chords[ci+1:] {
if _, prs := triggering[rest]; prs {
// Avoid recursive triggering
continue
}
if acts, prs := t.keymap[rest]; prs {
t.wait.pending = append(t.wait.pending, acts...)
}
}
break
}
} }
} }
case actSigStop: case actSigStop:
@@ -7970,6 +8356,8 @@ func (t *Terminal) Loop() error {
case actChangePreviewWindow: case actChangePreviewWindow:
// NOTE: We intentionally use "previewOpts" instead of "activePreviewOpts" here // NOTE: We intentionally use "previewOpts" instead of "activePreviewOpts" here
currentPreviewOpts := t.previewOpts currentPreviewOpts := t.previewOpts
wasNoSeparatorLine := t.noSeparatorLine()
wasSeparatorLength := t.separatorLength()
// Reset preview options and apply the additional options // Reset preview options and apply the additional options
t.previewOpts = t.initialPreviewOpts t.previewOpts = t.initialPreviewOpts
@@ -7990,8 +8378,28 @@ func (t *Terminal) Loop() error {
a.a = strings.Join(append(tokens[1:], tokens[0]), "|") a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
} }
// Do not drop a preview window forced by the preview(...) action
keepForcedPreview := t.hasPreviewWindow() && !t.activePreviewOpts.hidden
// The default separator is decided from the whole threshold
// chain (separatedByBorder), which compare does not fully
// inspect, so it can change even when compare finds no
// layout difference
checkSeparator := func() {
if t.noSeparatorLine() != wasNoSeparatorLine {
// Number of lines changed; relayout
updatePreviewWindow(keepForcedPreview)
req(reqPreviewRefresh)
} else if t.separatorLength() != wasSeparatorLength {
// Only the content of the info line changed
req(reqInfo)
}
}
// Full redraw // Full redraw
switch currentPreviewOpts.compare(t.activePreviewOpts, &t.previewOpts) { switch currentPreviewOpts.compare(t.activePreviewOpts, &t.previewOpts) {
case previewOptsSame:
checkSeparator()
case previewOptsDifferentLayout: case previewOptsDifferentLayout:
// Preview command can be running in the background if the size of // Preview command can be running in the background if the size of
// the preview window is 0 but not 'hidden' // the preview window is 0 but not 'hidden'
@@ -7999,7 +8407,7 @@ func (t *Terminal) Loop() error {
// FIXME: One-time preview window can't reappear once hidden // FIXME: One-time preview window can't reappear once hidden
// fzf --bind space:preview:ls --bind 'enter:change-preview-window:down|left|up|hidden|' // fzf --bind space:preview:ls --bind 'enter:change-preview-window:down|left|up|hidden|'
updatePreviewWindow(t.hasPreviewWindow() && !t.activePreviewOpts.hidden) updatePreviewWindow(keepForcedPreview)
if wasHidden && t.hasPreviewWindow() { if wasHidden && t.hasPreviewWindow() {
// Restart // Restart
refreshPreview(t.previewOpts.command) refreshPreview(t.previewOpts.command)
@@ -8013,6 +8421,7 @@ func (t *Terminal) Loop() error {
case previewOptsDifferentContentLayout: case previewOptsDifferentContentLayout:
t.previewed.version = 0 t.previewed.version = 0
req(reqPreviewRefresh) req(reqPreviewRefresh)
checkSeparator()
} }
// Adjust scroll offset // Adjust scroll offset
@@ -8055,9 +8464,21 @@ func (t *Terminal) Loop() error {
return true return true
} }
if t.jumping == jumpDisabled || len(actions) > 0 { // Execute pending actions if wait just unblocked. Capture the jump
// state first so that a pending 'jump' action isn't cancelled right
// away by the wake-up event below.
jumpingBefore := t.jumping
if len(t.wait.pending) > 0 && !t.wait.blocked {
pending := t.wait.pending
t.wait.pending = nil
if !doActions(pending) {
continue
}
}
if jumpingBefore == jumpDisabled || len(actions) > 0 {
// Break out of jump mode if any action is submitted to the server // Break out of jump mode if any action is submitted to the server
if t.jumping != jumpDisabled { if jumpingBefore != jumpDisabled {
t.jumping = jumpDisabled t.jumping = jumpDisabled
if acts, prs := t.keymap[tui.JumpCancel.AsEvent()]; prs && !doActions(acts) { if acts, prs := t.keymap[tui.JumpCancel.AsEvent()]; prs && !doActions(acts) {
continue continue
@@ -8116,6 +8537,9 @@ func (t *Terminal) Loop() error {
} }
reload := changed || newCommand != nil reload := changed || newCommand != nil
if reload {
t.wait.searching = true
}
var reloadRequest *searchRequest var reloadRequest *searchRequest
if reload { if reload {
reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, withNth: newWithNth, headerLines: newHeaderLines, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.resultMerger.Revision()} reloadRequest = &searchRequest{sort: t.sort, sync: reloadSync, nth: newNth, withNth: newWithNth, headerLines: newHeaderLines, command: newCommand, environ: t.environ(), changed: changed, denylist: denylist, revision: t.resultMerger.Revision()}
+83
View File
@@ -549,6 +549,89 @@ func TestExtractPassthroughs(t *testing.T) {
} }
} }
func TestWrapPassThrough(t *testing.T) {
for _, passThrough := range []string{
"\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", // Already wrapped
"\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", // Sixel
"\x1b]1337;File=inline=1:AAAA\a", // iTerm2
} {
if got := wrapPassThrough(passThrough, true); got != passThrough {
t.Errorf("should have been left alone: %q -> %q", passThrough, got)
}
}
kitty := "\x1b_Ga=d,d=A\x1b\\"
if got := wrapPassThrough(kitty, false); got != kitty {
t.Errorf("should have been left alone outside of tmux: %q", got)
}
// ESC characters are doubled, and the trailing carriage return is kept
// outside of the wrapper
for _, test := range []struct{ input, want string }{
{kitty, "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\"},
{kitty + "\r", "\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\\r"},
{"\x1b_Gm=1;\x1bAAA=\x1b\\", "\x1bPtmux;\x1b\x1b_Gm=1;\x1b\x1bAAA=\x1b\x1b\\\x1b\\"},
} {
if got := wrapPassThrough(test.input, true); got != test.want {
t.Errorf("expected %q, got %q", test.want, got)
}
}
}
func TestIsImagePassThrough(t *testing.T) {
for _, tc := range []struct {
given string
image bool
}{
// Kitty
{"\x1b_Ga=T,f=32,s=1258,v=1295,c=74,r=35,m=1\x1b\\", true},
{"\x1b_Ga=p,i=1\x1b\\", true},
// The action key is not always first, and a put carries no payload
{"\x1b_Gi=1,a=p\x1b\\", true},
{"\x1b_Ga=p\x1b\\", true},
{"\x1b_Ga=T,f=100\x1b\\\r", true},
{"\x1b_Gi=1,a=d\x1b\\", false},
{"\x1b_Ga=d,d=A\x1b\\", false}, // 'kitten icat --clear'
{"\x1b_Ga=q,i=1\x1b\\", false}, // query
{"\x1b_Gi=1,f=100\x1b\\", false}, // transmit only, the default action
{"\x1b_Gm=1;AAAA\x1b\\", false}, // continuation chunk
{"\x1b_Ga=f,i=1;AAAA\x1b\\", false}, // animation frame
{"\x1b_Ga=T,U=1,f=32;AAAA\x1b\\", true}, // unicode placeholder
// Wrapped in the tmux passthrough sequence
{"\x1bPtmux;\x1b\x1b_Ga=T,f=100\x1b\x1b\\\x1b\\", true},
{"\x1bPtmux;\x1b\x1b_Ga=d,d=A\x1b\x1b\\\x1b\\", false},
{"\x1bPtmux;\x1b\x1b_Gi=1,a=p\x1b\x1b\\\x1b\\", true},
// iTerm2
{"\x1b]1337;File=inline=1:AAAA\a", true},
{"\x1b]1337;MultipartFile=inline=1\a", true},
{"\x1b]1337;SetUserVar=foo=YmFy\a", false},
{"\x1b]1337;CurrentDir=/tmp\a", false},
// Sixel
{"\x1bP0;1;0q#0;2;0;0;0#0~~@@vv@@~~@\x1b\\", true},
{"\x1bPq#0~~\x1b\\", true},
{"\x1bPtmux;\x1b\x1bP0;1;0q#0~~\x1b\x1b\\\x1b\\", true},
{"", false},
} {
if actual := isImagePassThrough(tc.given); actual != tc.image {
t.Errorf("expected %v for %q, got %v", tc.image, tc.given, actual)
}
}
if containsImage([]string{"foo", "bar"}) {
t.Error("plain text carries no image")
}
if !containsImage([]string{"foo", "bar\x1b_Ga=T,f=100\x1b\\baz"}) {
t.Error("failed to find an image in a line")
}
if containsImage([]string{"foo\x1b_Ga=d,d=A\x1b\\"}) {
t.Error("a delete command is not an image")
}
// The image is not the first passthrough on the line
if !containsImage([]string{"\x1b_Ga=d,d=A\x1b\\text\x1b_Ga=T,f=100;AAAA\x1b\\"}) {
t.Error("failed to look past an earlier passthrough")
}
}
/* utilities section */ /* utilities section */
// Item represents one line in fzf UI. Usually it is relative path to files and folders. // Item represents one line in fzf UI. Usually it is relative path to files and folders.
+6 -1
View File
@@ -3,6 +3,7 @@
package fzf package fzf
import ( import (
"context"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@@ -10,8 +11,12 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
func notifyOnResize(resizeChan chan<- os.Signal) { func notifyOnResize(ctx context.Context, resizeChan chan<- os.Signal) {
signal.Notify(resizeChan, syscall.SIGWINCH) signal.Notify(resizeChan, syscall.SIGWINCH)
go func() {
<-ctx.Done()
signal.Stop(resizeChan)
}()
} }
func notifyStop(p *os.Process) { func notifyStop(p *os.Process) {
+49 -2
View File
@@ -3,11 +3,58 @@
package fzf package fzf
import ( import (
"context"
"os" "os"
"syscall"
"time"
"golang.org/x/sys/windows"
) )
func notifyOnResize(resizeChan chan<- os.Signal) { const resizePollInterval = 100 * time.Millisecond
// TODO
type resizeSignal struct{}
func (resizeSignal) String() string { return "resize" }
func (resizeSignal) Signal() {}
// Windows has no SIGWINCH, so poll the console screen buffer for window
// size changes instead.
func notifyOnResize(ctx context.Context, resizeChan chan<- os.Signal) {
consoleOut, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0)
if err != nil {
return
}
var info windows.ConsoleScreenBufferInfo
if windows.GetConsoleScreenBufferInfo(windows.Handle(consoleOut), &info) != nil {
syscall.Close(consoleOut)
return
}
last := info.Window
go func() {
defer syscall.Close(consoleOut)
ticker := time.NewTicker(resizePollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
if windows.GetConsoleScreenBufferInfo(windows.Handle(consoleOut), &info) != nil {
continue
}
current := info.Window
if current.Right-current.Left != last.Right-last.Left ||
current.Bottom-current.Top != last.Bottom-last.Top {
last = current
select {
case resizeChan <- resizeSignal{}:
default:
}
}
}
}()
} }
func notifyStop(p *os.Process) { func notifyStop(p *os.Process) {
+224
View File
@@ -1,10 +1,234 @@
package fzf package fzf
import ( import (
"fmt"
"os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"github.com/junegunn/fzf/src/tui"
) )
// Returns the size of the current window if the tmux server supports
// floating panes (tmux 3.7 or above)
func tmuxFloatingPaneInfo() (int, int, bool) {
// TMUX_PANE is not set when fzf is not started from within a pane
// (e.g. 'bind-key 0 run-shell "fzf --popup"'). Do not use a floating
// pane there; a blocking run-shell suspends key processing for the
// client until the command exits, so fzf in a floating pane would
// never receive a key and the client would appear frozen until the
// process is killed externally. A popup is unaffected; its input is
// handled at the client overlay level, bypassing the suspended path.
// The right approach for such bindings is 'run-shell -b', which does
// not suspend key processing, as discussed in:
// https://github.com/tmux/tmux/issues/5384
target := os.Getenv("TMUX_PANE")
if target == "" {
return 0, 0, false
}
// A single invocation for both checks. Cannot rely on the exit status;
// tmux versions before 3.7 exit normally with empty output for an
// unknown command name, so check the output instead.
out, err := exec.Command("tmux", "display-message", "-p", "-t", target,
"#{window_width} #{window_height}", ";", "list-commands", "new-pane").Output()
if err != nil || !strings.Contains(string(out), "new-pane") {
return 0, 0, false
}
var width, height int
if _, err := fmt.Sscanf(string(out), "%d %d", &width, &height); err != nil {
return 0, 0, false
}
// The window is too small to fit a floating pane of the minimum size
if width < 3 || height < 3 {
return 0, 0, false
}
return width, height, true
}
// tmux ends a command at an argument ending in ';', so a trailing one is
// escaped. Elsewhere it is not special
func escapeTmuxSeparator(str string) string {
if strings.HasSuffix(str, ";") {
return str[:len(str)-1] + `\;`
}
return str
}
// '#[...]' is still processed when a substituted value is drawn, so '#' is
// doubled. '#{...}' is already literal
func escapeTmuxFormat(str string) string {
return escapeTmuxSeparator(strings.ReplaceAll(str, "#", "##"))
}
// The option pane-border-format reads back, and the pane it is set on
const (
tmuxBorderLabelOption = "@fzf-border-label"
tmuxBorderLabelEnv = internalEnvPrefix + "TMUX_LABEL_PANE"
)
// Errors are ignored. The pane may be gone
func setTmuxBorderLabel(pane string, label string) {
// Strip ANSI sequences. tmux would draw the escape's remainder as text
label, _, _ = extractColor(label, nil, nil)
exec.Command("tmux", "set-option", "-p", "-t", pane,
tmuxBorderLabelOption, escapeTmuxFormat(label)).Run()
}
// Convert sizeSpec to the number of cells, clamped between the minimum
// footprint of 3, including the border, and the window size
func tmuxDim(spec sizeSpec, window int) int {
dim := int(spec.size)
if spec.percent {
dim = window * dim / 100
}
return max(3, min(dim, window))
}
func runTmuxFloatingPane(argStr string, dir string, windowWidth int, windowHeight int, opts *Options) (int, error) {
// Unlike display-popup, the size of a floating pane does not account for
// the border around it, and the position is that of the content area. To
// stay consistent with popups, treat the requested size as the total
// footprint including the border.
width := tmuxDim(opts.Tmux.width, windowWidth)
height := tmuxDim(opts.Tmux.height, windowHeight)
x := (windowWidth-width)/2 + 1
y := (windowHeight-height)/2 + 1
switch opts.Tmux.position {
case posUp:
y = 1
case posDown:
y = windowHeight - height + 1
case posLeft:
x = 1
case posRight:
x = windowWidth - width + 1
}
return runProxy(argStr, func(temp string, needBash bool) (*exec.Cmd, error) {
sh, err := sh(needBash)
if err != nil {
return nil, err
}
// Unlike display-popup, new-pane does not block until the command
// finishes, and it does not propagate the exit status. So we block on
// a wait-for channel that the pane signals on completion, and pass
// the exit status through a temporary file. A watchdog process
// signals the same channel if the pane is closed abnormally
// (e.g. kill-pane), in which case the file is not written.
//
// has-session is the liveness check because it fails when the target
// pane is gone, while display-message succeeds even for a dead pane.
signal := escapeSingleQuote("fzf-" + filepath.Base(temp))
// Pre-create the exit status file so that another user on a shared
// TMPDIR cannot plant a file or a symbolic link at the predictable
// path while the pane is running
codeFile := temp + ".code"
f, err := os.OpenFile(codeFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
f.Close()
code := escapeSingleQuote(codeFile)
// Pane options are set by the pane itself before running fzf, so
// that they are in place no matter how quickly the command exits.
// The target must be explicit; without it, the commands would
// resolve to the active pane of the session's current window,
// which is not necessarily the pane running them.
//
// The pane should always close on exit like a popup, even when
// remain-on-exit is on.
setup := `tmux set-option -p -t "$TMUX_PANE" remain-on-exit off 2> /dev/null; `
// --border-label lives in a pane-scoped user option that
// pane-border-format reads back, so a program in the pane cannot
// replace it by setting the title. Set even when empty, so
// change-border-label can fill it in and the default pane status is
// not shown. pane-border-status is a window option until the next
// tmux release, so it is left alone.
// https://github.com/tmux/tmux/commit/7a18fa281db3
// --border-label-pos is ignored. With an explicit --border fzf draws
// the label itself, but the native border cannot be removed.
ownsLabel := noBorderSpecified(opts)
label := ""
if ownsLabel {
// Strip ANSI sequences fzf would otherwise render itself
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
}
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" %s %s 2> /dev/null; `,
tmuxBorderLabelOption, escapeSingleQuote(escapeTmuxFormat(label)))
setup += fmt.Sprintf(`tmux set-option -p -t "$TMUX_PANE" pane-border-format %s 2> /dev/null; `,
escapeSingleQuote("#{"+tmuxBorderLabelOption+"}"))
if ownsLabel {
setup += fmt.Sprintf(`export %s="$TMUX_PANE"; `, tmuxBorderLabelEnv)
}
paneCmd := fmt.Sprintf("%s%s %s; echo $? > %s; tmux wait-for -S %s",
setup, escapeSingleQuote(sh), escapeSingleQuote(temp), code, signal)
// Unzoom the window first; creating a floating pane over a zoomed
// window crashes the tmux server on 3.7b, and newer versions of
// tmux unzoom the window anyway.
target := os.Getenv("TMUX_PANE")
newPane := fmt.Sprintf(
"tmux if -F -t %s '#{window_zoomed_flag}' %s ';' new-pane -P -F '#{pane_id}' -t %s -c %s -x %d -y %d -X %d -Y %d %s -c %s",
escapeSingleQuote(target), escapeSingleQuote("resize-pane -Z -t "+target),
escapeSingleQuote(target), escapeSingleQuote(dir), width-2, height-2, x, y,
escapeSingleQuote(sh), escapeSingleQuote(paneCmd))
// The pane is killed when the proxy process is interrupted or hung up,
// like a popup dying with its client. wait-for runs in the background
// and is awaited with the interruptible wait builtin so that the trap
// can fire while blocked. The trap is installed before creating the
// pane; a signal received during creation is deferred until the
// command substitution completes, and the pane is killed right after.
// An interrupted wait does not reap the waiter, so it is killed
// along with the watchdog.
script := fmt.Sprintf(`trap '[ -n "$id" ] && tmux kill-pane -t "$id" 2> /dev/null' INT TERM HUP
id=$(%s) || { status=$?; rm -f %s; exit "$status"; }
{ while tmux has-session -t "$id" 2> /dev/null; do sleep 1; done; tmux wait-for -S %s; } &
watchdog=$!
tmux wait-for %s &
waiter=$!
wait "$waiter"
kill "$watchdog" "$waiter" 2> /dev/null
wait 2> /dev/null
if [ -s %s ]; then code=$(cat %s); else code=130; fi
rm -f %s
exit "$code"`, newPane, code, signal, signal, code, code, code)
return exec.Command(sh, "-c", script), nil
}, opts, true)
}
// Whether no box border was asked for. 'none' and 'line' count as none.
// fzf draws no box for either
func noBorderSpecified(opts *Options) bool {
return opts.BorderShape == tui.BorderUndefined ||
opts.BorderShape == tui.BorderLine || opts.BorderShape == tui.BorderNone
}
// Whether to use the multiplexer's native border for the floating pane. Its
// native border is the handle that makes the pane movable and resizable with
// the mouse, so it is the default; 'border-native' forces it. It is not used
// when a border style is explicitly specified with --border, so that the
// fzf-drawn border is the only one shown.
func nativeBorder(opts *Options) bool {
return opts.Tmux.border || noBorderSpecified(opts)
}
func runTmux(args []string, opts *Options) (int, error) { func runTmux(args []string, opts *Options) (int, error) {
// On tmux 3.7 or above, fzf runs in a floating pane instead of a popup.
// When the native border is not used (an explicit --border style), a
// popup is used instead so that the fzf-drawn border is the only border
// shown; the native border of a tmux floating pane cannot be removed.
if nativeBorder(opts) {
if windowWidth, windowHeight, ok := tmuxFloatingPaneInfo(); ok {
opts.Tmux.border = true
argStr, dir := popupArgStr(args, opts)
return runTmuxFloatingPane(argStr, dir, windowWidth, windowHeight, opts)
}
}
argStr, dir := popupArgStr(args, opts) argStr, dir := popupArgStr(args, opts)
// Set tmux options for popup placement // Set tmux options for popup placement
+53
View File
@@ -0,0 +1,53 @@
package fzf
import "testing"
func TestEscapeTmuxFormat(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"", ""},
{" fzf ", " fzf "},
{"100%", "100%"},
// '#[' would be taken as a style directive when the label is drawn
{"#[fg=red]x", "##[fg=red]x"},
// '#{' is already literal in a substituted value, but doubling it
// keeps a label that fzf renders itself looking the same
{"#{pane_id}", "##{pane_id}"},
{" C# notes #S ", " C## notes ##S "},
{";", `\;`},
{"; rm", "; rm"},
{"C#;", `C##\;`},
} {
if actual := escapeTmuxFormat(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
}
func TestEscapeTmuxSeparator(t *testing.T) {
for _, tc := range []struct {
given string
expected string
}{
{"", ""},
{" fzf ", " fzf "},
{"#", "#"},
{"#{pane_id}", "#{pane_id}"},
{"100%", "100%"},
// tmux drops a trailing ';' and everything after it, so only that
// one is escaped
{";", `\;`},
{"abc;", `abc\;`},
{";;", `;\;`},
{`foo\;`, `foo\\;`},
{"; rm", "; rm"},
{" ; ", " ; "},
{"a;b", "a;b"},
} {
if actual := escapeTmuxSeparator(tc.given); actual != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, actual)
}
}
}
+3 -2
View File
@@ -164,11 +164,12 @@ func _() {
_ = x[ClickFooter-153] _ = x[ClickFooter-153]
_ = x[Multi-154] _ = x[Multi-154]
_ = x[Every-155] _ = x[Every-155]
_ = x[ResultFinal-156]
} }
const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEvery" const _EventType_name = "RuneCtrlACtrlBCtrlCCtrlDCtrlECtrlFCtrlGCtrlHTabCtrlJCtrlKCtrlLEnterCtrlNCtrlOCtrlPCtrlQCtrlRCtrlSCtrlTCtrlUCtrlVCtrlWCtrlXCtrlYCtrlZEscCtrlSpaceCtrlBackSlashCtrlRightBracketCtrlCaretCtrlSlashShiftTabBackspaceDeletePageUpPageDownUpDownLeftRightHomeEndInsertShiftUpShiftDownShiftLeftShiftRightShiftDeleteShiftHomeShiftEndShiftPageUpShiftPageDownF1F2F3F4F5F6F7F8F9F10F11F12AltBackspaceAltUpAltDownAltLeftAltRightAltDeleteAltHomeAltEndAltPageUpAltPageDownAltShiftUpAltShiftDownAltShiftLeftAltShiftRightAltShiftDeleteAltShiftHomeAltShiftEndAltShiftPageUpAltShiftPageDownCtrlUpCtrlDownCtrlLeftCtrlRightCtrlHomeCtrlEndCtrlBackspaceCtrlDeleteCtrlPageUpCtrlPageDownAltCtrlAltCtrlAltUpCtrlAltDownCtrlAltLeftCtrlAltRightCtrlAltHomeCtrlAltEndCtrlAltBackspaceCtrlAltDeleteCtrlAltPageUpCtrlAltPageDownCtrlShiftUpCtrlShiftDownCtrlShiftLeftCtrlShiftRightCtrlShiftHomeCtrlShiftEndCtrlShiftDeleteCtrlShiftPageUpCtrlShiftPageDownCtrlAltShiftUpCtrlAltShiftDownCtrlAltShiftLeftCtrlAltShiftRightCtrlAltShiftHomeCtrlAltShiftEndCtrlAltShiftDeleteCtrlAltShiftPageUpCtrlAltShiftPageDownMouseDoubleClickLeftClickRightClickSLeftClickSRightClickScrollUpScrollDownSScrollUpSScrollDownPreviewScrollUpPreviewScrollDownInvalidFatalBracketedPasteBeginBracketedPasteEndResizeChangeBackwardEOFStartLoadFocusOneZeroResultJumpJumpCancelClickHeaderClickFooterMultiEveryResultFinal"
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} var _EventType_index = [...]uint16{0, 4, 9, 14, 19, 24, 29, 34, 39, 44, 47, 52, 57, 62, 67, 72, 77, 82, 87, 92, 97, 102, 107, 112, 117, 122, 127, 132, 135, 144, 157, 173, 182, 191, 199, 208, 214, 220, 228, 230, 234, 238, 243, 247, 250, 256, 263, 272, 281, 291, 302, 311, 319, 330, 343, 345, 347, 349, 351, 353, 355, 357, 359, 361, 364, 367, 370, 382, 387, 394, 401, 409, 418, 425, 431, 440, 451, 461, 473, 485, 498, 512, 524, 535, 549, 565, 571, 579, 587, 596, 604, 611, 624, 634, 644, 656, 659, 666, 675, 686, 697, 709, 720, 730, 746, 759, 772, 787, 798, 811, 824, 838, 851, 863, 878, 893, 910, 924, 940, 956, 973, 989, 1004, 1022, 1040, 1060, 1065, 1076, 1085, 1095, 1105, 1116, 1124, 1134, 1143, 1154, 1169, 1186, 1193, 1198, 1217, 1234, 1240, 1246, 1257, 1262, 1266, 1271, 1274, 1278, 1284, 1288, 1298, 1309, 1320, 1325, 1330, 1341}
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) {
+65 -38
View File
@@ -39,6 +39,7 @@ func (r *LightRenderer) Bell() {
func (r *LightRenderer) PassThrough(str string) { func (r *LightRenderer) PassThrough(str string) {
r.queued.WriteString("\x1b7" + str + "\x1b8") r.queued.WriteString("\x1b7" + str + "\x1b8")
r.invalidateSGR()
} }
func (r *LightRenderer) stderr(str string) { func (r *LightRenderer) stderr(str string) {
@@ -90,14 +91,36 @@ func (r *LightRenderer) csi(code string) string {
return fullcode return fullcode
} }
// setSGR emits the given SGR sequence only when the terminal is not already
// in that state. Sequences built by csiColor start with a reset parameter,
// so each of them fully determines the state on its own. The zero value of
// r.sgr never matches a sequence, so the first update after Init, Resume, or
// invalidateSGR is always emitted.
func (r *LightRenderer) setSGR(code string) {
if code != r.sgr {
r.stderr(code)
r.sgr = code
}
}
// invalidateSGR marks the terminal SGR state as unknown, e.g. after raw
// output that may have changed it behind the renderer's back.
func (r *LightRenderer) invalidateSGR() {
r.sgr = ""
}
func (r *LightRenderer) flush() { func (r *LightRenderer) flush() {
if r.queued.Len() > 0 { if r.queued.Len() > 0 {
raw := "\x1b[?7l\x1b[?25l" + r.queued.String() // Leave the terminal in the default SGR state between frames
r.setSGR("\x1b[0m")
// Wrap the frame in synchronized update mode (2026) so that the
// terminal applies it atomically. Terminals without support ignore
// the unknown private mode and behave as before.
raw := "\x1b[?2026h\x1b[?7l\x1b[?25l" + r.queued.String()
if r.showCursor { if r.showCursor {
raw += "\x1b[?25h\x1b[?7h" raw += "\x1b[?25h"
} else {
raw += "\x1b[?7h"
} }
raw += "\x1b[?7h\x1b[?2026l"
r.flushRaw(raw) r.flushRaw(raw)
r.queued.Reset() r.queued.Reset()
} }
@@ -128,6 +151,7 @@ type LightRenderer struct {
fullscreen bool fullscreen bool
upOneLine bool upOneLine bool
queued strings.Builder queued strings.Builder
sgr string
y int y int
x int x int
maxHeightFunc func(int) int maxHeightFunc func(int) int
@@ -232,7 +256,7 @@ func (r *LightRenderer) Init() error {
r.csi("G") r.csi("G")
r.csi("K") r.csi("K")
if !r.clearOnExit && !r.fullscreen { if !r.clearOnExit && !r.fullscreen {
r.csi("s") r.stderr("\x1b7") // DECSC: save cursor position
} }
if !r.fullscreen && r.mouse { if !r.fullscreen && r.mouse {
r.yoffset, _ = r.findOffset() r.yoffset, _ = r.findOffset()
@@ -250,15 +274,15 @@ func (r *LightRenderer) makeSpace() {
} }
func (r *LightRenderer) move(y int, x int) { func (r *LightRenderer) move(y int, x int) {
// w.csi("u")
if r.y < y { if r.y < y {
r.csi(fmt.Sprintf("%dB", y-r.y)) r.csi(fmt.Sprintf("%dB", y-r.y))
} else if r.y > y { } else if r.y > y {
r.csi(fmt.Sprintf("%dA", r.y-y)) r.csi(fmt.Sprintf("%dA", r.y-y))
} }
r.stderr("\r") if x == 0 {
if x > 0 { r.stderr("\r")
r.csi(fmt.Sprintf("%dC", x)) } else {
r.csi(fmt.Sprintf("%dG", x+1))
} }
r.y = y r.y = y
r.x = x r.x = x
@@ -905,12 +929,15 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
down := rest[end] == 'M' down := rest[end] == 'M'
scroll := 0 scroll := 0
if t >= 64 { wheel := t >= 64
if wheel {
t -= 64 t -= 64
if t&0b1 == 1 { // SGR wheel button codes: 64=up, 65=down, 66=left, 67=right
scroll = -1 switch t & 0b11 {
} else { case 0:
scroll = 1 scroll = 1
case 1:
scroll = -1
} }
} }
@@ -921,7 +948,7 @@ func (r *LightRenderer) mouseSequence(sz *int) Event {
shift := t&0b00100 > 0 shift := t&0b00100 > 0
drag := t&0b100000 > 0 // 32 drag := t&0b100000 > 0 // 32
if scroll != 0 { if wheel {
return Event{Mouse, 0, &MouseEvent{y, x, scroll, false, false, false, ctrl, alt, shift}} return Event{Mouse, 0, &MouseEvent{y, x, scroll, false, false, false, ctrl, alt, shift}}
} }
@@ -997,6 +1024,9 @@ func (r *LightRenderer) disableModes() {
func (r *LightRenderer) Resume(clear bool, sigcont bool) { func (r *LightRenderer) Resume(clear bool, sigcont bool) {
r.setupTerminal() r.setupTerminal()
// The programs that ran in the meantime may have left the terminal in an
// arbitrary SGR state
r.invalidateSGR()
if clear { if clear {
if r.fullscreen { if r.fullscreen {
r.smcup() r.smcup()
@@ -1018,7 +1048,6 @@ func (r *LightRenderer) Clear() {
if r.fullscreen { if r.fullscreen {
r.csi("H") r.csi("H")
} }
// r.csi("u")
r.origin() r.origin()
r.csi("J") r.csi("J")
r.flush() r.flush()
@@ -1041,7 +1070,6 @@ func (r *LightRenderer) Refresh() {
} }
func (r *LightRenderer) Close() { func (r *LightRenderer) Close() {
// r.csi("u")
if r.clearOnExit { if r.clearOnExit {
if r.fullscreen { if r.fullscreen {
r.rmcup() r.rmcup()
@@ -1053,7 +1081,7 @@ func (r *LightRenderer) Close() {
r.csi("J") r.csi("J")
} }
} else if !r.fullscreen { } else if !r.fullscreen {
r.csi("u") r.stderr("\x1b8") // DECRC: restore cursor position
} }
if !r.showCursor { if !r.showCursor {
r.csi("?25h") r.csi("?25h")
@@ -1270,10 +1298,6 @@ func (w *LightWindow) drawBorder(onlyHorizontal bool) {
} }
} }
func (w *LightWindow) csi(code string) string {
return w.renderer.csi(code)
}
func (w *LightWindow) stderrInternal(str string, allowNLCR bool, resetCode string) { func (w *LightWindow) stderrInternal(str string, allowNLCR bool, resetCode string) {
w.renderer.stderrInternal(str, allowNLCR, resetCode) w.renderer.stderrInternal(str, allowNLCR, resetCode)
} }
@@ -1412,13 +1436,18 @@ func ulColorCode(c Color) string {
return "" return ""
} }
// csiColor builds the SGR sequence for the given colors and attributes
// without emitting it. The sequence starts with a reset parameter, so it
// fully determines the SGR state on its own.
func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) { func (w *LightWindow) csiColor(fg Color, bg Color, ul Color, attr Attr) (bool, string) {
codes := append(attrCodes(attr), colorCodes(fg, bg)...) codes := append(attrCodes(attr), colorCodes(fg, bg)...)
if ulCode := ulColorCode(ul); ulCode != "" { if ulCode := ulColorCode(ul); ulCode != "" {
codes = append(codes, ulCode) codes = append(codes, ulCode)
} }
code := w.csi(";" + strings.Join(codes, ";") + "m") if len(codes) == 0 {
return len(codes) > 0, code return false, "\x1b[0m"
}
return true, "\x1b[;" + strings.Join(codes, ";") + "m"
} }
func (w *LightWindow) Print(text string) { func (w *LightWindow) Print(text string) {
@@ -1431,15 +1460,13 @@ func cleanse(str string) string {
func (w *LightWindow) CPrint(pair ColorPair, text string) { func (w *LightWindow) CPrint(pair ColorPair, text string) {
_, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr()) _, code := w.csiColor(pair.Fg(), pair.Bg(), pair.Ul(), pair.Attr())
w.renderer.setSGR(code)
w.stderrInternal(cleanse(text), false, code) w.stderrInternal(cleanse(text), false, code)
w.csi("0m")
} }
func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) { func (w *LightWindow) cprint2(fg Color, bg Color, attr Attr, text string) {
hasColors, code := w.csiColor(fg, bg, colDefault, attr) _, code := w.csiColor(fg, bg, colDefault, attr)
if hasColors { w.renderer.setSGR(code)
defer w.csi("0m")
}
w.stderrInternal(cleanse(text), false, code) w.stderrInternal(cleanse(text), false, code)
} }
@@ -1460,7 +1487,7 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
} }
w.MoveAndClear(w.posy, w.posx) w.MoveAndClear(w.posy, w.posx)
w.Move(w.posy+1, 0) w.Move(w.posy+1, 0)
w.renderer.stderr(resetCode) w.renderer.setSGR(resetCode)
if len(lines) > 1 { if len(lines) > 1 {
sign := w.wrapSign sign := w.wrapSign
width := w.wrapSignWidth width := w.wrapSignWidth
@@ -1470,7 +1497,8 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
width = truncatedWidth width = truncatedWidth
} }
w.stderrInternal(DIM+sign, false, resetCode) w.stderrInternal(DIM+sign, false, resetCode)
w.renderer.stderr(resetCode) w.renderer.invalidateSGR()
w.renderer.setSGR(resetCode)
w.Move(w.posy, width) w.Move(w.posy, width)
} }
} }
@@ -1481,20 +1509,19 @@ func (w *LightWindow) fill(str string, resetCode string) FillReturn {
return FillSuspend return FillSuspend
} }
w.Move(w.posy+1, 0) w.Move(w.posy+1, 0)
w.renderer.stderr(resetCode) w.renderer.setSGR(resetCode)
return FillNextLine return FillNextLine
} }
return FillContinue return FillContinue
} }
func (w *LightWindow) setBg() string { func (w *LightWindow) setBg() string {
if w.bg != colDefault { // The plain reset code for the default background is still required to
_, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular) // clear the dim attribute after ␍ in the preview window
return code
}
// Should clear dim attribute after ␍ in the preview window
// e.g. printf "foo\rbar" | fzf --ansi --preview 'printf "foo\rbar"' // e.g. printf "foo\rbar" | fzf --ansi --preview 'printf "foo\rbar"'
return "\x1b[m" _, code := w.csiColor(colDefault, w.bg, colDefault, AttrRegular)
w.renderer.setSGR(code)
return code
} }
func (w *LightWindow) LinkBegin(uri string, params string) { func (w *LightWindow) LinkBegin(uri string, params string) {
@@ -1520,7 +1547,7 @@ func (w *LightWindow) CFill(fg Color, bg Color, ul Color, attr Attr, text string
bg = w.bg bg = w.bg
} }
if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors { if hasColors, resetCode := w.csiColor(fg, bg, ul, attr); hasColors {
defer w.csi("0m") w.renderer.setSGR(resetCode)
return w.fill(text, resetCode) return w.fill(text, resetCode)
} }
return w.fill(text, w.setBg()) return w.fill(text, w.setBg())
+127
View File
@@ -3,6 +3,7 @@ package tui
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"testing" "testing"
"unicode" "unicode"
) )
@@ -350,3 +351,129 @@ func TestLightRenderer(t *testing.T) {
assertEscSequence("\x1b[14~", "f4") assertEscSequence("\x1b[14~", "f4")
} }
func TestLightRendererScrollWheel(t *testing.T) {
tty_file, _ := os.Open("")
renderer, _ := NewLightRenderer(
"", tty_file, &ColorTheme{}, true, true, 0, false, true,
func(h int) int { return h })
light_renderer := renderer.(*LightRenderer)
assertScroll := func(sequence string, scroll int, mods string) {
bytes := []byte(sequence)
light_renderer.buffer = bytes
sz := 1
event := light_renderer.escSequence(&sz)
me := event.MouseEvent
if event.Type != Mouse || me == nil {
t.Errorf("sequence: %q | got %s, want a Mouse event", sequence, event.Type.String())
return
}
got := ""
if me.Ctrl {
got += "ctrl-"
}
if me.Alt {
got += "alt-"
}
if me.Shift {
got += "shift-"
}
got = strings.TrimSuffix(got, "-")
if me.S != scroll || got != mods || me.Down {
t.Errorf(
"sequence: %q | scroll=%d mods=%q down=%v != scroll=%d mods=%q down=false",
sequence, me.S, got, me.Down, scroll, mods)
}
}
assertScroll("\x1b[<64;1;1M", 1, "") // up
assertScroll("\x1b[<65;1;1M", -1, "") // down
assertScroll("\x1b[<66;1;1M", 0, "") // left (ignored)
assertScroll("\x1b[<67;1;1M", 0, "") // right (ignored)
assertScroll("\x1b[<68;1;1M", 1, "shift") // shift + up
assertScroll("\x1b[<69;1;1M", -1, "shift") // shift + down
assertScroll("\x1b[<70;1;1M", 0, "shift") // shift + left (ignored)
assertScroll("\x1b[<71;1;1M", 0, "shift") // shift + right (ignored)
assertScroll("\x1b[<72;1;1M", 1, "alt") // alt + up
assertScroll("\x1b[<73;1;1M", -1, "alt") // alt + down
assertScroll("\x1b[<74;1;1M", 0, "alt") // alt + left (ignored)
assertScroll("\x1b[<75;1;1M", 0, "alt") // alt + right (ignored)
assertScroll("\x1b[<80;1;1M", 1, "ctrl") // ctrl + up
assertScroll("\x1b[<81;1;1M", -1, "ctrl") // ctrl + down
assertScroll("\x1b[<82;1;1M", 0, "ctrl") // ctrl + left (ignored)
assertScroll("\x1b[<83;1;1M", 0, "ctrl") // ctrl + right (ignored)
assertScroll("\x1b[<84;1;1M", 1, "ctrl-shift") // ctrl+shift + up
assertScroll("\x1b[<85;1;1M", -1, "ctrl-shift") // ctrl+shift + down
assertScroll("\x1b[<86;1;1M", 0, "ctrl-shift") // ctrl+shift + left (ignored)
assertScroll("\x1b[<87;1;1M", 0, "ctrl-shift") // ctrl+shift + right (ignored)
assertScroll("\x1b[<92;1;1M", 1, "ctrl-alt-shift") // ctrl+alt+shift + up
assertScroll("\x1b[<93;1;1M", -1, "ctrl-alt-shift") // ctrl+alt+shift + down
assertScroll("\x1b[<94;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + left (ignored)
assertScroll("\x1b[<95;1;1M", 0, "ctrl-alt-shift") // ctrl+alt+shift + right (ignored)
}
// Assert the exact byte stream emitted for a scripted rendering sequence,
// locking down the SGR deduplication behavior of the light renderer.
func TestLightRendererSGRDeduplication(t *testing.T) {
renderer, _ := NewLightRenderer(
"", nil, &ColorTheme{}, true, false, 8, false, true,
func(h int) int { return h })
r := renderer.(*LightRenderer)
r.width = 80
r.height = 24
out, err := os.CreateTemp(t.TempDir(), "fzf-ttyout")
if err != nil {
t.Fatal(err)
}
r.ttyout = out
w := r.NewWindow(0, 0, 40, 4, WindowList, MakeBorderStyle(BorderNone, true), false).(*LightWindow)
w.fg = colDefault
w.bg = colDefault
r.queued.Reset()
red := NewColorPair(196, colDefault, AttrRegular)
blue := NewColorPair(21, colDefault, AttrRegular)
w.Move(0, 0)
w.CPrint(red, "ab")
w.CPrint(red, "cd") // same pair; no SGR expected
w.CPrint(blue, "ef")
w.Print("gh") // default colors; one reset expected
w.Print("ij") // still default; no SGR expected
w.Move(1, 2)
w.CPrint(red, "kl")
r.invalidateSGR()
w.CPrint(red, "mn") // re-emitted after invalidation
r.flush() // adds a trailing reset to leave the default state
expected := "\x1b[?2026h\x1b[?7l\x1b[?25l" +
"\r" +
"\x1b[;38;5;196mab" +
"cd" +
"\x1b[;38;5;21mef" +
"\x1b[0mgh" +
"ij" +
"\x1b[1B\x1b[3G" +
"\x1b[;38;5;196mkl" +
"\x1b[;38;5;196mmn" +
"\x1b[0m" +
"\x1b[?25h\x1b[?7h\x1b[?2026l"
bytes, err := os.ReadFile(out.Name())
if err != nil {
t.Fatal(err)
}
if string(bytes) != expected {
t.Errorf("expected: %q\n got: %q", expected, string(bytes))
}
}
+51 -38
View File
@@ -4,6 +4,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode"
"github.com/junegunn/fzf/src/util" "github.com/junegunn/fzf/src/util"
"github.com/rivo/uniseg" "github.com/rivo/uniseg"
@@ -233,6 +234,7 @@ const (
ClickFooter ClickFooter
Multi Multi
Every Every
ResultFinal
) )
func (t EventType) AsEvent() Event { func (t EventType) AsEvent() Event {
@@ -252,6 +254,12 @@ func (e Event) Comparable() Event {
return Event{e.Type, e.Char, nil} return Event{e.Type, e.Char, nil}
} }
// Printable returns true if the event is a printable character that can be
// inserted into the query (e.g. via the 'put' action).
func (e Event) Printable() bool {
return e.Type == Rune && unicode.IsGraphic(e.Char)
}
func (e Event) KeyName() string { func (e Event) KeyName() string {
if me := e.MouseEvent; me != nil { if me := e.MouseEvent; me != nil {
return me.Name() return me.Name()
@@ -995,51 +1003,56 @@ func init() {
undefined := ColorAttr{colUndefined, AttrUndefined} undefined := ColorAttr{colUndefined, AttrUndefined}
NoColorTheme = &ColorTheme{ NoColorTheme = &ColorTheme{
Colored: false, Colored: false,
Input: defaultColor, // Root colors. Everything else is left undefined so that overriding a
Fg: defaultColor, // root (e.g. --color bw,bg:blue) propagates to the derived colors,
Bg: defaultColor, // just like in the colored base themes.
ListFg: defaultColor, Input: defaultColor,
ListBg: defaultColor, Fg: defaultColor,
Bg: defaultColor,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
Spinner: defaultColor,
Info: defaultColor,
Pointer: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Footer: defaultColor,
BorderLabel: defaultColor,
// Derived colors. Left undefined so they inherit from a root.
ListFg: undefined,
ListBg: undefined,
AltBg: undefined, AltBg: undefined,
SelectedFg: defaultColor, SelectedFg: undefined,
SelectedBg: defaultColor, SelectedBg: undefined,
SelectedMatch: defaultColor, SelectedMatch: undefined,
DarkBg: defaultColor,
Prompt: defaultColor,
Match: defaultColor,
Current: undefined, Current: undefined,
CurrentMatch: undefined, CurrentMatch: undefined,
Spinner: defaultColor,
Info: defaultColor,
Pointer: defaultColor,
Marker: defaultColor,
Header: defaultColor,
Border: undefined, Border: undefined,
BorderLabel: defaultColor,
Ghost: undefined, Ghost: undefined,
Disabled: defaultColor, Disabled: undefined,
PreviewFg: defaultColor, PreviewFg: undefined,
PreviewBg: defaultColor, PreviewBg: undefined,
Gutter: undefined, Gutter: undefined,
AltGutter: undefined, AltGutter: undefined,
PreviewBorder: defaultColor, PreviewBorder: undefined,
PreviewScrollbar: defaultColor, PreviewScrollbar: undefined,
PreviewLabel: defaultColor, PreviewLabel: undefined,
ListLabel: defaultColor, ListLabel: undefined,
ListBorder: defaultColor, ListBorder: undefined,
Separator: defaultColor, Separator: undefined,
Scrollbar: defaultColor, Scrollbar: undefined,
InputBg: defaultColor, InputBg: undefined,
InputBorder: defaultColor, InputBorder: undefined,
InputLabel: defaultColor, InputLabel: undefined,
HeaderBg: defaultColor, HeaderBg: undefined,
HeaderBorder: defaultColor, HeaderBorder: undefined,
HeaderLabel: defaultColor, HeaderLabel: undefined,
FooterBg: defaultColor, FooterBg: undefined,
FooterBorder: defaultColor, FooterBorder: undefined,
FooterLabel: defaultColor, FooterLabel: undefined,
GapLine: defaultColor, GapLine: undefined,
Nth: undefined, Nth: undefined,
Nomatch: undefined, Nomatch: undefined,
} }
+43 -1
View File
@@ -1,18 +1,53 @@
package fzf package fzf
import ( import (
"fmt"
"os/exec" "os/exec"
) )
const zellijBorderLabelEnv = internalEnvPrefix + "ZELLIJ_LABEL_PANE"
// Errors are ignored. The pane may be gone
func setZellijBorderLabel(pane string, label string) {
// Strip ANSI sequences fzf would otherwise render itself
label, _, _ = extractColor(label, nil, nil)
// '--' so that a label starting with a hyphen is not parsed as a flag
exec.Command("zellij", "action", "rename-pane", "-p", pane, "--", label).Run()
}
func runZellij(args []string, opts *Options) (int, error) { func runZellij(args []string, opts *Options) (int, error) {
// Use the native Zellij border by default, consistent with tmux, so that
// the pane can be moved and resized with the mouse. Set before
// popupArgStr so that it does not inject an fzf border. fzf draws its own
// border instead when a border style is explicitly specified.
if nativeBorder(opts) {
opts.Tmux.border = true
}
argStr, dir := popupArgStr(args, opts) argStr, dir := popupArgStr(args, opts)
zellijArgs := []string{ zellijArgs := []string{
"run", "--floating", "--close-on-exit", "--block-until-exit", "run", "--floating", "--close-on-exit", "--block-until-exit",
"--cwd", dir, "--cwd", dir,
} }
ownsLabel := false
if !opts.Tmux.border { if !opts.Tmux.border {
zellijArgs = append(zellijArgs, "--borderless", "true") zellijArgs = append(zellijArgs, "--borderless", "true")
} else {
// Set --border-label as the name of the pane, displayed on the
// native border. The label is left to fzf when it draws its own
// border with the label on it (border-native with an explicit
// --border style). Empty otherwise, to override the default name
// (the running command). Passed as a single argument in the
// --name=label form; the detached form fails to parse when the
// label starts with a hyphen. No escaping is needed beyond
// stripping ANSI sequences fzf would otherwise render itself.
// --border-label-pos is ignored.
label := ""
ownsLabel = noBorderSpecified(opts)
if ownsLabel {
label, _, _ = extractColor(opts.BorderLabel.label, nil, nil)
}
zellijArgs = append(zellijArgs, "--name="+label)
} }
switch opts.Tmux.position { switch opts.Tmux.position {
case posUp: case posUp:
@@ -35,7 +70,14 @@ func runZellij(args []string, opts *Options) (int, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
zellijArgs = append(zellijArgs, sh, temp) if ownsLabel {
// 'zellij run' cannot set the new pane's environment, so the
// command exports it itself. exec avoids a lingering shell
zellijArgs = append(zellijArgs, sh, "-c", fmt.Sprintf(`export %s="$ZELLIJ_PANE_ID"; exec %s %s`,
zellijBorderLabelEnv, escapeSingleQuote(sh), escapeSingleQuote(temp)))
} else {
zellijArgs = append(zellijArgs, sh, temp)
}
return exec.Command("zellij", zellijArgs...), nil return exec.Command("zellij", zellijArgs...), nil
}, opts, true) }, opts, true)
} }
+8 -1
View File
@@ -136,6 +136,7 @@ class Tmux
rescue Minitest::Assertion rescue Minitest::Assertion
retries += 1 retries += 1
raise if retries > 5 raise if retries > 5
retry retry
end end
send_keys 'clear', :Enter send_keys 'clear', :Enter
@@ -176,6 +177,12 @@ class Tmux
system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter') system('tmux', 'setb', str, ';', 'pasteb', '-t', win, ';', 'send-keys', '-t', win, 'Enter')
end end
# Paste with bracketed paste control codes so fzf sees
# bracketed-paste-begin/end around the content
def paste_bracketed(str)
system('tmux', 'setb', str, ';', 'pasteb', '-p', '-t', win)
end
def capture def capture
go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse go(%W[capture-pane -p -J -t #{win}]).map(&:rstrip).reverse.drop_while(&:empty?).reverse
end end
@@ -295,7 +302,7 @@ class Tmux
if @shell == :nushell if @shell == :nushell
message = "Prepare[#{tries}]" message = "Prepare[#{tries}]"
send_keys 'C-u', 'C-l' send_keys 'C-u', 'C-l'
sleep 0.2 sleep(0.2)
send_keys ' ', 'C-u', :Enter, message send_keys ' ', 'C-u', :Enter, message
self.until { |lines| lines[-1] == message } self.until { |lines| lines[-1] == message }
else else
+173
View File
@@ -971,6 +971,24 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_includes lines[1], ' aabravo/aabravo' } tmux.until { |lines| assert_includes lines[1], ' aabravo/aabravo' }
end end
def test_transform_put
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:transform:echo put'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys :a
tmux.until { |lines| assert_equal '> a', lines.last }
tmux.send_keys :b
tmux.until { |lines| assert_equal '> ab', lines.last }
end
# The async callback runs in a later iteration, but 'put' must still insert
# the key that triggered the bg-transform (snapshot of the scheduling event).
def test_bg_transform_put
tmux.send_keys %(seq 1000 | #{FZF} --bind 'a:bg-transform:sleep 0.5; echo put'), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count }
tmux.send_keys 'ab'
tmux.until { |lines| assert_equal '> ba', lines.last }
end
def test_accept_non_empty def test_accept_non_empty
tmux.send_keys %(seq 1000 | #{fzf('--print-query --bind enter:accept-non-empty')}), :Enter tmux.send_keys %(seq 1000 | #{fzf('--print-query --bind enter:accept-non-empty')}), :Enter
tmux.until { |lines| assert_equal 1000, lines.match_count } tmux.until { |lines| assert_equal 1000, lines.match_count }
@@ -1144,6 +1162,130 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_equal 10, lines.match_count } tmux.until { |lines| assert_equal 10, lines.match_count }
end end
def test_wait_action
tmux.send_keys %((seq 100; sleep 15) | #{FZF} --bind 'start:search(1)+wait+best'), :Enter
tmux.until { |lines| assert_equal 20, lines.match_count }
tmux.until { |lines| assert lines.any_include?('20/100 (..)') }
tmux.send_keys 'C-c'
tmux.until { |lines| refute lines.any_include?('20/100 (..)') }
# Ctrl-C cancels the wait; fzf keeps running and accepts input again
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
end
def test_wait_action_start
# 'start:wait' blocks on the initial load until reading completes
tmux.send_keys %((seq 100; sleep 15) | #{FZF} --bind 'start:wait'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.until { |lines| assert lines.any_include?('(..)') }
tmux.send_keys 'C-c'
tmux.until { |lines| refute lines.any_include?('(..)') }
# Ctrl-C cancels the wait; fzf keeps running and accepts input again
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
end
def test_wait_action_bg_transform
# A bg-transform result is unrelated to the wait, so it's applied while the
# wait is still blocking rather than dropped. The long read keeps the wait
# blocked so the (instant) bg-transform completes during the block; the
# header must show while '(..)' is still displayed.
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:bg-transform-header(echo hello)+search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && lines.any_include?('hello')) }
end
def test_wait_action_bg_transform_actions
# Actions parsed from a generic bg-transform result are also applied
# while wait-blocked
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:bg-transform(echo change-header:hello)+search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && lines.any_include?('hello')) }
end
def test_wait_action_bg_transform_join
# A 'wait' in a bg-transform result body joins the ongoing wait; the
# actions after it run when the wait unblocks instead of being dropped
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:bg-transform(echo change-header{hello}+wait+change-footer{world})+search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && lines.any_include?('hello') && !lines.any_include?('world')) }
tmux.until { |lines| assert(lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_query_change
# Query-editing actions must also make wait block; accept must run on the
# results of the new query, not the stale ones
tmux.send_keys %(seq 100 | #{fzf("--bind 'space:change-query(55)+wait+accept'")}), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.send_keys :Space
assert_equal '55', fzf_output
end
def test_wait_action_trigger_join
# A wait armed inside a triggered chord defers the remaining actions of
# the outer binding as well
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:trigger(x)+change-footer(world)' --bind 'x:search(5)+wait'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && !lines.any_include?('world')) }
tmux.until { |lines| assert(lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_trigger_siblings
# Chords after a wait-arming chord are deferred, not dropped
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --bind 'start:trigger(x,y)+change-footer(world)' --bind 'x:search(5)+wait' --bind 'y:change-header(hello)'), :Enter
tmux.until { |lines| assert(lines.any_include?('(..)') && !lines.any_include?('hello') && !lines.any_include?('world')) }
tmux.until { |lines| assert(lines.any_include?('hello') && lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_cancel_rearm
# Deferral works even when the wait is cancelled and re-armed within a
# single action list. --query keeps 'cancel' non-fatal in case the Space
# arrives after the initial wait has already unblocked.
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --query 5 --bind 'start:wait' --bind 'space:cancel+trigger(x)+change-footer(world)' --bind 'x:search(5)+wait'), :Enter
tmux.until { |lines| assert lines.any_include?('(..)') }
tmux.send_keys :Space
tmux.until { |lines| assert(lines.any_include?('world') && !lines.any_include?('(..)')) }
end
def test_wait_action_jump
# A pending jump action must survive the wake-up event after unblock
tmux.send_keys %((seq 100; sleep 2) | #{FZF} --jump-labels abc --bind 'start:search(5)+wait+jump'), :Enter
tmux.until { |lines| assert_equal 'a 5', lines[-3] }
tmux.send_keys 'a'
tmux.until { |lines| assert_equal '> 5', lines[-3] }
end
def test_wait_action_bracketed_paste
# A wait armed by a binding fired from a pasted character must not break
# bracketed paste handling: paste-end passes through the block so
# t.pasting is cleared and the pending search is dispatched
tmux.send_keys %(seq 100 | #{FZF} --bind 'a:put(a)+wait+first'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.paste_bracketed('1a2')
# Search for the edited query must run; blocked forever before the fix
tmux.until { |lines| assert_equal 0, lines.match_count }
# Filtering must still work afterwards
tmux.send_keys 'C-u', '55'
tmux.until { |lines| assert_equal 1, lines.match_count }
end
def test_wait_action_expect
# --expect keys are ignored while wait-blocked, like the rest of the input
tmux.send_keys %((seq 100; sleep 2) | #{fzf('--expect ctrl-t --bind start:wait')}), :Enter
tmux.until { |lines| assert lines.any_include?('(..)') }
tmux.send_keys 'C-t'
# fzf must still be running; the wait unblocks when loading completes
tmux.until { |lines| assert_equal 100, lines.match_count }
tmux.until { |lines| refute lines.any_include?('(..)') }
tmux.send_keys 'C-t'
assert_equal %w[ctrl-t 1], fzf_output_lines
end
def test_track_blocked_bg_transform
# A bg-transform result completing while track-blocked is applied, not
# dropped. The header must show while '+T*' is still displayed.
tmux.send_keys "seq 100 | #{FZF} --track --id-nth .. --bind 'ctrl-r:bg-transform-header(echo hello)+reload(sleep 2; seq 100)'", :Enter
tmux.until { |lines| assert_includes lines[-2], '+T' }
tmux.send_keys 'C-r'
tmux.until { |lines| assert(lines.any_include?('+T*') && lines.any_include?('hello')) }
end
def test_clear_selection def test_clear_selection
tmux.send_keys %(seq 100 | #{FZF} --multi --bind space:clear-selection), :Enter tmux.send_keys %(seq 100 | #{FZF} --multi --bind space:clear-selection), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count } tmux.until { |lines| assert_equal 100, lines.match_count }
@@ -1387,6 +1529,17 @@ class TestCore < TestInteractive
tmux.until { |lines| assert_includes lines, '> 1' } tmux.until { |lines| assert_includes lines, '> 1' }
end end
def test_result_final_event
tmux.send_keys %[(seq 100; sleep 1; seq 100) | #{FZF} \\
--query 1 \\
--bind 'result:transform-header(echo "R=$FZF_MATCH_COUNT")' \\
--bind 'result-final:transform-footer(echo "F=$FZF_MATCH_COUNT")'], :Enter
tmux.until { |lines| assert lines.any_include?('R=20') }
tmux.until { |lines| refute lines.any_include?('F=20') }
tmux.until { |lines| assert lines.any_include?('R=40') }
tmux.until { |lines| assert lines.any_include?('F=40') }
end
def test_every_event def test_every_event
tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(cat #{tempname})'), :Enter tmux.send_keys %(seq 100 | fzf --bind 'every(0.2):transform-prompt(cat #{tempname})'), :Enter
tmux.until { |lines| assert_equal 100, lines.match_count } tmux.until { |lines| assert_equal 100, lines.match_count }
@@ -2317,6 +2470,26 @@ class TestCore < TestInteractive
end end
end end
def test_env_current_item_size_limit
preview = %[(echo START; env | grep '^FZF_CURRENT_ITEM='; echo END) > #{tempname}]
# Large item (> 64 KB) is omitted so it cannot overflow ARG_MAX and break exec
tmux.send_keys %(head -c 70000 /dev/zero | tr '\\0' a | #{FZF} --preview-window 0 --preview "#{preview}"), :Enter
wait do
content = File.exist?(tempname) ? File.read(tempname) : ''
assert_includes content, 'END'
refute_includes content, 'FZF_CURRENT_ITEM='
end
tmux.send_keys :Enter
FileUtils.rm_f(tempname)
# Smaller item is exported as usual
tmux.send_keys %(head -c 1000 /dev/zero | tr '\\0' a | #{FZF} --preview-window 0 --preview "#{preview}"), :Enter
wait do
content = File.exist?(tempname) ? File.read(tempname) : ''
assert_includes content, 'END'
assert_includes content, 'FZF_CURRENT_ITEM=' + ('a' * 1000)
end
end
def test_abort_action_chain def test_abort_action_chain
tmux.send_keys %(seq 100 | #{FZF} --bind 'load:accept+up+up' > #{tempname}), :Enter tmux.send_keys %(seq 100 | #{FZF} --bind 'load:accept+up+up' > #{tempname}), :Enter
wait do wait do
+290 -21
View File
@@ -248,7 +248,7 @@ class TestLayout < TestInteractive
tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter tmux.send_keys %(seq 5 | #{FZF} --layout=reverse --preview 'echo PREVIEW' --preview-window=next:3 --prompt='line2$ > '), :Enter
expected = <<~OUTPUT expected = <<~OUTPUT
line2$ > line2$ >
5/5 5/5
PREVIEW PREVIEW
@@ -268,7 +268,7 @@ class TestLayout < TestInteractive
5/5 5/5
> >
OUTPUT OUTPUT
tmux.until { assert_block(expected, it) } tmux.until { assert_block(expected, it) }
@@ -597,7 +597,7 @@ class TestLayout < TestInteractive
2 2
1 1
header header
19/97 19/97
> 1 > 1
@@ -628,7 +628,7 @@ class TestLayout < TestInteractive
hello hello
100/100 100/100
> >
BLOCK BLOCK
tmux.until { assert_block(block2, it) } tmux.until { assert_block(block2, it) }
@@ -648,7 +648,7 @@ class TestLayout < TestInteractive
2 2
1 1
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block1, it) } tmux.until { assert_block(block1, it) }
@@ -663,7 +663,7 @@ class TestLayout < TestInteractive
1 1
hello hello
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block2, it) } tmux.until { assert_block(block2, it) }
@@ -679,7 +679,7 @@ class TestLayout < TestInteractive
4 4
> 3 > 3
98/98 98/98
> >
2 2
@@ -718,7 +718,7 @@ class TestLayout < TestInteractive
2 2
1 1
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block1, it) } tmux.until { assert_block(block1, it) }
@@ -734,7 +734,7 @@ class TestLayout < TestInteractive
hello hello
98/98 98/98
> >
BLOCK BLOCK
tmux.until { assert_block(block2, it) } tmux.until { assert_block(block2, it) }
@@ -750,7 +750,7 @@ class TestLayout < TestInteractive
4 4
> 3 > 3
98/98 98/98
> >
2 2
@@ -767,7 +767,7 @@ class TestLayout < TestInteractive
2 2
1 1
98/98 98/98
> >
hello hello
@@ -810,7 +810,7 @@ class TestLayout < TestInteractive
2 2
1 1
header header
19/97 19/97
> 1 > 1
@@ -825,7 +825,7 @@ class TestLayout < TestInteractive
11 11
> 10 > 10
list list
19/97 19/97
> 1 > 1
3 3
@@ -1039,9 +1039,9 @@ class TestLayout < TestInteractive
103 101 103 101 102 102 2/2 103 103 101 103 101 102 102 2/2 103
5/5 102 2/2 102 103 101 103 101 > 2/2 101 101 > 5/5 102 2/2 102 103 101 103 101 > 2/2 101 101 >
> 103 > 103 102 102 > 102 5/5 102 > 103 > 103 102 102 > 102 5/5 102
5/5 103 2/2 103 101 103 > 103 5/5 103 2/2 103 101 103 > 103
> > 102 101 > > 102 101
2/2 103 101 102 2/2 103 101 102
> 102 > 103 > 102 > 103
103 103
@@ -1099,9 +1099,9 @@ class TestLayout < TestInteractive
> 102 > 102 > 101 > 101 101 101 101 > 1 > 102 > 102 > 101 > 101 101 101 101 > 1
3/3 101 1/1 101 102 102 102 102 102 3/3 101 1/1 101 102 102 102 102 102
> 102 > 102 HEAD 101 HEAD HEAD HEAD 101 1/1 101 > 102 > 102 HEAD 101 HEAD HEAD HEAD 101 1/1 101
3/3 102 1/1 1/1 1/1 102 > 102 3/3 > 3/3 102 1/1 1/1 1/1 102 > 102 3/3 >
> HEAD > > > HEAD HEAD > > HEAD > > > HEAD HEAD >
1/1 1/1
> 101 101 > 101 101
102 102 102 102
HEAD HEAD HEAD HEAD
@@ -1159,9 +1159,9 @@ class TestLayout < TestInteractive
> 102 > 102 > 101 > 101 > 3 > 3 > 3 101 3 > 102 > 102 > 101 > 101 > 3 > 3 > 3 101 3
3/3 101 1/1 101 102 102 102 3/3 101 1/1 101 102 102 102
> 102 > 102 HEAD 101 HEAD 101 1/1 101 > 102 > 102 HEAD 101 HEAD 101 1/1 101
3/3 102 1/1 102 > 102 3/3 > 3/3 102 1/1 102 > 102 3/3 >
> HEAD > HEAD HEAD > > HEAD > HEAD HEAD >
1/1 1/1
> 101 101 > 101 101
102 102 102 102
HEAD HEAD HEAD HEAD
@@ -1191,7 +1191,7 @@ class TestLayout < TestInteractive
label label
header header
10/10 10/10
> >
BLOCK BLOCK
@@ -1228,13 +1228,282 @@ class TestLayout < TestInteractive
end end
end end
def test_separator_with_input_border
# Border line below input does not face the list; separator is shown
tmux.send_keys %(seq 100 | #{FZF} --input-border bottom), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# List border faces the input instead; separator is hidden
tmux.send_keys %(seq 100 | #{FZF} --input-border bottom --list-border rounded), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Border line above input faces the list; separator is hidden
tmux.send_keys %(seq 100 | #{FZF} --input-border top), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_input_border_reverse
# Border line above input does not face the list; separator is shown
tmux.send_keys %(seq 100 | #{FZF} --layout reverse --input-border top), :Enter
block = <<~BLOCK
>
100/100
> 1
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Border line below input faces the list; separator is hidden
tmux.send_keys %(seq 100 | #{FZF} --layout reverse --input-border bottom), :Enter
block = <<~BLOCK
>
100/100
> 1
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_preview_next
# Preview window at 'next' position sits next to the input section, so the
# header border does not hide the separator
tmux.send_keys %(seq 100 | #{FZF} --header foo --header-border rounded --no-list-border --preview 'echo hi' --preview-window next,1,border-none), :Enter
block = <<~BLOCK
hi
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Same when 'next' position comes from a threshold alternative
tmux.send_keys %(seq 100 | #{FZF} --header foo --header-border rounded --no-list-border --preview 'echo hi' --preview-window 'up,40%,<9999(next,1,border-none)'), :Enter
tmux.until { assert_block(block, it) }
teardown
setup
# A border of the preview window at 'next' position facing the input
# section hides the separator
tmux.send_keys %(seq 100 | #{FZF} --preview : --preview-window next,3), :Enter
block = <<~BLOCK
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Same when every spec in the threshold chain is a bordered preview
# window at 'next' position
tmux.send_keys %(seq 100 | #{FZF} --preview : --preview-window 'next,3,<15(next,3)'), :Enter
tmux.until { assert_block(block, it) }
end
def test_separator_with_preview_next_toggle
tmux.send_keys %(seq 100 | #{FZF} --preview 'echo hi' --preview-window next,3,hidden --bind space:toggle-preview), :Enter
hidden = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(hidden, it) }
tmux.send_keys :Space
shown = <<~BLOCK
hi
100/100
>
BLOCK
tmux.until { assert_block(shown, it) }
tmux.send_keys :Space
tmux.until { assert_block(hidden, it) }
end
def test_separator_with_preview_next_alternative_only
# 'next' appears only in a threshold alternative and there is no
# previewer, so the input section stays embedded in the list window and
# needs the separator
tmux.send_keys %(seq 100 | #{FZF} --preview-window '<9999(next)' --list-border rounded), :Enter
block = <<~BLOCK
> 1
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_preview_action_at_next
# preview(...) action can display an ad-hoc preview window at 'next'
# position at any time; the list border must not hide the separator
tmux.send_keys %(seq 100 | #{FZF} --list-border rounded --preview-window 'next,border-left,5' --bind 'space:preview(echo hi)'), :Enter
block = <<~BLOCK
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
hi
100/100
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_change_preview_window_keeps_forced_preview
# Relayout triggered by the separator visibility change must not drop a
# preview window opened via the preview(...) action
tmux.send_keys %(seq 100 | #{FZF} --info hidden --input-border left --list-border --preview-window 'up,50%,<5(next)' --bind 'space:preview(echo AD-HOC)' --bind 'x:change-preview-window(up,50%,<5(up))'), :Enter
tmux.until { |lines| assert_includes lines.join, '│ ─' }
tmux.send_keys :Space
tmux.until do |lines|
assert_includes lines.join, 'AD-HOC'
assert_includes lines.join, '│ ─'
end
# Only the inactive threshold alternative changes; separator is hidden
# by the list border, and the forced preview window must survive
tmux.send_keys :x
tmux.until do |lines|
assert_includes lines.join, 'AD-HOC'
refute_includes lines.join, '│ ─'
end
end
def test_separator_with_change_preview_window_chain
# change-preview-window that only alters a non-active spec must still
# trigger a relayout when it changes the separator visibility
tmux.send_keys %(seq 100 | #{FZF} --info hidden --layout reverse --preview 'echo hi' --preview-window 'next,3,border-none,<100(next,3,border-top)' --bind 'space:change-preview-window(next,3,border-top,<100(next,3,border-top))'), :Enter
block = <<~BLOCK
>
hi
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
>
hi
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Also when only the drawn bar changes; the info line row count is
# constant with the default info style
tmux.send_keys %(seq 100 | #{FZF} --layout reverse --preview 'echo hi' --preview-window 'next,3,border-none,<100(next,3,border-top)' --bind 'space:change-preview-window(next,3,border-top,<100(next,3,border-top))'), :Enter
block = <<~BLOCK
>
100/100
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
>
100/100
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Also when compare finds a content-layout difference (scroll offset)
tmux.send_keys %(seq 100 | #{FZF} --info hidden --preview 'echo hi' --preview-window 'next,3,<2(up,3)' --bind 'space:change-preview-window(next,3,+1,<2(next,3))'), :Enter
block = <<~BLOCK
>
BLOCK
tmux.until { assert_block(block, it) }
tmux.send_keys :Space
block = <<~BLOCK
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_separator_with_inline_header_border
# Inline header is drawn inside the list border; bare header lines sit
# next to the input section, so the separator is shown
tmux.send_keys %(seq 100 | #{FZF} --list-border rounded --header foo --header-lines 2 --header-border inline --header-lines-border none), :Enter
block = <<~BLOCK
2
1
98/98
>
BLOCK
tmux.until { assert_block(block, it) }
teardown
setup
# Header lines are embedded in the list border along with the inline
# header; the list border faces the input section and hides the separator
tmux.send_keys %(seq 100 | #{FZF} --list-border rounded --header foo --header-lines 2 --header-border inline), :Enter
block = <<~BLOCK
2
1
foo
98/98
>
BLOCK
tmux.until { assert_block(block, it) }
end
def test_header_border_no_pointer_and_marker def test_header_border_no_pointer_and_marker
tmux.send_keys %(seq 10 | #{FZF} --header-lines 1 --header-border sharp --no-list-border --pointer '' --marker ''), :Enter tmux.send_keys %(seq 10 | #{FZF} --header-lines 1 --header-border sharp --no-list-border --pointer '' --marker ''), :Enter
block = <<~BLOCK block = <<~BLOCK
1 1
9/9 9/9
> >
BLOCK BLOCK
tmux.until { assert_block(block, it) } tmux.until { assert_block(block, it) }
+67
View File
@@ -100,6 +100,47 @@ module TestShell
tmux.until { |lines| assert_equal '/tmp', lines[-1] } tmux.until { |lines| assert_equal '/tmp', lines[-1] }
end end
def test_alt_c_symlink
base = '/tmp/fzf-test-alt-c-symlink'
FileUtils.rm_rf(base)
FileUtils.mkdir_p("#{base}/real/subdir")
FileUtils.ln_s("#{base}/real", "#{base}/link")
tmux.prepare
tmux.send_keys "cd #{base}/link", :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'subdir'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.prepare
tmux.send_keys :pwd, :Enter
tmux.until { |lines| assert_equal "#{base}/link/subdir", lines[-1] }
ensure
FileUtils.rm_rf(base)
end
def test_alt_c_absolute_cmd
base = '/tmp/fzf-test-alt-c-absolute'
FileUtils.rm_rf(base)
FileUtils.mkdir_p(base)
set_var('FZF_ALT_C_COMMAND', "echo #{base}")
tmux.prepare
tmux.send_keys 'cd /tmp', :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
tmux.prepare
tmux.send_keys :pwd, :Enter
tmux.until { |lines| assert_equal base, lines[-1] }
ensure
FileUtils.rm_rf(base)
end
def test_ctrl_r def test_ctrl_r
tmux.prepare tmux.prepare
tmux.send_keys 'echo 1st', :Enter tmux.send_keys 'echo 1st', :Enter
@@ -924,6 +965,18 @@ class TestZsh < TestBase
end end
end end
# Duplicate 'chpwd' calls overcount visits => skews rank tracking tools (e.g. 'zoxide')
def test_alt_c_chpwd_hook_once
tmux.send_keys "chpwd() { echo 'chpwd hook fired' >&2 }", :Enter
tmux.prepare
tmux.send_keys :Escape, :c
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys :Enter
tmux.until do |lines|
assert_equal 1, lines.count { |l| l.include?('chpwd hook fired') }
end
end
# Helper function to run test with Perl and again with Awk # Helper function to run test with Perl and again with Awk
def self.test_perl_and_awk(name, &block) def self.test_perl_and_awk(name, &block)
define_method(:"test_#{name}") do define_method(:"test_#{name}") do
@@ -958,6 +1011,20 @@ class TestZsh < TestBase
tmux.send_keys 'C-l', 'C-r' tmux.send_keys 'C-l', 'C-r'
end end
test_perl_and_awk 'ctrl_r_exit_code' do
exit_file = "#{tempname}-exit"
# Wrapper captures status, as widgets return values don't propagate to "$?"
# A non-zero status causes the shell to beep when the widget exits (man zshzle)
tmux.send_keys %(f() { zle fzf-history-widget; echo $? > #{exit_file}; zle reset-prompt } && zle -N f && bindkey "^R" f), :Enter
prepare_ctrl_r_test
tmux.until { |lines| assert_operator lines.match_count, :>, 0 }
tmux.send_keys 'C-g' # abort
tmux.send_keys "cat #{exit_file}", :Enter
tmux.until { |lines| assert_equal '130', lines[-1] }
ensure
FileUtils.rm_f(exit_file)
end
test_perl_and_awk 'ctrl_r_accept_or_print_query' do test_perl_and_awk 'ctrl_r_accept_or_print_query' do
set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query') set_var('FZF_CTRL_R_OPTS', '--bind enter:accept-or-print-query')
prepare_ctrl_r_test prepare_ctrl_r_test
+126
View File
@@ -0,0 +1,126 @@
# frozen_string_literal: true
require 'shellwords'
require 'tmpdir'
require_relative 'lib/common'
# Tests for running fzf in a tmux floating pane (--popup on tmux 3.7 or above)
class TestTmux < TestInteractive
def setup
super
# Cannot rely on the exit status; tmux versions before 3.7 exit
# normally with empty output for an unknown command name
supported = IO.popen(%w[tmux list-commands new-pane], err: File::NULL, &:read).include?('new-pane')
skip('floating panes not supported') unless supported
end
def test_floating_pane
tmux.send_keys "seq 100 | #{fzf('--popup center,80% --margin 0')}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
# Border text is empty when no label is given
pane = floating_pane
refute_nil pane
assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format')
assert_equal '', pane_option(pane, '@fzf-border-label')
tmux.send_keys '99'
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Enter
assert_equal '99', fzf_output
end
def test_floating_pane_killed
tmux.send_keys "seq 100 | #{FZF} --popup bottom,50% --margin 0; echo code:$?", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
assert system('tmux', 'kill-pane', '-t', pane)
tmux.until { |lines| assert lines.any_include?('code:130') }
end
def test_floating_pane_border_label
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --border-label ' #fzf-label 100% '))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
# The label is held in a user option, not in the pane title, which is
# left to the user. '#' is doubled so that the label is drawn as it is
# written, instead of '#[...]' being taken as a style directive
assert_equal ' ##fzf-label 100% ', pane_option(pane, '@fzf-border-label')
assert_equal "\#{@fzf-border-label}", pane_option(pane, 'pane-border-format')
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
refute_equal ' #fzf-label 100% ', title.chomp
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
def test_floating_pane_change_border_label
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --bind 'space:change-border-label( #[fg=red] 100% )'))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
assert_equal '', pane_option(pane, '@fzf-border-label')
tmux.send_keys :Space
wait { assert_equal ' ##[fg=red] 100% ', pane_option(pane, '@fzf-border-label') }
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
# A program running in the pane owns the pane title, but not the label
def test_floating_pane_border_label_not_affected_by_title
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --border-label ' label ' --bind 'space:execute-silent(printf "\\033]2;hijacked\\033\\\\" > /dev/tty)'))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
pane = floating_pane
refute_nil pane
tmux.send_keys :Space
wait do
title = IO.popen(['tmux', 'display-message', '-p', '-t', pane, "\#{pane_title}"], &:read)
assert_equal 'hijacked', title.chomp
end
assert_equal ' label ', pane_option(pane, '@fzf-border-label')
tmux.send_keys :Enter
assert_equal '1', fzf_output
end
def test_floating_pane_become
tmux.send_keys "seq 100 | #{fzf(%(--popup center,80% --margin 0 --bind 'enter:become(echo became-{})'))}", :Enter
tmux.until { |lines| assert_equal 100, lines.item_count }
tmux.send_keys :Enter
assert_equal 'became-1', fzf_output
end
def test_explicit_border_falls_back_to_popup
# display-popup requires an attached client, which the test environment
# may not have; intercept it with a tmux shim on PATH
dir = Dir.mktmpdir
real = `command -v tmux`.chomp
shim = File.join(dir, 'tmux')
File.write(shim, <<~SH)
#!/bin/sh
if [ "$1" = display-popup ]; then
echo popup-used >&2
exit 0
fi
exec #{real.shellescape} "$@"
SH
FileUtils.chmod(0o755, shim)
tmux.send_keys "seq 100 | PATH=#{dir.shellescape}:$PATH #{FZF} --popup center --border rounded", :Enter
tmux.until { |lines| assert lines.any_include?('popup-used') }
refute floating_pane
ensure
FileUtils.remove_entry(dir) if dir
end
private
# stderr is merged in so that an unset option, which prints 'invalid
# option' to stderr and nothing to stdout, is not read as an empty value
def pane_option(pane, name)
IO.popen(['tmux', 'show-options', '-p', '-t', pane, '-v', name], err: %i[child out], &:read).chomp
end
def floating_pane
format = "\#{pane_id} \#{pane_floating_flag}"
lines = IO.popen(['tmux', 'list-panes', '-t', tmux.win, '-F', format]) { |io| io.readlines(chomp: true) }
lines.filter_map { |line| line.split.first if line.end_with?(' 1') }.first
end
end